From acf734a1e5506e5cae67e5aca44093595187be27 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Wed, 15 Jul 2026 18:33:53 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20A=20round=2012:=20v3.0-alpha=20gig=20lay?= =?UTF-8?q?er=20(=3Fgigs=3D1)=20=E2=80=94=20venue,=20plan.gigs,=20band=20n?= =?UTF-8?q?ames,=20closing-time=20debt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes the gig interface the whole round hangs off (A runs first). Everything is behind ?gigs=1 as a POST-HOC augmentation (gigs.js withGigs, applied by the selector), so generatePlan is untouched and all v2 goldens stay frozen (synthetic 0x3fa36874, osm melbourne 0x34cfdec0, katoomba 0x0f652510). Source-agnostic (synthetic + osm). - Venue: one 'pub' per town — a spine-end plain shop converted in place (keeps lot id/geometry, enterable), shop.venue=true, never the openLate landmark. Registry gains a weight-0 'pub' type (interior:'band_room' for Lane C) → never auto-placed → base plan byte-identical. - plan.gigs: 7 nightly {gigId,venueShopId,bandName,genreKey:'pubrock',night,startSeg:5, endSeg:5,cover}; cover ~half free / half $2-$10 (F charges at the door). - Band names: bandName(seed) generator (90s pub-rock) + OPTIONAL web/assets/custom_bands.json drop-in (priority into the pool; absent → pure generator, no fetch under ?noassets). - plan.posters: ~6 seeded {id,gigId,x,z,ry} for Lane B. - Closing-time debt paid: isOpen(shopOrHours,hour) exported (half-open law as code, D consumes). - map.html?gigs=1: venue ring + poster pins + gig panel line. Flags-off unchanged. Self-check ALL GREEN 3074/3074 (gigSuite ×2 sources, flags-off≡base, gig golden 0xa6ae5a5e). qa.sh --strict GREEN 6/6. Verified in-browser both modes. CITY_SPEC v3 section + LANE_A_NOTES handshake for C/D/B/F. Atomic pathspec commit (shared-tree race). Co-Authored-By: Claude Opus 4.8 --- docs/A-progress.md | 42 +++++++++++++- docs/CITY_SPEC.md | 49 ++++++++++++++++ docs/LANES/LANE_A_NOTES.md | 54 ++++++++++++++++++ web/assets/custom_bands.json | 15 +++++ web/js/citygen/gigs.js | 108 +++++++++++++++++++++++++++++++++++ web/js/citygen/index.js | 11 +++- web/js/citygen/names.js | 22 +++++++ web/js/citygen/plan.js | 11 ++++ web/js/citygen/selfcheck.js | 49 +++++++++++++++- web/js/citygen/wordlists.js | 27 +++++++++ web/js/core/registry.js | 13 ++++- web/map.html | 34 ++++++++++- 12 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 web/assets/custom_bands.json create mode 100644 web/js/citygen/gigs.js diff --git a/docs/A-progress.md b/docs/A-progress.md index 62c9258..4d045af 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,9 +1,47 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 2–9 closed** (see below). -`qa.sh --strict` GREEN.* +*Status: **all deliverables landed; self-check all-green (3074/3074); rounds 2–12 closed** (see below). +`qa.sh --strict` GREEN. v3.0-alpha gig interface published.* *Date: 2026-07-15 · owner: PROCITY-A (Opus 4.8)* +## Round 12 (2026-07-15) — v3.0-alpha GIG LAYER (`?gigs=1`): venue + gigs + band names + closing-time debt + +Lane A ran **first** this round (the whole round hangs off the `plan.gigs` handshake). Everything is +behind `?gigs=1` (prime flag law): **flags-off is byte-identical — all v2 goldens frozen** (synthetic +`0x3fa36874`, osm melbourne `0x34cfdec0`, katoomba `0x0f652510`). Self-check **ALL GREEN 3074/3074** +(+ the whole gig suite ×2 sources), `qa.sh --strict` GREEN (6/6). Verified in-browser both modes. + +**Architecture — a post-hoc augmentation layer, not a generator change.** `web/js/citygen/gigs.js` +`withGigs(plan, seed, opts)` takes a finished plan and adds the venue + gigs + posters; the selector +(`index.js generatePlanFor`) applies it **only** when `opts.gigs`. So `generatePlan` is untouched +(goldens can't move) and the layer is source-agnostic (synthetic + osm both get gigs). + +Delivered (all behind the flag): +1. **Venue** — one `pub` per town: a spine-END plain shop converted in place to `type:'pub'` (keeps lot + id/geometry ⇒ enterable, no overlap), `shop.venue=true`, hours `[17,23]`. **Never** the openLate shop. + Registry gains a weight-0 `pub` type (facades `djsim-pub`+`federation`, `interior:'band_room'` for + Lane C, fittings). Weight-0 ⇒ never auto-placed ⇒ base plan unchanged. +2. **`plan.gigs`** — 7 nightly entries `{gigId, venueShopId, bandName, genreKey:'pubrock', night, startSeg:5, + endSeg:5, cover}`; `cover` seeded ~half free / half `$2–$10`. F picks tonight's + charges at the door. +3. **Band names** — `bandName(seed)` generator (90s Aussie pub-rock wordlists) **+ the drop-in** + `web/assets/custom_bands.json` (`{bands:[…]}`, ships with 10 examples incl. "The Feral Galahs"): + present → priority into the pool (up to 7 surface); absent → pure generator, zero fetch (`?noassets`). + Deterministic given (seed, file). The shell (F) does the async load and passes `customBands` in. +4. **`plan.posters`** — ~6 seeded poster placements `{id, gigId, x, z, ry}` (venue + spine) for Lane B. +5. **Closing-time debt PAID** — `isOpen(shopOrHours, hour)` exported from the barrel: the canonical + half-open law (`open ≤ h < close`) as code, for D's gig-night patronage surge (was prose in round 9). +6. **Debug map** — `map.html?gigs=1` shows the venue ring + "♪ PUB" label, poster pins, and a panel gig + line ("♪ The Thornbury Hotel · tonight: Screaming Utes · $10 cover · 7 nights · 6 posters"). Loads the + band drop-in for the view. Flags-off map is unchanged (adds a benign `pub (0)` legend row). + +New self-check coverage: `gigSuite` runs the full structural suite (venue-aware — the pub is exempt +from the single-late-shop rule via `!sh.venue`, a no-op on base plans) + gig invariants (one pub, gig +schema, cover ∈ {0}∪[2,10], band names resolved, posters ref real gigs), on synthetic + both osm towns; +a `?gigs`-off≡base byte-identity check per seed; a custom-band priority + determinism check; and a +**gig golden `0xa6ae5a5e`**. CITY_SPEC gained a "CityPlan v3 — gig layer" section; handshake interfaces +for C/D/B/F are in `LANE_A_NOTES.md`. Sample seed 20261990 → venue "The Thornbury Hotel" (a converted +spine-end pawn shop), openLate video preserved. + ## Round 9 (2026-07-15) — freeze the v2 contract + closing-time ruling (ship round) Small round; the most "done" lane makes it official. diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index 09dba25..c5ccbd4 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -204,6 +204,53 @@ Interiors are **not** street geometry. Walking into a doorway triggers exactly where you stood. Street windows show a cheap fake (tinted glass + emissive warm glow at night in v1; interior-mapping shader is a stretch goal). +## CityPlan v3 — gig layer (`?gigs=1`) — v3.0-alpha + +> **Prime flag law.** The gig layer is a **post-hoc augmentation** applied by the selector only when +> `?gigs=1`. With the flag OFF, `generatePlanFor(seed, src)` returns the byte-identical v2 base plan — +> **all v2 goldens stay frozen**. So this section adds fields that exist *only on gig-on plans*; it does +> not touch the v2 contract above. Deterministic from `(citySeed, customBands)`; works on synthetic AND +> osm towns. Node-pure (no THREE, no runtime fetch — the shell loads the band drop-in and passes it in). + +**Turning it on** (Lane F): `generatePlanFor(seed, source, { gigs:true, customBands }) → plan` with the +gig fields below (else the base plan, untouched). `generatePlanFor` stays synchronous — F fetches +`custom_bands.json` in the bootstrap and passes the array. `withGigs(plan, seed, opts)` is also exported. + +**The venue (one `pub` per town).** A spine-END plain `shop` is converted **in place** to `type:'pub'` +(keeps its lot id + geometry ⇒ enterable like any shop, no overlap), tagged `shop.venue === true`. It is +**never** the openLate landmark (the video survives; the town still has exactly one openLate shop). The +registry gains a `pub` type (facades, `interior:'band_room'`, fittings, hours `[17,23]`) with **no +district weights** so it is never auto-placed. Venue hours `[17,23]` keep it open through DUSK+NIGHT so +the gig runs and D's patronage surge fills it — and it drains at close per the closing-time ruling above. +The pub is the *only* gig-plan shop allowed to close ≥22 besides the openLate video (selfcheck exempts it). + +**`plan.gigs`** — the nightly schedule (present only on gig-on plans): +``` +gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover } ] +``` +- 7 nightly entries at the one venue; `night` = 0-based index, **F selects tonight's** (alpha: `gigs[0]`). +- `venueShopId` = the pub shop id. `genreKey = 'pubrock'` (alpha's one genre). `bandName` — see drop-in. +- `startSeg = 5` (NIGHT / 22:00), `endSeg = 5`. F's state machine: `quiet → doors (startSeg−1 = DUSK) + → on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.) +- `cover` — integer: **~half `0` (free), the rest `$2–$10`**. F debits the wallet at the door; free = walk in. + +**`plan.posters`** — gig advertising (Lane B renders): `posters: [ { id, gigId, x, z, ry } ]` (~6 — +one on the venue frontage, a seeded few along the spine, on poles/walls B already streams). B places E's +poster skin at `(x, z)` rotated `ry`; F wires "poster → tonight's-gig discoverability". + +**Band names + the drop-in.** `bandName(seed)` generates 90s-Aussie pub-rock names from wordlists. +`web/assets/custom_bands.json` is an **OPTIONAL** editable drop-in (schema `{ "bands": ["The Feral +Galahs", …] }`, Lane-A-owned per ROUND12): present → those names get **priority** on the schedule +(shuffled by seed, so a town surfaces up to its 7 nightly slots of them); **absent → pure generator, zero +errors, zero fetch** (the `?noassets=1` path). Deterministic given `(seed, file contents)`. John edits the +file and reloads. + +**Closing-time law as code (v2 debt paid).** `isOpen(shopOrHours, hour)` is exported from the barrel — the +canonical half-open predicate (`open ≤ hour < close`) for D's gig-night patronage surge and everyone else. + +**Gig golden** (guards gig-layer drift; pure-generator path, no custom file), seed `20261990` synthetic: +`0xa6ae5a5e`. Asserted by `selfcheck.js` alongside the frozen v2 goldens; changing gig output re-pins it. + ## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read) > **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for @@ -232,6 +279,7 @@ night in v1; interior-mapping shader is a stretch goal). | `milkbar` | djsim-milkbar, brickveneer | counter + freezer | counter, fridge, magazine rack, freezer | | `dept` (anchor) | terrazzo, djsim-dept | grand hall | mixed sections, escalator prop, glass case, counter | | `stall` (market) | market, corrugated | open stall | trestle tables, crates | +| `pub` (v3 venue) | djsim-pub, federation | band_room | bar counter, stage, PA stack, tables, stools | ## NPC contract (Lane D) @@ -268,6 +316,7 @@ night in v1; interior-mapping shader is a stretch goal). | `web/js/interiors/*`, `web/interior_test.html` | Lane C | | `web/js/citizens/*`, `web/citizens_test.html`, `web/models/*` | Lane D | | `pipeline/*`, `web/assets/*` + `web/assets/manifest.json` | Lane E | +| `web/assets/custom_bands.json` | Lane A (v3 band drop-in — carve-out from `web/assets/*`, per ROUND12) | | `web/package.json` | scaffold/shared — `{"type":"module"}` only, added by Lane A | | `docs/*` | everyone, additively | diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index 7f9133d..8b2e9c9 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,60 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 12 (2026-07-15) — v3.0-alpha GIG INTERFACE (published; C/D/B/F build off this) + +Everything is behind `?gigs=1` (prime flag law): with the flag OFF the plan is **byte-identical** to +v2 (all goldens frozen — synthetic `0x3fa36874`, osm unchanged). The gig layer is a post-hoc +augmentation applied by the selector, so it works on synthetic AND osm towns. + +### How the gig layer turns on (Lane F wires this) +```js +// F's shell bootstrap, when ?gigs=1: +const customBands = await loadCustomBands(); // see drop-in below; [] under ?noassets or if absent +const plan = citygen.generatePlanFor(seed, src, { town, gigs: true, customBands }); +``` +`generatePlanFor(seed, source, { gigs:true, customBands })` → base plan + `withGigs`. Without +`gigs:true`, the base plan is returned untouched. `generatePlanFor` stays **synchronous** — F does the +async `custom_bands.json` fetch in the bootstrap and passes the array in. + +### `plan.gigs` — the nightly schedule (NEW, present only when gigs on) +``` +plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ] +``` +- 7 nightly entries at the one venue. `night` = 0-based index; **F picks tonight's** (alpha: `gigs[0]`). +- `venueShopId` = the pub shop's id. `genreKey` = `'pubrock'` (alpha's one genre; E ships that bed). +- `startSeg=5` (NIGHT, 22:00) `endSeg=5`. F's state machine: `quiet → doors (startSeg−1 = DUSK seg4) + → on (seg5) → done`. Segment→hour map is lighting.js (`0 DAWN … 5 NIGHT`). +- `cover`: integer. **~half are 0 (free, walk in); the rest $2–$10** (F debits the wallet at the door). + +### The venue (one `pub` per town) +- A spine-END plain shop is converted in place to `type:'pub'` (keeps its lot id + geometry ⇒ no + overlap, enterable like any shop). Tagged **`shop.venue === true`**; also `plan.shops.find(s=>s.type==='pub')`. +- **Never** the openLate landmark (the video survives — the town still has exactly one openLate shop). +- Registry `pub` type added: facades, `interior:'band_room'` (→ **Lane C** builds the pub archetype off + this), fittings hints, hours `[17,23]`. It has **no district weights** so it's never auto-placed. +- Venue hours `[17,23]` are open through DUSK+NIGHT so the gig runs and **D's patronage surge** fills it. + +### `plan.posters` — gig advertising (NEW; **Lane B** renders) +``` +plan.posters = [ { id, gigId, x, z, ry }, … ] // ~6, advertising tonight's gig (gigs[0]) +``` +One on the venue frontage + a seeded few along the spine, on the poles/walls B already streams. B +places E's poster skin at `(x,z)` rotated `ry`. F wires "poster → tonight's-gig discoverability". + +### → Lane D: the closing-time debt is PAID — consume `isOpen` +`citygen.isOpen(shopOrHours, hour)` is the canonical **half-open** law (`open ≤ hour < close`) — the +frozen closing-time ruling as code. Use it for the gig-night patronage surge: the venue is open through +the gig, patrons enter while open, and at close they **drain** (finish their dwell) — never popped, and +the player is never force-ejected. Your `sim.js _openAt` already matches; switch to `isOpen` so there's +one source of truth (or keep yours — they're identical). + +### → John: the band-name drop-in (`web/assets/custom_bands.json`) +Edit the file, reload — your names get **priority** on the town's posters. Schema: +`{ "bands": ["The Feral Galahs", …] }`. **Delete it and the generator takes over** (no errors, no fetch +under `?noassets=1`). Deterministic given (seed, file contents). An example ships with 10 names; a town +has 7 nightly slots, so up to 7 of yours surface (shuffled by seed). + ## Round 9 (2026-07-15) ### → Lane D/F: closing-time occupant ruling (you already built it right — this ratifies + freezes it) diff --git a/web/assets/custom_bands.json b/web/assets/custom_bands.json new file mode 100644 index 0000000..69a90bc --- /dev/null +++ b/web/assets/custom_bands.json @@ -0,0 +1,15 @@ +{ + "_comment": "OPTIONAL band-name drop-in (v3 gigs). Edit the list and reload — these names get PRIORITY on the town's gig posters. Delete this file and the generator takes over (no errors). Schema: { \"bands\": [ \"name\", ... ] }. Owned by Lane A per ROUND12.", + "bands": [ + "The Feral Galahs", + "Chunderbolt", + "Screaming Utes", + "The Rissoles", + "Dazza & The Dropbears", + "Sunburnt Country", + "The Flannel Shirts", + "Amyl & The Servos", + "Boganvillea", + "The Dead Ringers" + ] +} diff --git a/web/js/citygen/gigs.js b/web/js/citygen/gigs.js new file mode 100644 index 0000000..ebaa58f --- /dev/null +++ b/web/js/citygen/gigs.js @@ -0,0 +1,108 @@ +// PROCITY CityGen — v3 GIG LAYER (?gigs=1). Post-hoc augmentation: takes a finished CityPlan and +// adds the venue (one pub), the nightly gig schedule (`plan.gigs`), and poster assignments +// (`plan.posters`). Applied ONLY when the flag is on (selector: generatePlanFor(..,{gigs:true})), +// so the base plan stays byte-identical and the frozen goldens don't move (CITY_SPEC prime flag law). +// +// Source-agnostic: works on synthetic AND osm plans (it only reads shops/lots/edges). Deterministic +// from citySeed (+ optional custom band names). NO THREE, no network — pure data (CITY_SPEC law). + +import { rng, seedFor, mulberry32, pick, shuffle } from '../core/prng.js'; +import { SHOP_TYPES } from '../core/registry.js'; +import { shopName, bandName } from './names.js'; + +const r2 = v => Math.round(v * 100) / 100; +const r4 = v => Math.round(v * 10000) / 10000; + +// Alpha model: one genre, a night gig (doors at DUSK seg4, on during NIGHT seg5 — see lighting.js +// segment→hour map). A week of nightly entries at the one venue; F's clock picks tonight's. +const NIGHTS = 7; +const GIG_START_SEG = 5; // NIGHT (22:00) — gig is "on" +const GIG_END_SEG = 5; // single coarse night segment; F does doors at startSeg−1 (DUSK) +const GENRE = 'pubrock'; // the alpha genre (Lane E ships the pub-rock live bed) + +// Pick the venue: prefer a spine-END plain shop (a 'shop'-use lot fronting a 'main' edge, farthest +// from the origin — a destination you follow the sound to), NEVER the openLate landmark. Fallback +// for osm / no-spine towns: the farthest-from-centre plain shop. Seeded pick among the farthest few. +function pickVenue(plan, citySeed) { + const lotById = new Map(plan.lots.map(l => [l.id, l])); + const candidates = plan.shops.filter(s => { + if (s.openLate) return false; // must not displace the night landmark + const lot = lotById.get(s.lot); + return lot && lot.use === 'shop'; // convert a plain shop → keeps SOLID + geometry + }); + if (!candidates.length) return null; + const mainEdges = new Set(plan.streets.edges.filter(e => e.kind === 'main').map(e => e.id)); + const dist2 = s => { const l = lotById.get(s.lot); return l.x * l.x + l.z * l.z; }; + let pool = candidates.filter(s => mainEdges.has(lotById.get(s.lot).frontEdge)); + if (!pool.length) pool = candidates; // osm / no spine → any plain shop + pool = pool.slice().sort((a, b) => dist2(b) - dist2(a) || a.id - b.id); // farthest first, id tiebreak + const topN = pool.slice(0, Math.min(5, pool.length)); + return pick(rng(citySeed, 'venue', 0), topN); +} + +// Band names across the nights: John's OPTIONAL custom names (from web/assets/custom_bands.json, +// loaded by the shell and passed as opts.customBands) get PRIORITY — shuffled deterministically and +// filled first, so a town surfaces up to NIGHTS of them — and the rest are generated. Deterministic +// given (citySeed, customBands). Absent/empty custom list ⇒ pure generator (the ?noassets path). +function assignBands(citySeed, nGigs, customBands) { + const custom = (Array.isArray(customBands) ? customBands : []) + .filter(b => typeof b === 'string' && b.trim()).map(b => b.trim()); + const shuffled = custom.length ? shuffle(rng(citySeed, 'bands', 0), custom.slice()) : []; + const out = []; + for (let i = 0; i < nGigs; i++) { + out.push(i < shuffled.length ? shuffled[i] : bandName(seedFor(citySeed, 'band', i))); + } + return out; +} + +// Posters advertising tonight's gig (night 0), near the venue + along the central streets B already +// streams (poles/walls). Modest count for the one-pub alpha; positions seeded + deterministic. B +// renders E's poster skin at (x,z) rotated ry; F wires "poster → tonight's gig" discoverability. +function buildPosters(plan, citySeed, venue, gig) { + const posters = []; + const add = (x, z, ry) => posters.push({ id: posters.length, gigId: gig.gigId, x: r2(x), z: r2(z), ry: r4(ry) }); + const vlot = plan.lots.find(l => l.id === venue.lot); // the venue's LOT carries the coords (shop doesn't) + if (vlot) add(vlot.x, vlot.z, vlot.ry); // one on the venue frontage itself + const mainNodeIds = new Set(); + for (const e of plan.streets.edges) if (e.kind === 'main') { mainNodeIds.add(e.a); mainNodeIds.add(e.b); } + let cand = plan.streets.nodes.filter(n => mainNodeIds.has(n.id)); + if (cand.length < 4) cand = plan.streets.nodes.slice(); + const chosen = shuffle(rng(citySeed, 'posters', 0), cand.slice()).slice(0, Math.min(5, cand.length)); + for (const n of chosen) add(n.x + 2, n.z, Math.atan2(n.x, n.z)); // small verge offset; face town centre + return posters; +} + +// Public: augment a finished plan with the gig layer. Mutates + returns `plan` (caller passes a fresh +// plan). Adds `plan.gigs`, `plan.posters`, and converts one shop into the `pub` venue in place. +export function withGigs(plan, citySeed, opts = {}) { + citySeed = citySeed >>> 0; + const venue = pickVenue(plan, citySeed); + if (!venue) { plan.gigs = []; plan.posters = []; return plan; } // degenerate town (no plain shops) + + // Convert the chosen shop into the pub venue, in place (keeps its lot id + geometry ⇒ no overlap). + const reg = SHOP_TYPES.pub; + const vseed = seedFor(citySeed, 'venue', venue.id); + const vr = mulberry32(vseed); + venue.type = 'pub'; + venue.facadeSkin = pick(vr, reg.facades); + const nm = shopName(vseed, 'pub'); + venue.name = nm.name; venue.sign = nm.sign; + venue.storeys = Math.max(venue.storeys | 0, 2); // a corner hotel reads as ≥2 storeys + venue.hours = [reg.hours.open, reg.hours.close]; // [17,23] — open into NIGHT so the gig runs + venue.venue = true; // easy lookup alongside venueShopId + + // Nightly gig schedule at the venue. + const bands = assignBands(citySeed, NIGHTS, opts.customBands); + plan.gigs = []; + for (let n = 0; n < NIGHTS; n++) { + const gr = mulberry32(seedFor(citySeed, 'gig', n)); + const cover = gr() < 0.5 ? 0 : 2 + ((gr() * 9) | 0); // ~half free, else integer $2–$10 + plan.gigs.push({ + gigId: n, venueShopId: venue.id, bandName: bands[n], genreKey: GENRE, + night: n, startSeg: GIG_START_SEG, endSeg: GIG_END_SEG, cover, + }); + } + + plan.posters = buildPosters(plan, citySeed, venue, plan.gigs[0]); + return plan; +} diff --git a/web/js/citygen/index.js b/web/js/citygen/index.js index 107871c..53ce1e4 100644 --- a/web/js/citygen/index.js +++ b/web/js/citygen/index.js @@ -7,9 +7,10 @@ // (Originated as a Lane F integration shim because the shell imports ./js/citygen/index.js while the // generator ships as plan.js; adopted here as the real Lane A entry point, per the shim's own note.) -export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap } from './plan.js'; -export { shopName, townName } from './names.js'; +export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js'; +export { shopName, townName, bandName } from './names.js'; export { generatePlanOSM, osmTownKeys } from './plan_osm.js'; +export { withGigs } from './gigs.js'; // planSource selector (round 6; multi-town round 8). Default 'synthetic' → the byte-identical // golden-hash generator; 'osm' → the real-data fixture importer. Lane F wires ?plansrc=osm (+ optional @@ -17,8 +18,12 @@ export { generatePlanOSM, osmTownKeys } from './plan_osm.js'; // ran. Unknown source → synthetic; unknown town → the default town. import { generatePlan as _synth } from './plan.js'; import { generatePlanOSM as _osm } from './plan_osm.js'; +import { withGigs as _withGigs } from './gigs.js'; export function generatePlanFor(seed, source = 'synthetic', opts = {}) { - return source === 'osm' ? _osm(seed, opts.town, opts) : _synth(seed); + const plan = source === 'osm' ? _osm(seed, opts.town, opts) : _synth(seed); + // v3 gig layer, gated: ?gigs=1 → shell passes { gigs:true, customBands } and we augment; otherwise + // the base plan is returned untouched (byte-identical, goldens frozen — CITY_SPEC prime flag law). + return opts.gigs ? _withGigs(plan, seed, opts) : plan; } // Convenience default so `citygen.default` also resolves to the (synthetic) generator. diff --git a/web/js/citygen/names.js b/web/js/citygen/names.js index 820f54e..a77b1de 100644 --- a/web/js/citygen/names.js +++ b/web/js/citygen/names.js @@ -25,6 +25,10 @@ function tok(r, t) { case 'First': return pick(r, W.FIRSTNAME); case 'Animal': return pick(r, W.ANIMAL); case 'Init': return initials(r); + case 'Pub': return pick(r, W.PUB_NAME); // v3: full pub name ("The Royal") + case 'BandAdj': return pick(r, W.BAND_ADJ); // v3: band-name banks + case 'BandNoun':return pick(r, W.BAND_NOUN); + case 'BandOne': return pick(r, W.BAND_ONEWORD); default: return t; } } @@ -73,8 +77,17 @@ const PATTERNS = { '{First}’s Stall', '{Word} Stall', '{Adj} Bits', 'Trash & Treasure', '{Animal} Wares', 'Bric-a-Brac', 'Sunday Stall', ], + pub: [ // v3 venue (?gigs=1): the town pub/hotel + '{Pub}', 'The {Suburb} Hotel', 'The {Family} Hotel', 'The {Adj} Arms', '{Pub}', + ], }; +// ── band-name patterns (v3 gigs, 90s Aussie pub-rock) ────────────────────────────── +const BAND_PATTERNS = [ + 'The {BandAdj} {BandNoun}', '{BandAdj} {BandNoun}', 'The {BandNoun}', '{BandOne}', + '{First} & The {BandNoun}', 'The {Animal}s', '{BandAdj} {Animal}', 'The {BandOne}s', +]; + // ── short signboard forms (what actually fits above the door) ────────────────────── const SIGN_PATTERNS = { record: ['{Word}', 'RECORDS', 'VINYL', '{Suburb} SOUND'], @@ -86,6 +99,7 @@ const SIGN_PATTERNS = { milkbar: ['MILK BAR', '{Family}', 'DELI', 'CORNER STORE'], dept: ['{Suburb}', 'EMPORIUM', '{Family} & CO'], stall: ['STALL', 'BITS', 'BRIC-A-BRAC', 'WARES'], + pub: ['HOTEL', 'THE ROYAL', '{Suburb} HOTEL', 'PUB'], }; // Public: name a shop from its stable seed + type. Returns { name, sign }. @@ -103,6 +117,14 @@ export function shopName(seed, type) { return { name, sign }; } +// Public (v3 gigs): generate a band name from a stable seed. Pure — the OPTIONAL custom-name +// drop-in is mixed in by gigs.js (which calls this only when it needs a fresh generated name), so +// this stays node-testable and fetch-free. Deterministic: same seed ⇒ same name, forever. +export function bandName(seed) { + const r = mulberry32((seed ^ 0x1b56c4e9) >>> 0); // decorrelate from shop/town seed uses + return fill(r, pick(r, BAND_PATTERNS)).replace(/\s+/g, ' ').trim(); +} + // Public: name the town from the city seed. export function townName(citySeed) { const r = mulberry32((citySeed ^ 0x2545f491) >>> 0); diff --git a/web/js/citygen/plan.js b/web/js/citygen/plan.js index 6d55d32..84d455b 100644 --- a/web/js/citygen/plan.js +++ b/web/js/citygen/plan.js @@ -20,6 +20,17 @@ const clampHour = h => Math.max(0, Math.min(23, h | 0)); // and the `openLate` field are equivalent, and both identify that single shop (Lane F night gate). const LATE_HOUR = 22; +// Canonical CLOSING-TIME LAW (frozen v2 ruling; Lane D/F consume this — the "debt" made code). +// Openness is HALF-OPEN: a shop is open for `open ≤ hour < close`, so a shop closing at 17 is shut +// AT 17:00. New entry is refused when closed; occupants already inside finish their dwell and drain +// (never popped); the player is never force-ejected. `hour` is a 0–24 float (Lane F's currentHour()). +// Pass a shop (or any { hours:[open,close] }). No hours ⇒ treated as always open. +export function isOpen(shopOrHours, hour) { + const h = Array.isArray(shopOrHours) ? shopOrHours : (shopOrHours && shopOrHours.hours); + if (!h) return true; + return hour >= h[0] && hour < h[1]; +} + // World-space corners of a lot's rotated footprint. Convention matches selfcheck + Lane B: // a lot faces its street via `ry`; corners are [±w/2, ±d/2] rotated by ry about the lot centre. export function lotCorners(l) { diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index 98e8af2..ea3c46a 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js'; import { generatePlanOSM, osmTownKeys } from './plan_osm.js'; +import { generatePlanFor } from './index.js'; import { allFacadeSkins, SHOP_TYPES } from '../core/registry.js'; import { xmur3 } from '../core/prng.js'; @@ -130,7 +131,10 @@ function structuralSuite(plan, label) { // exactly one late-night landmark; openLate field ⟺ hours[1]≥22 (same shop); it's the video (or, // for a town with no video at all, the fallback landmark). Holds for synthetic AND osm. const late = plan.shops.filter(sh => sh.openLate); - const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22); + // The v3 gig venue (`sh.venue`) is a night place that also runs ≥22:00 by design, so it's exempt + // from the single-late-shop rule. Base plans have no venue ⇒ this filter is a no-op there (goldens + // unaffected); gig plans exclude the pub so the openLate video is still the lone landmark. + const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22 && !sh.venue); ok(late.length === 1, `${label}: exactly one open-late shop (${late.length})`); ok(ge22.length === 1, `${label}: exactly one shop closes ≥22:00 (${ge22.length})`); ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0], `${label}: openLate field ⟺ hours[1]≥22 (same shop)`); @@ -138,6 +142,25 @@ function structuralSuite(plan, label) { ok(late.length === 1 && (late[0].type === 'video' || !plan.shops.some(x => x.type === 'video')), `${label}: the open-late shop is the video rental (or fallback if no video)`); } +// The v3 GIG layer (?gigs=1). Runs the full structuralSuite (venue-aware) plus gig-specific +// invariants. Validates the interface every downstream lane (C/D/B/F) hangs off this round. +function gigSuite(plan, label) { + structuralSuite(plan, label); // full coverage; venue exempt from late-shop rule + const pubs = plan.shops.filter(s => s.type === 'pub'); + ok(pubs.length === 1, `${label}: exactly one pub venue (${pubs.length})`); + const pub = pubs[0] || {}; + ok(pub.venue === true && !pub.openLate, `${label}: pub tagged venue, not the openLate landmark`); + ok((pub.hours || [0, 0])[1] >= 22, `${label}: venue open into night so the gig can run`); + ok(Array.isArray(plan.gigs) && plan.gigs.length >= 1, `${label}: has a nightly gig schedule`); + ok(plan.gigs.every(g => g.venueShopId === pub.id), `${label}: every gig plays the venue`); + ok(plan.gigs.every(g => Number.isInteger(g.cover) && (g.cover === 0 || (g.cover >= 2 && g.cover <= 10))), `${label}: cover ∈ {0}∪[2,10]`); + ok(plan.gigs.every(g => typeof g.bandName === 'string' && g.bandName && !/[{}]|undefined/.test(g.bandName)), `${label}: band names resolved`); + ok(plan.gigs.every(g => Number.isInteger(g.startSeg) && Number.isInteger(g.endSeg) && g.startSeg >= 0 && g.endSeg <= 5 && g.startSeg <= g.endSeg), `${label}: gig segment window sane`); + ok(plan.gigs.every(g => g.genreKey === 'pubrock' && Number.isInteger(g.night)), `${label}: alpha genre + night index`); + const gigIds = new Set(plan.gigs.map(g => g.gigId)); + ok(Array.isArray(plan.posters) && plan.posters.every(p => gigIds.has(p.gigId) && isFiniteNum(p.x) && isFiniteNum(p.z) && isFiniteNum(p.ry)), `${label}: posters reference a real gig with finite coords`); +} + // ── 1. determinism: two runs byte-identical ───────────────────────────────────────── section('determinism (deep-equal across two runs)'); for (const s of SEEDS) { @@ -203,6 +226,30 @@ for (const town of osmTownKeys()) { console.log(` ${town}: ${report.shops} shops · normalized {typesRemapped:${report.typesRemapped}, hoursClamped:${report.hoursClamped}, openLate:${report.openLate}}`); } +// ── 3c. v3 GIG layer (?gigs=1): flags-off identity + gig invariants (synthetic + osm) ── +section('v3 gig layer (?gigs=1) — flags-off identity + gig invariants'); +// PRIME FLAG LAW: ?gigs off must be byte-identical to the base generator (goldens can't move). +for (const s of SEEDS) ok(JSON.stringify(generatePlanFor(s)) === JSON.stringify(generatePlan(s)), `seed ${s}: ?gigs off ≡ base (byte-identical)`); +const GIG_SEEDS = [20261990, 1, 42, 777]; +for (const s of GIG_SEEDS) gigSuite(generatePlanFor(s, 'synthetic', { gigs: true }), `gig syn ${s}`); +for (const town of osmTownKeys()) for (const s of GIG_SEEDS) gigSuite(generatePlanFor(s, 'osm', { gigs: true, town }), `gig osm/${town} ${s}`); +// determinism of the gig layer (same seed ⇒ byte-identical gig plan) +ok(JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })) === JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })), `gig layer deterministic`); +// custom-band drop-in: passed names surface with priority, deterministically given (seed, list) +{ + const custom = ['AAA Custom One', 'BBB Custom Two', 'CCC Custom Three']; + const pc = generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom }); + ok(pc.gigs.map(g => g.bandName).filter(b => custom.includes(b)).length === custom.length, `custom bands: all ${custom.length} passed names surface`); + ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`); +} +// gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file) +const GIG_GOLDEN = 0xa6ae5a5e; +{ + const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0; + if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`); + ok(GIG_GOLDEN !== 0 && hash === (GIG_GOLDEN >>> 0), `gig layer golden 0x${hash.toString(16)} matches pinned 0x${(GIG_GOLDEN >>> 0).toString(16)}`); +} + // ── 4. every facade skin referenced by the registry exists on disk ────────────────── section('assets on disk'); for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`); diff --git a/web/js/citygen/wordlists.js b/web/js/citygen/wordlists.js index c16b01e..5871e14 100644 --- a/web/js/citygen/wordlists.js +++ b/web/js/citygen/wordlists.js @@ -82,3 +82,30 @@ export const TOWN_SUFFIX = [ 'Flat', 'Creek', 'Springs', 'Gully', 'Junction', 'Crossing', 'Downs', 'Vale', 'Heads', 'Plains', 'Ridge', 'Hollow', 'Bend', 'Reach', 'Wells', '', '', '', // blanks: bare root sometimes ]; + +// ── v3 gigs (?gigs=1): band names (90s Aussie pub-rock) + pub/venue names ──────────── +// Band adjectives (feral, sunburnt, sweaty-front-bar register) +export const BAND_ADJ = [ + 'Feral', 'Screaming', 'Dead', 'Rusty', 'Filthy', 'Mangy', 'Sunburnt', 'Flannel', 'Amyl', + 'Cosmic', 'Chunder', 'Drunken', 'Wild', 'Electric', 'Grinning', 'Roaring', 'Deadset', + 'Salty', 'Dusty', 'Bitter', 'Feral', 'Howling', 'Molten', 'Ratbag', 'Bogan', +]; +// Band nouns (plural — "The {Adj} {Noun}") +export const BAND_NOUN = [ + 'Galahs', 'Utes', 'Cheezels', 'Dropbears', 'Rissoles', 'Chisels', 'Larrikins', 'Dropkicks', + 'Woodducks', 'Cockies', 'Servos', 'Sherbets', 'Ferals', 'Yobbos', 'Ratbags', 'Cane Toads', + 'Redbacks', 'Blowflies', 'Mullets', 'Eskies', 'Goannas', 'Numbats', 'Longnecks', 'Bull Bars', +]; +// Single-word band names (Regurgitator / Grinspoon / Spiderbait parody tier) +export const BAND_ONEWORD = [ + 'Chunderbolt', 'Boganvillea', 'Roadkill', 'Grommet', 'Feralcat', 'Backburn', 'Sunstroke', + 'Bindii', 'Deadwood', 'Longneck', 'Dropkick', 'Bullbar', 'Stinger', 'Slabhead', 'Gutful', + 'Chundermuffin', 'Wombatt', 'Screwtop', 'Goonsack', 'Ferntongue', +]; + +// Pub / hotel names — every AU country town has a Royal, a Commercial, a Terminus. +export const PUB_NAME = [ + 'The Royal', 'The Royal Hotel', 'The Commercial', 'The Terminus', 'The Railway Hotel', + 'The Criterion', 'The Grand', 'The Exchange Hotel', 'The Federal', 'The Bridge Hotel', + 'The Victoria', 'The Star Hotel', 'The Imperial', 'The Great Southern', +]; diff --git a/web/js/core/registry.js b/web/js/core/registry.js index ab5b1f0..dd1aeae 100644 --- a/web/js/core/registry.js +++ b/web/js/core/registry.js @@ -8,7 +8,7 @@ export const DISTRICT_KINDS = ['mainstreet', 'arcade', 'backstreets', 'warehouse', 'residential', 'market']; export const EDGE_KINDS = ['main', 'side', 'lane', 'arcade']; export const USE_KINDS = ['shop', 'anchor', 'house', 'yard', 'stall', 'infill']; -export const SHOP_TYPE_IDS = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall']; +export const SHOP_TYPE_IDS = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall', 'pub']; // ── the registry ────────────────────────────────────────────────────────────────── // Per type: @@ -121,6 +121,17 @@ export const SHOP_TYPES = { weights: { market: 1 }, footprint: { fmin: 3, fmax: 5, dmin: 3, dmax: 5 }, }, + pub: { + label: 'Pub', + facades: ['facade-djsim-pub.jpg', 'facade-federation.jpg'], // corner hotel; both exist in web/assets/gen/ + sign: { bg: '#3a2416', fg: '#f2d98a', accent: '#c0392b', style: 'gilt' }, + interior: 'band_room', // Lane C pub archetype (stage + PA + bar) + fittings: ['bar_counter', 'stage', 'pa_stack', 'pub_tables', 'stools'], + storeys: [1, 2], + hours: { open: 17, close: 23 }, // a NIGHT venue: open into NIGHT so gigs can run (gig layer sets these) + weights: {}, // NEVER auto-placed — the ?gigs=1 layer converts one shop into the venue + footprint: { fmin: 16, fmax: 24, dmin: 18, dmax: 26 }, + }, }; // ── pure helpers (no randomness — plan.js supplies the prng stream) ───────────────── diff --git a/web/map.html b/web/map.html index c7b00e0..47cfbc1 100644 --- a/web/map.html +++ b/web/map.html @@ -91,12 +91,14 @@ import { generatePlanFor, chunkIndex, CHUNK } from './js/citygen/index.js'; const _q = new URLSearchParams(location.search); const PLANSRC = _q.get('plansrc') === 'osm' ? 'osm' : 'synthetic'; const TOWN = _q.get('town') || undefined; +const GIGS = _q.get('gigs') === '1'; // v3 gig layer: venue + nightly gigs + posters (?gigs=1) +let customBands = []; // John's OPTIONAL band drop-in (loaded async at boot when ?gigs=1) import { SHOP_TYPES, SHOP_TYPE_IDS } from './js/core/registry.js'; // ── palette ───────────────────────────────────────────────────────────────────────── const TYPE_COLOR = { record:'#ff5470', opshop:'#5fbf74', toy:'#ffcf3f', book:'#c98b3b', video:'#ffe14d', - pawn:'#c0c0c0', milkbar:'#ff6b6b', dept:'#e07b9a', stall:'#c8a24a', + pawn:'#c0c0c0', milkbar:'#ff6b6b', dept:'#e07b9a', stall:'#c8a24a', pub:'#e8b73a', }; const USE_COLOR = { shop:'#6b5a3a', anchor:'#8a4a63', house:'#3f5a4a', yard:'#2e3a2a', stall:'#7a6a3a', infill:'#4a4436', @@ -153,6 +155,7 @@ function draw(){ drawStreets(); if ($('tLots').checked) drawLots(); if ($('tShops').checked) drawShops(); + if (GIGS) drawGigs(); if ($('tNodes').checked) drawNodes(); if (hover) drawHoverHi(); drawCompass(); @@ -229,6 +232,19 @@ function drawShops(){ } } } +// v3 gig overlay (?gigs=1): poster markers + a ring/label on the venue. +function drawGigs(){ + if(!plan.gigs || !plan.gigs.length) return; + ctx.fillStyle='#ffd23f'; + for(const p of (plan.posters||[])){ const [sx,sy]=w2s(p.x,p.z); // little poster pins + ctx.beginPath(); ctx.moveTo(sx,sy-4); ctx.lineTo(sx+3,sy+3); ctx.lineTo(sx-3,sy+3); ctx.closePath(); ctx.fill(); } + const venue=plan.shops.find(s=>s.venue); if(!venue) return; + const l=plan.lots.find(x=>x.id===venue.lot); if(!l) return; + const [sx,sy]=w2s(l.x,l.z); + ctx.beginPath(); ctx.arc(sx,sy,10,0,7); ctx.strokeStyle='#ffd23f'; ctx.lineWidth=2; ctx.stroke(); + ctx.font='bold 10px ui-monospace,monospace'; ctx.fillStyle='#ffd23f'; ctx.textAlign='center'; + ctx.fillText('♪ '+venue.sign, sx, sy-15); +} function drawNodes(){ ctx.fillStyle=getcss('--accent'); for(const n of plan.streets.nodes){ const [sx,sy]=w2s(n.x,n.z); @@ -296,7 +312,7 @@ const pad=h=>String(h).padStart(2,'0')+':00'; // ── panel ───────────────────────────────────────────────────────────────────────── function regen(seed){ const s = (seed>>>0); - plan = generatePlanFor(s, PLANSRC, { town: TOWN }); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan + plan = generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS, customBands }); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan $('seed').value = s; $('name').innerHTML = `${esc(plan.name)}`; const nShop=plan.shops.length, byType={}; @@ -304,6 +320,13 @@ function regen(seed){ $('stats').innerHTML = `${plan.streets.edges.length} streets · ${plan.blocks.length} blocks
`+ `${plan.lots.length} lots · ${nShop} shops · ${Object.keys(idx.chunks).length} chunks`; + if (GIGS && plan.gigs && plan.gigs.length){ + const venue=plan.shops.find(s=>s.venue), g0=plan.gigs[0]; + $('stats').innerHTML += `
♪ ${esc(venue?.name||'venue')}
`+ + `tonight: ${esc(g0.bandName)} · ${g0.cover?('$'+g0.cover+' cover'):'FREE'}
`+ + `${plan.gigs.length} nights · ${(plan.posters||[]).length} posters`+ + `${customBands.length?` · ${customBands.length} custom bands`:''}`; + } fitView(); draw(); buildLegend(byType); } function buildLegend(byType){ @@ -335,7 +358,12 @@ addEventListener('resize', resize); // rAF ensures the flex layout has sized the canvas before fitView reads its dimensions. const q=new URLSearchParams(location.search); const bootSeed=parseInt(q.get('seed')||$('seed').value,10)||20261990; -requestAnimationFrame(()=>{ resize(); regen(bootSeed); }); +requestAnimationFrame(async ()=>{ + resize(); + // OPTIONAL band drop-in: load John's names for the debug view (graceful if the file is absent). + if (GIGS){ try { const r=await fetch('./assets/custom_bands.json'); if(r.ok){ const j=await r.json(); if(Array.isArray(j.bands)) customBands=j.bands; } } catch(e){} } + regen(bootSeed); +});