The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it, a magpie owns one street for six weeks of the year, and six point sources put a life off-screen. MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`, :8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre spine steps at yaw 0, each frame fully streamed, through the shell's own composer: worst draws 224 -> 225 (+1) worst tris 89,502 -> 93,298 (+3,796 of ~75k spare) sum draws over 102 frames 16,676 -> 16,663 (-13) The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time. `?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw / +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws. §1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION. `townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key, so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path. The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`, so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns. `gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had; making the slot addressable fixed it at the root, and both are selected now. E's three new grounds landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for. §1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands). THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37 chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin. Zero on every real town (no hoists ⇒ no mesh) and zero at night. §1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never mentions it. `swoopPos()` is exported and pure. The hat stays cut. MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on (citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%. §1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff, AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot; 3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability control — one manifest key renamed away, served from a third port — makes that source report `no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors. TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256 instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height, and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to furniture.js's own streetlight rule. LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The `magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch. furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000. Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38. 0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets, magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones. Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
241 lines
19 KiB
JavaScript
241 lines
19 KiB
JavaScript
// PROCITY Lane B — character.js (v8 WAVE 1 §1.2 — THE PER-TOWN CHARACTER VECTOR)
|
||
//
|
||
// Twenty-three real towns have shipped since R17 differing ONLY in street layout and shop names:
|
||
// same road skin, same footpath skin, same base plane, same sky, same ambience. This file is the
|
||
// difference — a ground palette, a sky bias and an ambience mix, one entry per town key.
|
||
//
|
||
// ── WHY THIS IS FREE ────────────────────────────────────────────────────────────────────────────
|
||
// `ground.js`'s `add()` merges each surface class into ONE mesh for the whole town regardless of
|
||
// town size (measured R37: exactly 5 meshes on both the synthetic default and katoomba_real).
|
||
// Swapping which skin name / tint that one mesh's ONE shared material carries is therefore
|
||
// **+0 draws and +0 triangles** — the strongest cost claim in the v8 packet, and it holds at any N.
|
||
//
|
||
// ── CLASSIC IS BYTE-IDENTICAL BY CONSTRUCTION, NOT BY FLAG (Fable's R38 binding correction 3) ────
|
||
// `townCharacter(null)` returns CLASSIC_CHARACTER: the exact literals `ground.js` and `lighting.js`
|
||
// have used since v1 (`djsim-road` / `djsim-footpath` / `grass` / `brickpave`, kerb 0x9a948c, and a
|
||
// null sky so `plan.sky || 'golden-arvo'` still decides). No tints. `skins.groundMat(name, null)`
|
||
// takes the *same cache key* it always did (`ground:<name>`), so the covenanted boot doesn't merely
|
||
// look the same — it constructs the same objects. There is no `if (classic)` anywhere in the ground
|
||
// path for this item; classic gets the frozen literals because a boot with no town key HAS no
|
||
// character, which is also what the synthetic default boot gets. **The pinned 292/300 frame cannot
|
||
// move for §1.2 by construction**, because the default boot never leaves this branch.
|
||
//
|
||
// ── THE VACUOUS ARM IS PER-TOWN, NOT PER-CORPUS (Fable's R38 binding correction 2) ──────────────
|
||
// The obvious implementation keys on `state` from `assets/towns/index.json` ("NSW → temperate,
|
||
// NT → red dust"). That is a silent trap: **`newtown_godverse` and `redhill_godverse` carry
|
||
// `state: ''`** (verified in the shipped index, 2 of 23), so a per-corpus arm would drop exactly
|
||
// those two into a default branch with nothing printed. Every one of the 23 keys therefore has its
|
||
// OWN entry below, and an unmatched key returns the frozen literals with `matched:false` set, so a
|
||
// gate (or the boot log) can say *which* town fell through instead of guessing.
|
||
//
|
||
// ── THE SKY IS A BASE SELECTION, NEVER `lighting.setSky` (binding correction 3) ─────────────────
|
||
// `lighting.js:53`'s `externalSky` is a ONE-WAY LATCH with no un-set path: once weather calls
|
||
// `setSky()` the dome is owned forever, and the dawn swap (R32) never fires again. A character sky
|
||
// pushed through `setSky` would therefore silently kill R32's dawn dome on all 23 towns. Instead
|
||
// `createLighting` takes a `sky` OPTION that feeds the *base* `skyName` selection — so weather still
|
||
// wins when it rains, and DAWN still swaps, exactly as they do today.
|
||
//
|
||
// ── PROCEDURAL-FIRST, AND THE UPGRADE ALREADY HAPPENED ──────────────────────────────────────────
|
||
// This table was written against the 10 grounds that shipped BEFORE this round, so it stands up on
|
||
// its own with no asset wave at all — including the two that had been in the manifest since v1 and
|
||
// were selected by **nothing**: `gravel` (use:"verge", now Castlemaine's goldfields tailings) and
|
||
// `reddust` (use:"outback", now Darwin and both Red Hills). Everything past those 10 is a per-class
|
||
// **tint**: a multiplier on the existing shared material (color × map, three's own path), so it is
|
||
// free at any N and needs no file to exist.
|
||
//
|
||
// Lane E's grounds then landed MID-ROUND and the use-if-ready upgrade is exactly what it was
|
||
// designed to be — a name change in this table, nothing else: `grass-dry` (Bendigo, Adelaide,
|
||
// Northbridge), `grass-temperate` (Daylesford, Katoomba, Bowral, Hobart, Launceston),
|
||
// `coastal-sand` (Fremantle, Newcastle). Their tints were re-picked against the delivered pixels
|
||
// rather than against the names, and if a JPEG ever goes missing `skins.js`'s flat class fallback
|
||
// (pre-multiplied by the tint) still lands somewhere sane, so `?noassets` is one code path.
|
||
//
|
||
// A note on WHY the two orphans were orphans, because it is not what it looks like (Lane E, R38):
|
||
// `gravel` and `reddust` carry `use` values — `"verge"` and `"outback"` — that name **no slot**
|
||
// `ground.js` has. Pre-R38 that file hardcoded exactly four skin names, so a manifest entry whose
|
||
// `use` was neither road/footpath/yard/plaza was not merely unselected, it was unselectable. This
|
||
// table fixes that at the root by making the SLOT the addressable thing: any skin name can be the
|
||
// road, the footpath, the base plane or the plaza, and the manifest's `use` string goes back to
|
||
// being documentation (nothing in `skins.groundMat` ever read it — it builds a filename). Both
|
||
// orphans are now selected: `gravel` is Castlemaine's base, `reddust` is Darwin's and both Red Hills'.
|
||
//
|
||
// Measured mean colour of every ground skin now in the manifest (16×16 box mean):
|
||
// djsim-road #364357 · asphalt1 #7d7663 · asphalt2 #544839 · djsim-footpath #b0b1b2
|
||
// footpath1 #948166 · footpath2 #958b74 · grass #876635 (already a DRY brown, not green)
|
||
// gravel #76715e · reddust #9e431b · brickpave #7b4c3f
|
||
// grass-dry #c0b7aa (E, R38 — pale tussock) · grass-temperate #496c3c (E — deep green)
|
||
// coastal-sand #cacbca (E — near-white beach sand, tinted down or a town glows)
|
||
|
||
// The six §1.1 point-source ambience layers, in table order. Exported so audio.js and any gate
|
||
// iterate the SAME list rather than re-deriving it (the R27 trap: two lists that agree until they
|
||
// don't).
|
||
export const AMBIENCE_SOURCES = Object.freeze([
|
||
'dog-bark-fence', 'mower-distant', 'radio-through-window', 'sprinkler', 'roller-door', 'magpie-carol',
|
||
]);
|
||
|
||
const MIX_NEUTRAL = Object.freeze({
|
||
'dog-bark-fence': 1, 'mower-distant': 1, 'radio-through-window': 1,
|
||
sprinkler: 1, 'roller-door': 1, 'magpie-carol': 1,
|
||
});
|
||
|
||
// The frozen literals. Every one of these is what the file that consumes it used before this round:
|
||
// ground.js's four skin names + its kerb colour, and a null sky so lighting keeps `plan.sky ||
|
||
// 'golden-arvo'`. Returned for a null/unknown town key ⇒ classic and the synthetic default are
|
||
// byte-identical BY CONSTRUCTION.
|
||
export const CLASSIC_CHARACTER = Object.freeze({
|
||
key: null,
|
||
label: 'classic',
|
||
matched: false,
|
||
ground: Object.freeze({ road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' }),
|
||
tint: Object.freeze({ road: null, foot: null, base: null, plaza: null }),
|
||
kerb: 0x9a948c,
|
||
sky: null,
|
||
mix: MIX_NEUTRAL,
|
||
});
|
||
|
||
// ── the 23 towns ────────────────────────────────────────────────────────────────────────────────
|
||
// One entry per key in `assets/towns/index.json`. `label` is the character in words (it is what a
|
||
// gate prints, and what the boot log prints), NOT a corpus bucket — two towns can share a label and
|
||
// still differ in tint, and a town can be the only member of its label. Mix values are multipliers
|
||
// on §1.1's per-source gain; 0 means "this sound does not belong in this town" and is a real answer
|
||
// (a mower in Fitzroy, a roller door in Daylesford).
|
||
const TOWNS = {
|
||
// ── VIC goldfields / spa country: dry summer paddock, gravel verge, hot white sky ──
|
||
bendigo_real: { label: 'goldfields dry', ground: { road: 'asphalt1', foot: 'footpath2', base: 'grass-dry', plaza: 'brickpave' },
|
||
tint: { road: '#c8bfa8', foot: '#e6dcc4', base: '#cbbf92', plaza: null }, sky: 'heat-haze',
|
||
mix: { 'dog-bark-fence': 1.1, 'mower-distant': 1.0, 'radio-through-window': 0.8, sprinkler: 1.2, 'roller-door': 0.7, 'magpie-carol': 1.3 } },
|
||
castlemaine_real: { label: 'goldfields gravel', ground: { road: 'asphalt1', foot: 'footpath1', base: 'gravel', plaza: 'brickpave' },
|
||
tint: { road: '#c0b8a2', foot: '#dcd0b4', base: '#d0c49c', plaza: null }, sky: 'heat-haze',
|
||
mix: { 'dog-bark-fence': 1.2, 'mower-distant': 1.1, 'radio-through-window': 0.7, sprinkler: 1.0, 'roller-door': 0.5, 'magpie-carol': 1.4 } },
|
||
daylesford_real: { label: 'spa country green', ground: { road: 'asphalt1', foot: 'footpath2', base: 'grass-temperate', plaza: 'brickpave' },
|
||
tint: { road: '#b4b2ac', foot: '#d6d2c2', base: '#d8e6cc', plaza: null }, sky: 'spring-puffy',
|
||
mix: { 'dog-bark-fence': 1.1, 'mower-distant': 1.2, 'radio-through-window': 0.6, sprinkler: 0.9, 'roller-door': 0.3, 'magpie-carol': 1.2 } },
|
||
|
||
// ── VIC regional cities: bitumen main street, grass verges, ordinary blue ──
|
||
geelong_real: { label: 'bay-city bitumen', ground: { road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' },
|
||
tint: { road: '#b6bcc4', foot: '#dfe2e0', base: '#b6c48c', plaza: null }, sky: 'endless-blue',
|
||
mix: { 'dog-bark-fence': 1.0, 'mower-distant': 0.9, 'radio-through-window': 1.0, sprinkler: 0.9, 'roller-door': 1.0, 'magpie-carol': 1.0 } },
|
||
|
||
// ── inner Melbourne: bluestone-dark road, worn concrete, almost no verge left ──
|
||
fitzroy_real: { label: 'inner-city bluestone', ground: { road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' },
|
||
tint: { road: '#8f97a6', foot: '#c9c8c4', base: '#8f9a72', plaza: '#c0b0a8' }, sky: 'spring-puffy',
|
||
mix: { 'dog-bark-fence': 0.9, 'mower-distant': 0.15, 'radio-through-window': 1.4, sprinkler: 0.3, 'roller-door': 1.4, 'magpie-carol': 0.6 } },
|
||
brunswick_real: { label: 'inner-city bluestone', ground: { road: 'djsim-road', foot: 'footpath2', base: 'grass', plaza: 'brickpave' },
|
||
tint: { road: '#949cab', foot: '#cfc9bb', base: '#98a075', plaza: '#c4b4aa' }, sky: 'spring-puffy',
|
||
mix: { 'dog-bark-fence': 1.0, 'mower-distant': 0.3, 'radio-through-window': 1.3, sprinkler: 0.4, 'roller-door': 1.3, 'magpie-carol': 0.7 } },
|
||
|
||
// ── NSW: sandstone sydney inner-west, mountains, southern highlands, hunter ──
|
||
newtown_real: { label: 'inner-west terrace', ground: { road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' },
|
||
tint: { road: '#9aa0ac', foot: '#d2cec4', base: '#8f9a70', plaza: '#c8b6a4' }, sky: 'spring-puffy',
|
||
mix: { 'dog-bark-fence': 0.9, 'mower-distant': 0.15, 'radio-through-window': 1.4, sprinkler: 0.3, 'roller-door': 1.2, 'magpie-carol': 0.6 } },
|
||
glebe_real: { label: 'inner-west terrace', ground: { road: 'djsim-road', foot: 'footpath1', base: 'grass', plaza: 'brickpave' },
|
||
tint: { road: '#9ba3af', foot: '#d8c9b0', base: '#93a074', plaza: '#c8b6a4' }, sky: 'endless-blue',
|
||
mix: { 'dog-bark-fence': 0.9, 'mower-distant': 0.2, 'radio-through-window': 1.3, sprinkler: 0.4, 'roller-door': 1.1, 'magpie-carol': 0.7 } },
|
||
marrickville_real: { label: 'inner-west brick', ground: { road: 'asphalt1', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' },
|
||
tint: { road: '#b0aaa0', foot: '#cdccc8', base: '#9aa678', plaza: null }, sky: 'spring-puffy',
|
||
mix: { 'dog-bark-fence': 1.2, 'mower-distant': 0.6, 'radio-through-window': 1.2, sprinkler: 0.7, 'roller-door': 1.3, 'magpie-carol': 0.9 } },
|
||
katoomba_real: { label: 'mountain wet-cold', ground: { road: 'asphalt2', foot: 'djsim-footpath', base: 'grass-temperate', plaza: 'brickpave' },
|
||
tint: { road: '#a8a4a0', foot: '#b8bcbe', base: '#bcd0c4', plaza: '#a89a94' }, sky: 'high-cirrus',
|
||
mix: { 'dog-bark-fence': 1.1, 'mower-distant': 0.7, 'radio-through-window': 0.9, sprinkler: 0.4, 'roller-door': 0.6, 'magpie-carol': 1.1 } },
|
||
bowral_real: { label: 'highlands green', ground: { road: 'asphalt1', foot: 'footpath2', base: 'grass-temperate', plaza: 'brickpave' },
|
||
tint: { road: '#b4b0a8', foot: '#dcd8cc', base: '#dcecd4', plaza: null }, sky: 'high-cirrus',
|
||
mix: { 'dog-bark-fence': 1.0, 'mower-distant': 1.3, 'radio-through-window': 0.6, sprinkler: 1.1, 'roller-door': 0.4, 'magpie-carol': 1.2 } },
|
||
newcastle_real: { label: 'coal-coast salt', ground: { road: 'djsim-road', foot: 'footpath2', base: 'coastal-sand', plaza: 'brickpave' },
|
||
tint: { road: '#a8b0ba', foot: '#e0d8c4', base: '#cec6ac', plaza: null }, sky: 'endless-blue',
|
||
mix: { 'dog-bark-fence': 1.1, 'mower-distant': 0.8, 'radio-through-window': 1.1, sprinkler: 0.8, 'roller-door': 1.2, 'magpie-carol': 0.9 } },
|
||
|
||
// ── QLD: red soil, tin and timber, big afternoon light ──
|
||
redhill_real: { label: 'brisbane red soil', ground: { road: 'asphalt1', foot: 'footpath1', base: 'reddust', plaza: 'brickpave' },
|
||
tint: { road: '#c2b2a2', foot: '#e2d0b4', base: '#d8b8a0', plaza: null }, sky: 'endless-blue',
|
||
mix: { 'dog-bark-fence': 1.3, 'mower-distant': 1.2, 'radio-through-window': 1.0, sprinkler: 1.1, 'roller-door': 0.8, 'magpie-carol': 1.2 } },
|
||
westend_real: { label: 'brisbane river-flat', ground: { road: 'asphalt1', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' },
|
||
tint: { road: '#bcb0a4', foot: '#d8d4cc', base: '#a8bc78', plaza: '#c8b4a4' }, sky: 'summer-storm',
|
||
mix: { 'dog-bark-fence': 1.1, 'mower-distant': 0.7, 'radio-through-window': 1.3, sprinkler: 0.9, 'roller-door': 1.1, 'magpie-carol': 1.0 } },
|
||
|
||
// ── SA / WA / NT ──
|
||
adelaide_real: { label: 'plains dry-grid', ground: { road: 'asphalt1', foot: 'djsim-footpath', base: 'grass-dry', plaza: 'brickpave' },
|
||
tint: { road: '#c4bcac', foot: '#ddd8cc', base: '#d2c69a', plaza: null }, sky: 'endless-blue',
|
||
mix: { 'dog-bark-fence': 1.0, 'mower-distant': 1.0, 'radio-through-window': 1.0, sprinkler: 1.3, 'roller-door': 1.0, 'magpie-carol': 1.1 } },
|
||
fremantle_real: { label: 'port limestone', ground: { road: 'asphalt1', foot: 'footpath2', base: 'coastal-sand', plaza: 'brickpave' },
|
||
tint: { road: '#cabfae', foot: '#eee4cc', base: '#ded0ac', plaza: '#d0bca8' }, sky: 'endless-blue',
|
||
mix: { 'dog-bark-fence': 1.0, 'mower-distant': 0.6, 'radio-through-window': 1.1, sprinkler: 0.9, 'roller-door': 1.2, 'magpie-carol': 1.0 } },
|
||
northbridge_real: { label: 'perth hot-grid', ground: { road: 'djsim-road', foot: 'footpath2', base: 'grass-dry', plaza: 'brickpave' },
|
||
tint: { road: '#aeb2b8', foot: '#e6dcc2', base: '#dcc894', plaza: null }, sky: 'heat-haze',
|
||
mix: { 'dog-bark-fence': 0.9, 'mower-distant': 0.4, 'radio-through-window': 1.3, sprinkler: 1.0, 'roller-door': 1.3, 'magpie-carol': 0.8 } },
|
||
darwin_real: { label: 'top-end red dust', ground: { road: 'asphalt1', foot: 'footpath1', base: 'reddust', plaza: 'brickpave' },
|
||
tint: { road: '#c8a894', foot: '#e0c4a0', base: null, plaza: '#c8a090' }, sky: 'monsoon',
|
||
mix: { 'dog-bark-fence': 1.2, 'mower-distant': 0.8, 'radio-through-window': 1.1, sprinkler: 0.6, 'roller-door': 1.0, 'magpie-carol': 0.5 } },
|
||
|
||
// ── TAS: cold light, deep green, dark wet bitumen ──
|
||
hobart_real: { label: 'cold-light southern', ground: { road: 'asphalt2', foot: 'djsim-footpath', base: 'grass-temperate', plaza: 'brickpave' },
|
||
tint: { road: '#9ea6ae', foot: '#b4bcc2', base: '#c0d4c8', plaza: '#a09aa0' }, sky: 'cold-front',
|
||
mix: { 'dog-bark-fence': 1.0, 'mower-distant': 0.8, 'radio-through-window': 0.9, sprinkler: 0.3, 'roller-door': 0.9, 'magpie-carol': 1.0 } },
|
||
launceston_real: { label: 'cold-light southern', ground: { road: 'asphalt2', foot: 'footpath2', base: 'grass-temperate', plaza: 'brickpave' },
|
||
tint: { road: '#a4a8ac', foot: '#c8ccc4', base: '#cce0c4', plaza: null }, sky: 'cold-front',
|
||
mix: { 'dog-bark-fence': 1.1, 'mower-distant': 1.0, 'radio-through-window': 0.8, sprinkler: 0.4, 'roller-door': 0.7, 'magpie-carol': 1.1 } },
|
||
|
||
// ── ACT ──
|
||
braddon_real: { label: 'capital planted', ground: { road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' },
|
||
tint: { road: '#a8aeb8', foot: '#d4d6d4', base: '#b0b878', plaza: null }, sky: 'high-cirrus',
|
||
mix: { 'dog-bark-fence': 0.8, 'mower-distant': 1.1, 'radio-through-window': 1.0, sprinkler: 1.2, 'roller-door': 1.0, 'magpie-carol': 1.5 } },
|
||
|
||
// ── the two GODVERSE towns. These are the pair carrying `state: ''` in the index — the exact
|
||
// reason correction 2 exists. They get their OWN entries here, keyed by town key, so neither
|
||
// can silently take a default branch. Each is deliberately the SAME character as its `_real`
|
||
// sibling (they are the same place with John's real shop data on top), and stating that as two
|
||
// separate entries is the point: it is a decision, not a fallthrough. ──
|
||
newtown_godverse: { label: 'inner-west terrace', ground: { road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' },
|
||
tint: { road: '#9aa0ac', foot: '#d2cec4', base: '#8f9a70', plaza: '#c8b6a4' }, sky: 'spring-puffy',
|
||
mix: { 'dog-bark-fence': 0.9, 'mower-distant': 0.15, 'radio-through-window': 1.4, sprinkler: 0.3, 'roller-door': 1.2, 'magpie-carol': 0.6 } },
|
||
redhill_godverse: { label: 'brisbane red soil', ground: { road: 'asphalt1', foot: 'footpath1', base: 'reddust', plaza: 'brickpave' },
|
||
tint: { road: '#c2b2a2', foot: '#e2d0b4', base: '#d8b8a0', plaza: null }, sky: 'endless-blue',
|
||
mix: { 'dog-bark-fence': 1.3, 'mower-distant': 1.2, 'radio-through-window': 1.0, sprinkler: 1.1, 'roller-door': 0.8, 'magpie-carol': 1.2 } },
|
||
};
|
||
|
||
// Also accept the two checked-in OSM fixtures (Lane A's `melbourne` / `katoomba` keys) so a
|
||
// `?plansrc=osm&town=katoomba` boot is not a silent fallthrough either.
|
||
TOWNS.katoomba = TOWNS.katoomba_real;
|
||
TOWNS.melbourne = TOWNS.fitzroy_real;
|
||
|
||
const freezeEntry = (key, e) => Object.freeze({
|
||
key,
|
||
label: e.label,
|
||
matched: true,
|
||
ground: Object.freeze({ ...e.ground }),
|
||
tint: Object.freeze({ road: null, foot: null, base: null, plaza: null, ...e.tint }),
|
||
kerb: e.kerb ?? CLASSIC_CHARACTER.kerb,
|
||
sky: e.sky || null,
|
||
mix: Object.freeze({ ...MIX_NEUTRAL, ...(e.mix || {}) }),
|
||
});
|
||
|
||
const CACHE = new Map();
|
||
|
||
/**
|
||
* townCharacter(townKey) → frozen character vector.
|
||
*
|
||
* `null` / `undefined` / an unmatched key ⇒ CLASSIC_CHARACTER, i.e. the literals this codebase has
|
||
* used since v1, with `matched:false` so the caller can PRINT which key fell through (correction 2:
|
||
* the vacuous arm is per-town and must never be silent). Pure, cached, no side effects, no fetch.
|
||
*/
|
||
export function townCharacter(townKey) {
|
||
if (!townKey) return CLASSIC_CHARACTER;
|
||
const key = String(townKey);
|
||
const hit = CACHE.get(key);
|
||
if (hit) return hit;
|
||
const e = TOWNS[key];
|
||
if (!e) return Object.freeze({ ...CLASSIC_CHARACTER, key, label: 'unmatched', matched: false });
|
||
const c = freezeEntry(key, e);
|
||
CACHE.set(key, c);
|
||
return c;
|
||
}
|
||
|
||
/** Every town key this file carries an explicit entry for (gates iterate this, not the index). */
|
||
export function characterKeys() { return Object.keys(TOWNS); }
|
||
|
||
/** One-line description for the boot log / a gate's report. */
|
||
export function describeCharacter(c) {
|
||
if (!c || !c.matched) return `character: none (${(c && c.key) || 'classic/synthetic'}) — frozen v1 literals`;
|
||
const g = c.ground;
|
||
return `character: ${c.label} — road ${g.road} · foot ${g.foot} · base ${g.base} · sky ${c.sky || 'plan default'}`;
|
||
}
|