Skip to content

Commit 528eb85

Browse files
committed
Merge branch 'worktree-agent-aac59d9f10256408d'
# Conflicts: # src/components/designs/genesis/scene.ts
2 parents bbb3c15 + 9d916cd commit 528eb85

9 files changed

Lines changed: 1220 additions & 14 deletions

File tree

scripts/genesis-stats.mjs

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,23 @@ function report(seed) {
187187
.map(([k, v]) => `${k} ${v}`)
188188
.join(', ');
189189
};
190+
const chests = map.chests ?? [];
191+
say(`\nCHESTS (${chests.length})`);
192+
for (const c of chests) {
193+
const p = uvOf(c);
194+
let road = Infinity;
195+
for (const r of map.roads) road = Math.min(road, polyDist(p, roadUV.get(r.id)));
196+
const site = map.sites.find((s) => s.id === c.siteId);
197+
const paid = site ? c.grantIds.filter((id) => site.buildings.some((b) => b.id === id)) : [];
198+
say(
199+
` ${c.id} ${c.reward.padEnd(8)} u,v ${f1(p[0]).padStart(6)},${f1(p[1]).padStart(6)} ` +
200+
`${c.biome.padEnd(6)} ${('“' + c.where + '”').padEnd(18)} ` +
201+
`road ${f1(road).padStart(5)} river ${f1(polyDist(p, river)).padStart(5)} ` +
202+
`${c.found ? 'FOUND' : 'buried'} by ${c.siteId}${site ? '' : ' (trimmed)'} ` +
203+
`pays ${paid.length}/${c.grantIds.length}${c.grantIds.length ? ' [' + c.grantIds.join(' ') + ']' : ''}`
204+
);
205+
}
206+
190207
say(`\nforest character: ${woodCharacter(seed)}`);
191208
say(`tree kinds: ${hist(map.trees, (t) => t.kind)}`);
192209
say(`scatter kinds: ${hist(map.scatter, (p) => p.kind)}`);
@@ -456,6 +473,78 @@ function checks(seed, map, scale = 1) {
456473
);
457474
}
458475

476+
/* (t) buried treasure: nought to two, in deep cover, well off every road,
477+
and paying out only where the day's roster could carry the reward.
478+
Chests are TERRAIN — the scale-identity half of that is in subsetChecks. */
479+
{
480+
const chests = map.chests ?? [];
481+
const bad = [];
482+
const ids = new Set();
483+
let minRoad = Infinity;
484+
let minRiver = Infinity;
485+
let minSite = Infinity;
486+
for (const c of chests) {
487+
if (ids.has(c.id)) bad.push(`duplicate id ${c.id}`);
488+
ids.add(c.id);
489+
const p = uvOf(c);
490+
const chunk = map.chunks.find(
491+
(k) => p[0] >= k.u0 && p[0] < k.u1 && p[1] >= k.v0 && p[1] < k.v1
492+
);
493+
if (!chunk) bad.push(`${c.id} sits outside every chunk`);
494+
else if (chunk.biome !== 'forest' && chunk.biome !== 'moor')
495+
bad.push(`${c.id} buried in ${chunk.biome}`);
496+
else if (chunk.biome !== c.biome) bad.push(`${c.id} says ${c.biome}, ground says ${chunk.biome}`);
497+
if (!c.where) bad.push(`${c.id} has nowhere to be`);
498+
for (const r of map.roads) minRoad = Math.min(minRoad, polyDist(p, roadUV.get(r.id)));
499+
minRiver = Math.min(minRiver, polyDist(p, river));
500+
for (const s of map.sites) minSite = Math.min(minSite, d2(p, uvOf(s)) - s.radius);
501+
for (const o of chests) if (o !== c && d2(p, uvOf(o)) < 12 - 1e-9) bad.push(`${c.id} crowds ${o.id}`);
502+
503+
if (!(c.siteIndex >= 1)) bad.push(`${c.id} names s0 as its finder`);
504+
if (c.siteId !== `s${c.siteIndex}`) bad.push(`${c.id} siteId/siteIndex disagree`);
505+
if (!['coin', 'charter', 'trinket'].includes(c.reward)) bad.push(`${c.id} reward ${c.reward}`);
506+
if (!c.found && c.grantIds.length) bad.push(`${c.id} was never found but pays out`);
507+
if (c.reward === 'trinket' && c.grantIds.length) bad.push(`${c.id} is a trinket that pays out`);
508+
if (c.reward === 'charter' && c.found && c.grantIds.length !== 1)
509+
bad.push(`${c.id} charter grants ${c.grantIds.length}`);
510+
for (const id of c.grantIds)
511+
if (!id.startsWith(`${c.siteId}-`)) bad.push(`${c.id} grant ${id} is not ${c.siteId}'s`);
512+
513+
const site = map.sites.find((s) => s.id === c.siteId);
514+
if (site && c.grantIds.length) {
515+
const here = c.grantIds.filter((id) => site.buildings.some((b) => b.id === id));
516+
// All of it, or (only where the pace control trimmed the town's roster
517+
// back past the grant) none of it. Never half a windfall.
518+
if (here.length && here.length !== c.grantIds.length)
519+
bad.push(`${c.id} pays out ${here.length}/${c.grantIds.length} at ${scale}x`);
520+
if (scale >= 1 && here.length !== c.grantIds.length)
521+
bad.push(`${c.id} reward trimmed at ${scale}x — a normal day must always pay`);
522+
if (c.reward === 'charter' && here.length && !site.buildings.some((b) => b.role === 'gildhall'))
523+
bad.push(`${c.id} charter paid out with no gildhall in ${site.id}`);
524+
}
525+
}
526+
// A gildhall exists for exactly one reason.
527+
for (const s of map.sites)
528+
for (const b of s.buildings)
529+
if (
530+
b.role === 'gildhall' &&
531+
!chests.some((c) => c.found && c.reward === 'charter' && c.grantIds.includes(b.id))
532+
)
533+
bad.push(`${b.id} is a gildhall nobody found a charter for`);
534+
535+
check(
536+
't chests buried off the road',
537+
chests.length <= 2 &&
538+
bad.length === 0 &&
539+
(!chests.length || (minRoad >= 2 && minRiver >= 3 && minSite >= 3)),
540+
`${chests.length} chest(s)` +
541+
(chests.length
542+
? `, nearest road ${f1(minRoad)}, river ${f1(minRiver)}, town rim ${f1(minSite)}`
543+
: '') +
544+
(bad.length ? ` — ${bad.join('; ')}` : '')
545+
);
546+
}
547+
459548
/* (s) no two towns wear the same accent */
460549
{
461550
const acc = map.sites.map((s) => s.accent);
@@ -490,7 +579,11 @@ function subsetChecks(seed) {
490579
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
491580

492581
// Terrain is scale-invariant, full stop.
493-
for (const field of ['river', 'riverWidth', 'chunks', 'trees', 'scatter', 'bounds', 'content', 'valleyName']) {
582+
// `chests` is in here on purpose: a chest is terrain. Where it is, what is in
583+
// it, who finds it and whether the day's digging turns it up at all are all a
584+
// pure function of the seed. Only the REWARD is allowed to notice the pace,
585+
// 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']) {
494587
const ok = maps.every((m) => same(m[field], maps[0][field]));
495588
check(`terrain: ${field} identical`, ok);
496589
}
@@ -576,6 +669,64 @@ function subsetChecks(seed) {
576669
return pass;
577670
}
578671

672+
/* ------------------------------ chest rates ------------------------------ */
673+
674+
/**
675+
* How often a day buries treasure, and how often the day's digging turns any of
676+
* it up. The number that matters is the last one: a discovery on roughly one in
677+
* three to one in two of the days that have a chest to find. Rarer than that and
678+
* nobody ever sees the feature; commoner and it stops being luck.
679+
*/
680+
function chestReport(n) {
681+
let chestDays = 0;
682+
let findDays = 0;
683+
let total = 0;
684+
let found = 0;
685+
const nHist = {};
686+
const tier = {};
687+
const paid = {};
688+
const where = {};
689+
for (let s = 1; s <= n; s++) {
690+
const map = generateMap(s);
691+
const chests = map.chests ?? [];
692+
nHist[chests.length] = (nHist[chests.length] ?? 0) + 1;
693+
if (chests.length) chestDays++;
694+
let f = 0;
695+
for (const c of chests) {
696+
total++;
697+
tier[c.reward] = (tier[c.reward] ?? 0) + 1;
698+
where[c.where] = (where[c.where] ?? 0) + 1;
699+
if (!c.found) continue;
700+
found++;
701+
f++;
702+
const site = map.sites.find((x) => x.id === c.siteId);
703+
const key =
704+
c.reward === 'trinket'
705+
? 'trinket (nothing owed)'
706+
: site && c.grantIds.every((id) => site.buildings.some((b) => b.id === id))
707+
? `${c.reward} paid`
708+
: `${c.reward} TRIMMED`;
709+
paid[key] = (paid[key] ?? 0) + 1;
710+
}
711+
if (f) findDays++;
712+
}
713+
const pct = (a, b) => (b ? `${((a / b) * 100).toFixed(1)}%` : '--');
714+
const hist = (o) =>
715+
Object.entries(o)
716+
.sort((a, b) => b[1] - a[1])
717+
.map(([k, v]) => `${k} ${v}`)
718+
.join(', ');
719+
console.log(`\nBURIED TREASURE over ${n} seeds`);
720+
console.log(` chests per day: ${hist(nHist)} (${chestDays} days with a chest, ${total} chests)`);
721+
console.log(` rewards buried: ${hist(tier)}`);
722+
console.log(` ground: ${hist(where)}`);
723+
console.log(` found at 1x: ${found}/${total} chests (${pct(found, total)}), ${hist(paid)}`);
724+
console.log(
725+
` => a discovery on ${findDays}/${chestDays} chest-days (${pct(findDays, chestDays)}), ` +
726+
`${pct(findDays, n)} of all days`
727+
);
728+
}
729+
579730
/* ---------------------------------- main --------------------------------- */
580731

581732
const argv = process.argv.slice(2);
@@ -618,6 +769,7 @@ if (argv[0] === '--sweep') {
618769
}
619770
console.log(`sweep ${Math.min(n, 60)} seeds subset stability: ${subFails.length ? `FAIL ${subFails.join(',')}` : 'ALL PASS'}`);
620771
if (subFails.length) allPass = false;
772+
chestReport(n);
621773
} else {
622774
const seeds = argv.length ? argv.map(Number) : [1, 42, 20260802];
623775
for (const s of seeds) {

scripts/genesis-timeline-stats.mjs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,35 @@ function makeFixture(seed, opts) {
191191
});
192192
}
193193

194+
// Buried treasure. `opts.chests` is a list of {reward, site, found, grants}:
195+
// the map's half of the feature, hand-authored so the timeline can be tested
196+
// on shapes gen.ts will not always produce — a find with nothing owed, a
197+
// reward the day's roster cannot carry, two chests in one valley.
198+
const chests = [];
199+
for (const [i, spec] of (opts.chests ?? []).entries()) {
200+
const si = Math.min(spec.site ?? 1, nSites - 1);
201+
const site = sites[si];
202+
const n = spec.found && spec.reward !== 'trinket' ? (spec.grants ?? 1) : 0;
203+
const grantIds = [];
204+
for (let k = 0; k < n; k++) {
205+
const b = site.buildings[site.buildings.length - 1 - k];
206+
if (b) grantIds.unshift(b.id);
207+
}
208+
chests.push({
209+
id: `ch${i}`,
210+
gx: 74 + i * 9,
211+
gy: 6 + i * 13,
212+
seed: 4000 + i * 13,
213+
biome: i % 2 ? 'moor' : 'forest',
214+
where: i % 2 ? 'the high moor' : 'the fir wood',
215+
reward: spec.reward,
216+
siteIndex: si,
217+
siteId: site.id,
218+
found: !!spec.found,
219+
grantIds: spec.orphanGrant ? [`${site.id}-b999`] : grantIds,
220+
});
221+
}
222+
194223
return {
195224
version: 1,
196225
seed,
@@ -208,6 +237,7 @@ function makeFixture(seed, opts) {
208237
bridges,
209238
trees,
210239
scatter,
240+
chests,
211241
valleyName: 'Ashmere Vale',
212242
};
213243
}
@@ -268,6 +298,7 @@ function serialize(snap) {
268298
roads: m(snap.roads),
269299
bridges: m(snap.bridges),
270300
props: s(snap.props),
301+
chests: m(snap.chests),
271302
founded: s(snap.founded),
272303
population: m(snap.population),
273304
log: snap.log,
@@ -554,6 +585,49 @@ function run(label, map, pace = 1) {
554585
for (const [id, n] of startCount) if (n > 1) bad(`(m) tree ${id} felled ${n} times`);
555586
for (const [id, n] of doneCount) if (n > 1) bad(`(m) tree ${id} chop-done ${n} times`);
556587

588+
/* (n2) buried treasure: one dig and one lid per chest, only for the chests
589+
the map actually buried and actually found, and always before the town
590+
starts spending the proceeds */
591+
const chests = map.chests ?? [];
592+
const chestById = new Map(chests.map((c) => [c.id, c]));
593+
const digT = new Map();
594+
const discT = new Map();
595+
for (const e of ev) {
596+
if (e.type !== 'dig' && e.type !== 'discover') continue;
597+
const into = e.type === 'dig' ? digT : discT;
598+
if (!chestById.has(e.chestId)) bad(`(n2) unknown chestId ${e.chestId}`);
599+
if (into.has(e.chestId)) bad(`(n2) ${e.chestId}: two ${e.type} events`);
600+
else into.set(e.chestId, e.t);
601+
}
602+
for (const [id, t] of discT) {
603+
const c = chestById.get(id);
604+
if (!c) continue;
605+
if (!c.found) bad(`(n2) ${id} was opened, but the map never found it`);
606+
if (!digT.has(id)) bad(`(n2) ${id} was opened without anybody digging for it`);
607+
else if (digT.get(id) > t + 1e-9)
608+
bad(`(n2) ${id}: dug at ${hhmm(digT.get(id))}, already open at ${hhmm(t)}`);
609+
// Cause, then effect: nothing a chest paid for may be staked out first.
610+
for (const gid of c.grantIds) {
611+
const sT = surveyT.get(gid);
612+
if (sT === undefined) continue;
613+
if (sT < t - 1e-9)
614+
bad(`(n2) ${id}: reward ${gid} surveyed at ${hhmm(sT)}, chest not open until ${hhmm(t)}`);
615+
}
616+
}
617+
for (const id of digT.keys())
618+
if (!discT.has(id) && !truncated) bad(`(n2) ${id} was dug for and never opened`);
619+
if (!truncated)
620+
for (const c of chests)
621+
if (c.found && !discT.has(c.id)) bad(`(n2) ${c.id} was found by the map but never opened`);
622+
if (!truncated) {
623+
for (const c of chests) {
624+
const st = end.chests.get(c.id);
625+
if (c.found && st !== 'open') bad(`(n2) ${c.id} is ${st ?? 'still buried'} at midnight`);
626+
if (!c.found && st !== undefined) bad(`(n2) ${c.id} should have stayed buried, is ${st}`);
627+
}
628+
}
629+
if (emptySnapshot(map).chests.size) bad(`(n2) chests are already disturbed at t=0`);
630+
557631
/* (k) day arc: first road mid-morning, ledger well spread, quiet close */
558632
const roadEvents = ev.filter((e) => e.type === 'road');
559633
const firstRoad = roadEvents.length ? roadEvents[0].t : null;
@@ -608,6 +682,18 @@ function run(label, map, pace = 1) {
608682
);
609683
const foundLine = [...foundT.entries()].map(([k, v]) => `${k}@${hhmm(v)}`).join(' ');
610684
if (foundLine) console.log(` foundings: ${foundLine}`);
685+
if (chests.length) {
686+
console.log(
687+
` chests: ` +
688+
chests
689+
.map(
690+
(c) =>
691+
`${c.id}/${c.reward}${c.grantIds.length ? `x${c.grantIds.length}` : ''}` +
692+
(discT.has(c.id) ? `@${hhmm(discT.get(c.id))}` : c.found ? '@NEVER' : ' buried')
693+
)
694+
.join(' ')
695+
);
696+
}
611697
for (const m of fails) console.log(` ! ${m}`);
612698
return { fails, ev, tl, map, lastBuild, pace };
613699
}
@@ -662,6 +748,39 @@ cases.push([
662748
makeFixture(5150, { buildings: [7, 6, 6, 6, 5], trees: 180, loopRoad: true, roadClears: 10 }),
663749
]);
664750
cases.push(['fixture: one site only', makeFixture(31337, { buildings: [5], trees: 18 })]);
751+
cases.push([
752+
'fixture: a coin hoard and a charter, both found',
753+
makeFixture(60613, {
754+
buildings: [5, 5, 4],
755+
trees: 90,
756+
roadClears: 6,
757+
chests: [
758+
{ reward: 'coin', site: 1, found: true, grants: 2 },
759+
{ reward: 'charter', site: 2, found: true, grants: 1 },
760+
],
761+
}),
762+
]);
763+
cases.push([
764+
'fixture: chests nobody finds, and a trinket somebody does',
765+
makeFixture(60614, {
766+
buildings: [4, 4],
767+
trees: 60,
768+
chests: [
769+
{ reward: 'coin', site: 1, found: false, grants: 2 },
770+
{ reward: 'trinket', site: 1, found: true },
771+
],
772+
}),
773+
]);
774+
cases.push([
775+
// the pace control trimmed the reward out of the day: the find is real, the
776+
// windfall never arrives, and the ledger has to cope
777+
'fixture: a find whose reward is not in the map',
778+
makeFixture(60615, {
779+
buildings: [4, 3],
780+
trees: 40,
781+
chests: [{ reward: 'coin', site: 1, found: true, grants: 2, orphanGrant: true }],
782+
}),
783+
]);
665784

666785
if (generateMap) {
667786
for (const day of ['2026-08-02', '2026-08-03', '2026-12-25', '2027-01-01']) {

src/components/designs/genesis/Catalog.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import type { Biome, RoofStyle, StructureRole, TreeKind, Vec2 } from './types';
4848
const ROLES: StructureRole[] = [
4949
'cottage', 'house', 'hall', 'barn', 'workshop', 'store', 'chapel', 'tower',
5050
'mill', 'granary', 'smithy', 'shed', 'bakery', 'brewhouse', 'homestead',
51+
'gildhall',
5152
];
5253

5354
/**
@@ -78,6 +79,9 @@ const PROP_POOLS = [
7879
'bush', 'rock', 'flowers', 'reeds', 'stump', 'crop', 'haystack', 'fenceL',
7980
'fenceR', 'shed', 'cart', 'crates', 'lumber', 'barrels', 'well', 'lamp',
8081
'sheep', 'campfire',
82+
// Buried treasure. Never a PropSpec — chests are their own list on the map —
83+
// but pooled all the same, so they belong on this shelf.
84+
'chest-buried', 'chest-closed', 'chest-open',
8185
];
8286

8387
/** Kinds not in the pools — built on demand by `propSprite()` in scene.ts. */
@@ -97,6 +101,9 @@ const EXTRA_PROPS = ['nameboard', 'signpost', 'stake', 'crane'];
97101
const GENERATED = new Set<string>([
98102
'bush', 'rock', 'stump', 'flowers', 'sheep', 'reeds', 'fenceL', 'fenceR',
99103
'well', 'nameboard', 'lamp', 'signpost', 'campfire', 'lumber',
104+
// The three states of a buried chest. gen.ts emits 0..2 ChestSpecs a day and
105+
// the timeline moves them between these sprites.
106+
'chest-buried', 'chest-closed', 'chest-open',
100107
...FLAVOUR_PROPS,
101108
]);
102109
const SCENE_ONLY = new Set<string>(['crane']);
@@ -274,6 +281,7 @@ const SPECIMEN: Record<StructureRole, RoleSpec> = {
274281
bakery: { w: 40, floors: 1, roof: 'gable', chimney: true },
275282
brewhouse: { w: 44, floors: 2, roof: 'hip', chimney: true },
276283
homestead: { w: 44, floors: 2, roof: 'gable', chimney: true, banner: true },
284+
gildhall: { w: 52, floors: 3, roof: 'gable', banner: true },
277285
};
278286

279287
function structure(

0 commit comments

Comments
 (0)