Lane A round 12: v3.0-alpha gig layer (?gigs=1) — venue, plan.gigs, band names, closing-time debt

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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 18:33:53 +10:00
parent 22a0741ad1
commit acf734a1e5
12 changed files with 425 additions and 10 deletions

View File

@ -1,9 +1,47 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 29 closed** (see below).
`qa.sh --strict` GREEN.*
*Status: **all deliverables landed; self-check all-green (3074/3074); rounds 212 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.

View File

@ -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 (startSeg1 = 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 |

View File

@ -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 (startSeg1 = 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)

View File

@ -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"
]
}

108
web/js/citygen/gigs.js Normal file
View File

@ -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 startSeg1 (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;
}

View File

@ -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.

View File

@ -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);

View File

@ -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 024 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) {

View File

@ -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}`);

View File

@ -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',
];

View File

@ -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) ─────────────────

View File

@ -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 = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`;
const nShop=plan.shops.length, byType={};
@ -304,6 +320,13 @@ function regen(seed){
$('stats').innerHTML =
`<b>${plan.streets.edges.length}</b> streets · <b>${plan.blocks.length}</b> blocks<br>`+
`<b>${plan.lots.length}</b> lots · <b>${nShop}</b> shops · <b>${Object.keys(idx.chunks).length}</b> chunks`;
if (GIGS && plan.gigs && plan.gigs.length){
const venue=plan.shops.find(s=>s.venue), g0=plan.gigs[0];
$('stats').innerHTML += `<br><b style="color:#ffd23f">♪ ${esc(venue?.name||'venue')}</b><br>`+
`tonight: <b>${esc(g0.bandName)}</b> · ${g0.cover?('$'+g0.cover+' cover'):'FREE'}<br>`+
`<span class="muted">${plan.gigs.length} nights · ${(plan.posters||[]).length} posters`+
`${customBands.length?` · ${customBands.length} custom bands`:''}</span>`;
}
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);
});
</script>
</body>
</html>