PROCITY/web/js/world/character.js
m3ultra 4514cb94b5 Lane B R38 (v8 WAVE 1, INHABITATION): all four runtime items — the whole round costs +1 walked draw
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.
2026-08-03 18:53:26 +10:00

241 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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'}`;
}