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);
+});