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