Lane A round 13: v3.0-beta the district (?gigs=1) — 2–4 venues, week schedule, town-wide posters, verge fix
Publishes the multi-venue plan.gigs interface C/D/B/F hang off (half-day handshake, A first). - pickVenues: seeded 2–4 shops converted in place → pub (spine end) / band_room (warehouse fringe) / rsl (off-spine); 4th = 2nd pub at far end. Registry gains weight-0 band_room+rsl kinds + venueKind/genreKey/placement. Never the openLate landmark; osm-safe fallbacks. - gigKey contract (debt #1): gigKeyFor(genreKey) => 'gig-'+genreKey, the single exported genre→bed mapping. No table anywhere; F deletes the GIG_BED bridge. - plan.gigs goes district: 7 nights × every venue, each plays a seeded 4–6 (dark reads true); cover skewed by kind; no band plays two venues on one night; custom names spread night-major. - plan.posters goes town-wide: tonight's playing venues each get frontage + seeded spine run. - Verge fix (debt #5): new corridor law roadWidth/vergeBand/poleOffset(edge) in registry.js (road-vs-verge split of edge.width, published as functions ⇒ base goldens frozen). Posters seat on the verge, never bitumen; selfcheck asserts no poster on a carriageway. - registry pub.interior 'band_room'→'pub' (unread field; avoids collision with band_room type). Gig golden re-pinned 0xa6ae5a5e→0x1f636349 (multi-venue changed output; in-commit per amendment law). selfcheck ALL GREEN 3273/3273; scaffold_check + consistency GREEN; flags-off byte-identical. CITY_SPEC §v3.0-beta + LANE_A_NOTES §Round 13 updated in this commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
79852ca220
commit
6fba79a1cb
@ -1,9 +1,49 @@
|
||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||
|
||||
*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.*
|
||||
*Status: **all deliverables landed; self-check all-green (3273/3273); rounds 2–13 closed** (see below).
|
||||
Node QA gates (selfcheck · scaffold · consistency) GREEN. v3.0-beta DISTRICT interface published (2–4
|
||||
venues, week schedule, town-wide posters); gig golden `0x1f636349`. Strict 6/6 is F's end-of-round gate.*
|
||||
*Date: 2026-07-15 · owner: PROCITY-A (Opus 4.8)*
|
||||
|
||||
## Round 13 (2026-07-15) — v3.0-beta THE DISTRICT: 2–4 venues + the week + town-wide posters + verge fix
|
||||
|
||||
Lane A ran **first** again (C/D/B/F all hang off the district `plan.gigs`). Same flag, same prime-flag
|
||||
law: **flags-off byte-identical — all v2 goldens frozen** (synthetic `0x3fa36874`, osm unchanged).
|
||||
Self-check **ALL GREEN 3273/3273**; `scaffold_check` + `consistency` GREEN. The gig **entry shapes are
|
||||
unchanged** — the alpha's one-venue readers now iterate 2–4. Interfaces published in `LANE_A_NOTES.md`
|
||||
(§Round 13) and `CITY_SPEC.md` (§v3.0-beta) in this same commit.
|
||||
|
||||
Delivered (all behind `?gigs=1`):
|
||||
1. **2–4 venues, three kinds** — `pickVenues` converts a seeded 2–4 plain shops in place: `pub` (spine
|
||||
end), `band_room` (warehouse fringe), `rsl` (off-spine), 4th = a 2nd pub at the far end. Each carries
|
||||
`venueKind` + `genreKey` (`pubrock`/`grunge`/`covers`). Registry gains weight-0 `band_room`+`rsl` types
|
||||
(facades, hours, footprints). Placement degrades gracefully so osm towns still get venues. Never the
|
||||
openLate landmark; venues occupy distinct lots.
|
||||
2. **The gigKey contract (debt #1)** — `gigKeyFor(genreKey) => 'gig-'+genreKey`, the single exported
|
||||
genre→bed mapping (no table anywhere). C/E/B/F all route through it; F deletes the `GIG_BED` bridge.
|
||||
3. **`plan.gigs` goes district** — 7 nights × every venue, each playing a seeded **4–6** (dark the rest,
|
||||
reads true). `gigId` unique town-wide; cover skewed by kind (pub 50%/`$2–10`, RSL 45%/`$2–5`, band_room
|
||||
70%/`$2–6`). **No band plays two venues on one night**; custom names spread night-major across the week.
|
||||
4. **`plan.posters` goes town-wide** — tonight's playing venues each get a frontage poster + a seeded spine
|
||||
run (2/main edge, either verge). Different venues share pole runs — the district reading.
|
||||
5. **Verge offset (debt #5 PAID)** — the R12 bug (spine posters at `node.x+2` = mid-bitumen on a 28 m
|
||||
corridor) is fixed by the new corridor law: `roadWidth`/`vergeBand`/`poleOffset(edge)` in registry.js,
|
||||
re-exported from the barrel — the road-vs-verge split of `edge.width`, published as **functions** (no
|
||||
plan fields ⇒ base goldens frozen). Selfcheck now asserts no poster stands on a carriageway.
|
||||
6. **Registry `pub.interior` corrected** `'band_room'`→`'pub'` (was an unread field; now avoids a
|
||||
name-collision with the new `band_room` venue type — C keys recipes off `shop.type`, not this field).
|
||||
|
||||
Golden re-pin: gig golden `0xa6ae5a5e`→**`0x1f636349`** (multi-venue changed gig-plan output; re-pinned
|
||||
in-commit per CITY_SPEC amendment law). New selfcheck coverage: 2–4 venues + known kinds + `type≡venueKind`
|
||||
+ genre map + gigKey ≡ `gig-`+genreKey; 4–6 gigs/venue, ≤1 gig/night/venue, no same-night band clash
|
||||
town-wide; posters advertise night-0 and **clear every carriageway**. Sample seed 20261990 → 3 venues:
|
||||
The Exchange Hotel (pub), Wangaratta RSL, The Vinyl Shed (band room, dark tonight); 14 gigs across the week.
|
||||
|
||||
**Handshake note for Lane C:** `RECIPES.band_room`/`RECIPES.rsl` don't exist in `theme.js` yet — until C
|
||||
adds them those venues fall back to the op-shop interior. That's the order (A→C). **Not run by A:** the
|
||||
browser flags/scale gates + depot (Gate 3, John-blocked) — F's end-of-round acceptance, and they'd test
|
||||
R12 runtime that hasn't adapted to the new venue count yet.
|
||||
|
||||
## 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
|
||||
|
||||
@ -204,52 +204,96 @@ 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
|
||||
## CityPlan v3 — gig layer (`?gigs=1`) — v3.0-beta (THE DISTRICT)
|
||||
|
||||
> **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).
|
||||
>
|
||||
> *(v3.0-alpha was one pub, one genre, one night's posters. **ROUND13 → v3.0-beta, the district:** 2–4
|
||||
> venues of three kinds, a full week's schedule across town, town-wide posters. Same flag, same laws.)*
|
||||
|
||||
**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).
|
||||
**The venues (2–4 per town — a seeded count).** Three registry venue kinds — **`pub`**, **`band_room`**,
|
||||
**`rsl`** — each with **no district weights** (never auto-placed). The layer converts that many plain
|
||||
`shop`s **in place** (keeps each lot id + geometry ⇒ enterable, no overlap), tags them
|
||||
`shop.venue === true`, `shop.venueKind`, `shop.genreKey`, and **never** takes the openLate landmark (the
|
||||
town keeps exactly one openLate shop). Kinds are added in the order `pub, band_room, rsl, pub` (a 4-venue
|
||||
town gets a second pub at the far end of the spine). Placement rules (each degrades to "any remaining
|
||||
plain shop" so osm towns still get venues):
|
||||
|
||||
**`plan.gigs`** — the nightly schedule (present only on gig-on plans):
|
||||
| kind | `genreKey` → gigKey | placement | hours | reads as |
|
||||
|---|---|---|---|---|
|
||||
| `pub` | `pubrock` → `gig-pubrock` | spine **end**/corner (main edge, farthest) | `[17,23]` | ≥2-storey corner hotel |
|
||||
| `band_room` | `grunge` → `gig-grunge` | warehouse-district fringe | `[18,23]` | single-storey tin shed |
|
||||
| `rsl` | `covers` → `gig-covers` | **off**-spine (not main, not warehouse) | `[16,23]` | carpet-and-bistro club |
|
||||
|
||||
Venue hours all close `23` (open through DUSK+NIGHT so the gig runs and D's surge fills the room; drains
|
||||
at close per the closing-time ruling above). Venues are the *only* gig-plan shops allowed to close ≥22
|
||||
besides the openLate video (selfcheck exempts them). `genreKey` is set from the registry kind→genre map
|
||||
(`SHOP_TYPES[kind].genre`, mirrored by `genreForVenueKind(kind)`); John may re-flavour the two new genres
|
||||
(names only — E synthesises to order).
|
||||
|
||||
**The gigKey contract (ROUND13 debt #1 — one key, zero mapping).** The audio-manifest bed key for a
|
||||
genre **IS** the gigKey, and it is **always** `gig-<genreKey>`. There is no mapping table anywhere: A
|
||||
emits `shop.genreKey` + `gig.genreKey`, C sets `audio.gigKey = 'gig-'+genreKey`, E names the manifest
|
||||
bed identically, B's spill reads it and F resolves it — all through the single exported helper
|
||||
**`gigKeyFor(genreKey) => 'gig-'+genreKey`** (`web/js/citygen/gigs.js`, re-exported from the barrel).
|
||||
Import it rather than building the string; R12 lost a bed to a private rename (`gig-pubrock` vs
|
||||
`pubrock-live`) and, because audio fails soft, the band just mimed.
|
||||
|
||||
**`plan.gigs`** — the week's schedule across the whole district (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.
|
||||
- **7 nights × every venue, but not every venue plays every night**: each venue takes a seeded **4–6**
|
||||
of the 7 and goes dark the rest (dark nights read true). `gigId` unique town-wide; `night` = 0-based,
|
||||
**F selects tonight's** (night `0`) per venue. `venueShopId` → the venue shop id; `genreKey` ≡ that
|
||||
venue's genre.
|
||||
- `startSeg = 5` (NIGHT / 22:00), `endSeg = 5`. F's state machine (now **per venue**): `quiet → doors
|
||||
(startSeg−1 = DUSK) → on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.)
|
||||
- `cover` — integer in `{0} ∪ [2,10]`, **~half free town-wide, skewed by kind**: pub 50% free / `$2–10`,
|
||||
RSL 45% free / `$2–5` (cheap — the bistro pays the bills), band_room 70% free / `$2–6` (a hat on a
|
||||
milk crate). F debits the wallet **per venue** at each door; a night at two venues is two covers.
|
||||
- **No band plays two venues on one night** (≤1 slot per name per night across town): custom names are
|
||||
deduped and used at most once; a generated name that would clash tonight advances its stream until it
|
||||
doesn't (deterministic — the generator index only moves forward).
|
||||
|
||||
**`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".
|
||||
**`plan.posters`** — town-wide gig advertising (Lane B renders): `posters: [ { id, gigId, x, z, ry } ]`.
|
||||
Only **tonight's** (night 0) playing venues are advertised. Each such venue gets one poster on **its own
|
||||
frontage** (seated on the facade plane, `lot.x + front·(d/2+ε)`), plus a seeded run along the spine — 2
|
||||
per main edge, at `t ∈ [0.2,0.8]` of the block, on **either verge**. Different venues' posters share the
|
||||
same pole runs — that IS the district reading. `ry` follows the one house convention (front/outward
|
||||
normal = local `−Z` at `ry=0`; `ry = atan2(nx, nz)`), so a poster faces the footpath that reads it. B
|
||||
places E's poster skin at `(x, z)` rotated `ry`; F wires "poster → that venue's tonight-gig".
|
||||
|
||||
**The street-corridor law (ROUND13 debt #5 — published as functions, not plan data).** `edge.width` is
|
||||
the **full corridor** (carriageway + verge/footpath, centreline ± width/2), never the carriageway alone —
|
||||
so a naive `node.x + 2` seats furniture mid-bitumen. `web/js/core/registry.js` now exports the split as
|
||||
pure functions over an edge (no plan fields change ⇒ base goldens stay frozen), re-exported from the
|
||||
barrel: **`roadWidth(edge)`** (carriageway half-corridor: main 10 m, side 6–8, lane = all, arcade 0),
|
||||
**`vergeBand(edge) → [inner,outer]`** (where footpath furniture lives), **`poleOffset(edge)`** (where a
|
||||
pole/poster seats — just behind the kerb). Posters, poles, verandah posts belong in the verge band; the
|
||||
selfcheck asserts no poster stands within `roadWidth/2` of any edge centreline.
|
||||
|
||||
**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.
|
||||
Galahs", …] }`, Lane-A-owned per ROUND12): present → those names get **priority**, deduped and shuffled
|
||||
by seed, spread **night-major across the whole week's slots town-wide** (not one venue's residency);
|
||||
**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.
|
||||
**`0x1f636349`** (ROUND13 re-pin — the district changed gig-plan output; was `0xa6ae5a5e`, the one-pub
|
||||
alpha). 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)
|
||||
|
||||
@ -279,7 +323,9 @@ canonical half-open predicate (`open ≤ hour < close`) for D's gig-night patron
|
||||
| `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 |
|
||||
| `pub` (v3 venue) | djsim-pub, federation | pub | bar counter, stage, PA stack, tables, stools |
|
||||
| `band_room` (v3 venue) | warehouse-tin, besser, corrugated | band_room | bar counter, stage, PA stack, stools |
|
||||
| `rsl` (v3 venue) | brickveneer, redbrick | rsl | bar counter, stage, PA stack, bistro tables, stools |
|
||||
|
||||
## NPC contract (Lane D)
|
||||
|
||||
|
||||
@ -2,6 +2,95 @@
|
||||
|
||||
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
|
||||
|
||||
## Round 13 (2026-07-15) — v3.0-beta THE DISTRICT (published FIRST; C/D/B/F hang off this)
|
||||
|
||||
Supersedes the R12 alpha interface below. Same flag (`?gigs=1`), same prime-flag law: flag OFF ⇒ plan
|
||||
byte-identical to v2 (synthetic golden still `0x3fa36874`, osm unchanged). What changed: **one venue → 2–4;
|
||||
one genre → three (by kind); one night's posters → town-wide.** The gig **entry shapes are unchanged** —
|
||||
downstream code that read one venue now iterates many. Verified green: `selfcheck.js` 3273/3273,
|
||||
`scaffold_check` + `consistency` GREEN.
|
||||
|
||||
**Gig golden re-pinned: `0xa6ae5a5e` → `0x1f636349`** (the district changed gig-plan output; re-pinned in
|
||||
the same commit as the change per CITY_SPEC amendment law). Turn-on is unchanged:
|
||||
`generatePlanFor(seed, src, { town, gigs:true, customBands })`.
|
||||
|
||||
### The venues (NEW: 2–4 per town, three kinds) — `plan.shops.filter(s => s.venue)`
|
||||
Each converted-in-place venue shop carries three new fields:
|
||||
```
|
||||
s.venue === true · s.venueKind ∈ {'pub','band_room','rsl'} · s.genreKey ∈ {'pubrock','grunge','covers'}
|
||||
s.type === s.venueKind // C keys its interior recipe off shop.type — the type IS the kind
|
||||
```
|
||||
| kind | genreKey | placement | hours | reads as |
|
||||
|---|---|---|---|---|
|
||||
| `pub` | `pubrock` | spine end/corner | `[17,23]` | ≥2-storey corner hotel |
|
||||
| `band_room` | `grunge` | warehouse fringe | `[18,23]` | single-storey tin shed |
|
||||
| `rsl` | `covers` | off-spine | `[16,23]` | carpet-and-bistro club |
|
||||
- Seeded count 2–4; a 4th venue is a **second pub** at the far spine end. A town always has ≥1 pub.
|
||||
- **Never** the openLate landmark (still exactly one openLate shop). Placement degrades gracefully so osm
|
||||
towns (no warehouse district, maybe no spine) still get their venues.
|
||||
- `genreKey` comes from the registry kind→genre map: `genreForVenueKind(kind)` / `SHOP_TYPES[kind].genre`.
|
||||
|
||||
### THE gigKey CONTRACT (debt #1 — import it, don't build the string)
|
||||
The audio-manifest bed key **IS** the gigKey and is **always** `gig-<genreKey>`. No mapping table anywhere:
|
||||
```js
|
||||
import { gigKeyFor } from './citygen/index.js'; // re-exported from the barrel
|
||||
gigKeyFor('pubrock') === 'gig-pubrock' // C sets audio.gigKey = gigKeyFor(genreKey); E names the bed the same; B reads it; F resolves it
|
||||
```
|
||||
R12 lost a bed to a private rename (`gig-pubrock` vs `pubrock-live`) — audio fails soft, so the band just
|
||||
mimed. One key, zero mapping. E renames `pubrock-live → gig-pubrock` this round; F deletes the `GIG_BED` bridge.
|
||||
|
||||
### `plan.gigs` — the week across the whole district (entry shape UNCHANGED)
|
||||
```
|
||||
plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ]
|
||||
```
|
||||
- **7 nights × every venue, but not every venue plays every night**: each venue plays a seeded **4–6** of
|
||||
the 7, dark the rest (reads true). `gigId` unique **town-wide**; `night ∈ [0,7)`; `genreKey ≡` its venue's.
|
||||
- **Pick tonight per venue** (each venue has its own state now): `plan.gigs.filter(g => g.venueShopId === v.id && g.night === 0)[0]` — **may be undefined** (venue dark tonight; F/B skip it, no poster).
|
||||
- `startSeg=5 endSeg=5`. Same `quiet → doors(seg4) → on(seg5) → done` machine — F runs one **per venue**.
|
||||
- `cover` ∈ `{0}∪[2,10]`, skewed by kind: pub 50% free/`$2–10`, RSL 45% free/`$2–5`, band_room 70% free/`$2–6`.
|
||||
F debits **per venue** — a night at two venues is two covers.
|
||||
- **No band plays two venues on one night** (guaranteed by A). Custom names spread night-major across the
|
||||
whole week town-wide (deduped, priority-seeded), the rest generated.
|
||||
|
||||
### `plan.posters` — town-wide now (shape UNCHANGED; **Lane B** renders)
|
||||
```
|
||||
plan.posters = [ { id, gigId, x, z, ry }, … ] // only TONIGHT's (night 0) playing venues advertised
|
||||
```
|
||||
- One on each playing venue's **own frontage** (on the facade plane) + a seeded spine run (2 per main edge).
|
||||
- Different venues' posters share the same pole runs — that IS the district reading. `p.gigId` tells B/F
|
||||
which venue's gig each poster sells (`plan.gigs.find(g=>g.gigId===p.gigId)`).
|
||||
- **Debt #5 fixed:** posters no longer land mid-road. See the corridor law ↓.
|
||||
|
||||
### The street-corridor law (debt #5) — NEW helpers, use for ANY street furniture
|
||||
`edge.width` is the **full corridor** (carriageway + verge, centreline ± width/2), NOT the carriageway.
|
||||
`node.x + 2` seats furniture on the bitumen. Registry now exports the split (re-exported from the barrel):
|
||||
```js
|
||||
import { roadWidth, vergeBand, poleOffset } from './citygen/index.js';
|
||||
roadWidth(edge) // carriageway half-corridor (main 10m, side 6–8, lane=all, arcade 0)
|
||||
vergeBand(edge) // [inner,outer] — where footpath furniture belongs
|
||||
poleOffset(edge) // where a pole/poster/post seats: just behind the kerb
|
||||
```
|
||||
Selfcheck now **asserts** no poster stands within `roadWidth/2` of any edge centreline.
|
||||
|
||||
### → Lane C: two recipes to add + the gigKey + a corrected field
|
||||
- `getRecipe(shop.type)` resolves off `RECIPES[type]` — `RECIPES.pub` exists; **`RECIPES.band_room` and
|
||||
`RECIPES.rsl` do NOT yet.** Until you add them, those venues fall back to the op-shop interior. That's
|
||||
the handshake — you're next in order.
|
||||
- Set `room.audio.gigKey = gigKeyFor(shop.genreKey)` (import from the barrel) — never hand-build `'gig-…'`.
|
||||
- Registry `pub.interior` was `'band_room'` (harmless, unread — you key off `shop.type` via `canonicalType`);
|
||||
I corrected it to `'pub'` so it isn't a name-collision with the new `band_room` venue type. No behaviour change.
|
||||
|
||||
### → Lane D: iterate the venues, one setGig each
|
||||
`const venues = plan.shops.filter(s => s.venue)` — call `setGig` **per venue** (multiple concurrent). Tonight's
|
||||
gig per venue as above (skip dark ones). `isOpen(shopOrHours, hour)` unchanged (half-open law). Queue zone
|
||||
comes from B (`venue.queueZone`) — consume if present, else a straight line off the door.
|
||||
|
||||
### → Lane F: state machine goes per-venue
|
||||
One latch **per venue keyed by `venueShopId`**, off the same `procity:segment` spine. `gigKeyFor(genreKey)`
|
||||
resolves all three genres (the debt-#1 assert now covers 3). Delete `GIG_BED` once E's rename lands. Cover
|
||||
charge is per venue (per-venue paid stamps). Keep the alpha `window.PROCITY.gigs` shape as a compat alias
|
||||
for one round (B reads it mid-round).
|
||||
|
||||
## 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
|
||||
|
||||
@ -1,108 +1,251 @@
|
||||
// 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})),
|
||||
// PROCITY CityGen — v3 GIG LAYER (?gigs=1). Post-hoc augmentation: takes a finished CityPlan and adds
|
||||
// the town's VENUES (2–4: pub / band_room / rsl), the week's 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
|
||||
// Source-agnostic: works on synthetic AND osm plans (it only reads shops/lots/blocks/edges). Deterministic
|
||||
// from citySeed (+ optional custom band names). NO THREE, no network — pure data (CITY_SPEC law).
|
||||
//
|
||||
// ROUND13 (v3.0-beta, the district): one venue → 2–4; one genre → three (by kind); one night's poster run
|
||||
// → town-wide. See LANE_A_NOTES §Round 13 for the interface C/D/B/F hang off.
|
||||
|
||||
import { rng, seedFor, mulberry32, pick, shuffle } from '../core/prng.js';
|
||||
import { SHOP_TYPES } from '../core/registry.js';
|
||||
import { rng, seedFor, mulberry32, pick, shuffle, irange } from '../core/prng.js';
|
||||
import { SHOP_TYPES, poleOffset } 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.
|
||||
// A night gig: doors at DUSK (seg4), on during NIGHT (seg5) — see lighting.js segment→hour map.
|
||||
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 VENUES_PER_TOWN = [2, 4]; // seeded count (charter: 2–4 venues per town)
|
||||
const GIGS_PER_VENUE = [4, 6]; // per week — the rest are dark nights, which read true
|
||||
// Kind order as venues are added. A town of 4 gets a second pub at the far end of the spine —
|
||||
// two pubs and a footy club is the most Australian town there is.
|
||||
const VENUE_SEQUENCE = ['pub', 'band_room', 'rsl', 'pub'];
|
||||
|
||||
// Cover law (ROUND13 §A2): ~half free town-wide; the RSL skews CHEAP (it's a club, the bistro pays the
|
||||
// bills), the band room skews FREE (the door is a hat on a milk crate). `cover` stays an integer in
|
||||
// {0} ∪ [2,10] for every kind, so F's wallet seam and the selfcheck assert are unchanged.
|
||||
const COVER_BY_KIND = {
|
||||
pub: { free: 0.50, min: 2, max: 10 },
|
||||
band_room: { free: 0.70, min: 2, max: 6 },
|
||||
rsl: { free: 0.45, min: 2, max: 5 },
|
||||
};
|
||||
|
||||
// ── THE gigKey CONTRACT (ROUND13 debt #1) ─────────────────────────────────────────────
|
||||
// The audio manifest key for a genre IS the gigKey, and it is ALWAYS `gig-<genreKey>`. There is no
|
||||
// mapping table anywhere: C emits `audio.gigKey`, E names the manifest bed, B's spill reads it and F
|
||||
// resolves it — all through this one function. R12 lost a gig bed to a private rename (C asked for
|
||||
// `gig-pubrock`, E shipped `pubrock-live`, and because audio fails soft the band just mimed). One key,
|
||||
// zero mapping — import this rather than building the string yourself.
|
||||
export const gigKeyFor = genreKey => `gig-${genreKey}`;
|
||||
|
||||
// ── venue selection ───────────────────────────────────────────────────────────────────
|
||||
// Placement rules (charter + ROUND13 §A1): pub at a spine END (a destination you follow the sound to),
|
||||
// band_room on the warehouse fringe, RSL off-spine. Each pick converts a plain shop IN PLACE (keeps the
|
||||
// lot id + geometry ⇒ enterable, no overlap) and never takes the openLate landmark (the video survives).
|
||||
// Every rule degrades to "any remaining plain shop" so an osm town — which has no warehouse fringe and
|
||||
// may have no spine at all — still gets its venues.
|
||||
function pickVenues(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 blockById = new Map(plan.blocks.map(b => [b.id, b]));
|
||||
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 lotOf = s => lotById.get(s.lot);
|
||||
const blockKindOf = s => { const l = lotOf(s), b = l && blockById.get(l.block); return b ? b.kind : null; };
|
||||
const onMain = s => { const l = lotOf(s); return !!l && mainEdges.has(l.frontEdge); };
|
||||
const dist2 = s => { const l = lotOf(s); return l ? l.x * l.x + l.z * l.z : 0; };
|
||||
const farthestFirst = arr => arr.slice().sort((a, b) => dist2(b) - dist2(a) || a.id - b.id);
|
||||
|
||||
const base = plan.shops.filter(s => {
|
||||
if (s.openLate) return false; // must not displace the night landmark
|
||||
const l = lotOf(s);
|
||||
return !!l && l.use === 'shop'; // a plain shop → keeps SOLID + geometry
|
||||
});
|
||||
|
||||
const taken = new Set();
|
||||
const out = [];
|
||||
for (let i = 0; i < nGigs; i++) {
|
||||
out.push(i < shuffled.length ? shuffled[i] : bandName(seedFor(citySeed, 'band', i)));
|
||||
const n = irange(rng(citySeed, 'venueN', 0), VENUES_PER_TOWN[0], VENUES_PER_TOWN[1]);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const kind = VENUE_SEQUENCE[i] || 'pub';
|
||||
const pool = base.filter(s => !taken.has(s.id));
|
||||
if (!pool.length) break; // degenerate town — fewer venues, still valid
|
||||
|
||||
let cand;
|
||||
if (kind === 'band_room') {
|
||||
cand = pool.filter(s => blockKindOf(s) === 'warehouse');
|
||||
if (!cand.length) cand = pool.filter(s => !onMain(s)); // osm: no warehouse district
|
||||
} else if (kind === 'rsl') {
|
||||
cand = pool.filter(s => !onMain(s) && blockKindOf(s) !== 'warehouse');
|
||||
if (!cand.length) cand = pool.filter(s => !onMain(s));
|
||||
} else {
|
||||
cand = pool.filter(onMain);
|
||||
// a SECOND pub belongs at the other end of the spine, not next door to the first
|
||||
const firstPub = out.find(v => v.kind === 'pub');
|
||||
if (firstPub) {
|
||||
const pz = (lotOf(firstPub.shop) || {}).z || 0;
|
||||
const far = cand.filter(s => Math.sign((lotOf(s) || {}).z || 0) !== Math.sign(pz));
|
||||
if (far.length) cand = far;
|
||||
}
|
||||
}
|
||||
if (!cand.length) cand = pool; // last resort: anything left
|
||||
const shortlist = farthestFirst(cand).slice(0, Math.min(5, cand.length));
|
||||
const shop = pick(rng(citySeed, 'venuepick', i), shortlist);
|
||||
if (!shop) break;
|
||||
taken.add(shop.id);
|
||||
out.push({ shop, kind });
|
||||
}
|
||||
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) {
|
||||
// Convert a chosen shop into a venue of `kind`, in place.
|
||||
function convertVenue(citySeed, shop, kind) {
|
||||
const reg = SHOP_TYPES[kind];
|
||||
const vseed = seedFor(citySeed, 'venue', shop.id);
|
||||
const vr = mulberry32(vseed);
|
||||
shop.type = kind;
|
||||
shop.facadeSkin = pick(vr, reg.facades);
|
||||
const nm = shopName(vseed, kind);
|
||||
shop.name = nm.name; shop.sign = nm.sign;
|
||||
// clamp into the kind's registry band (the shop may have been a 3-storey corner anchor; a band room
|
||||
// is a single-storey shed). A pub reads as a ≥2-storey corner hotel.
|
||||
shop.storeys = Math.min(Math.max(shop.storeys | 0, reg.storeys[0]), reg.storeys[1]);
|
||||
if (kind === 'pub') shop.storeys = Math.max(shop.storeys, 2);
|
||||
shop.hours = [reg.hours.open, reg.hours.close]; // open into NIGHT so the gig runs
|
||||
shop.venue = true; // easy lookup alongside venueKind
|
||||
shop.venueKind = kind;
|
||||
shop.genreKey = reg.genre; // ⇒ gigKeyFor(genreKey) is the audio bed
|
||||
return shop;
|
||||
}
|
||||
|
||||
// ── the week's schedule ───────────────────────────────────────────────────────────────
|
||||
// 7 nights × every venue, but not every venue plays every night: each takes a seeded 4–6 of the 7 and
|
||||
// goes dark the rest. Slots are walked NIGHT-MAJOR so John's custom band names spread across the whole
|
||||
// week rather than filling one venue's residency.
|
||||
function buildSchedule(plan, citySeed, venues, customBands) {
|
||||
const playing = venues.map((v) => {
|
||||
const k = irange(rng(citySeed, 'gignights', v.shop.id), GIGS_PER_VENUE[0], GIGS_PER_VENUE[1]);
|
||||
const order = shuffle(rng(citySeed, 'gigwhich', v.shop.id), [0, 1, 2, 3, 4, 5, 6]);
|
||||
return new Set(order.slice(0, k));
|
||||
});
|
||||
|
||||
// John's OPTIONAL custom names (web/assets/custom_bands.json via opts.customBands) get PRIORITY —
|
||||
// deduped, shuffled deterministically, filled first. Absent/empty ⇒ pure generator (?noassets path).
|
||||
const seen = new Set();
|
||||
const custom = (Array.isArray(customBands) ? customBands : [])
|
||||
.filter(b => typeof b === 'string' && b.trim())
|
||||
.map(b => b.trim())
|
||||
.filter(b => (seen.has(b) ? false : (seen.add(b), true)));
|
||||
const queue = custom.length ? shuffle(rng(citySeed, 'bands', 0), custom.slice()) : [];
|
||||
|
||||
const gigs = [];
|
||||
const usedByNight = new Map();
|
||||
let ci = 0, gi = 0;
|
||||
|
||||
for (let night = 0; night < NIGHTS; night++) {
|
||||
let used = usedByNight.get(night);
|
||||
if (!used) usedByNight.set(night, used = new Set());
|
||||
for (let vi = 0; vi < venues.length; vi++) {
|
||||
if (!playing[vi].has(night)) continue; // dark tonight
|
||||
const v = venues[vi];
|
||||
|
||||
// No band plays two venues at once. Custom names are deduped and used at most once each, so they
|
||||
// can never clash; a GENERATED name can collide with anything already booked tonight, so advance
|
||||
// the generator stream until it doesn't (deterministic: `gi` only ever moves forward).
|
||||
let band;
|
||||
if (ci < queue.length) band = queue[ci++];
|
||||
else do { band = bandName(seedFor(citySeed, 'band', gi++)); } while (used.has(band));
|
||||
used.add(band);
|
||||
|
||||
const cv = COVER_BY_KIND[v.kind] || COVER_BY_KIND.pub;
|
||||
const gr = mulberry32(seedFor(citySeed, 'gig', v.shop.id * 8 + night));
|
||||
const cover = gr() < cv.free ? 0 : cv.min + ((gr() * (cv.max - cv.min + 1)) | 0);
|
||||
|
||||
gigs.push({
|
||||
gigId: gigs.length, venueShopId: v.shop.id, bandName: band, genreKey: v.shop.genreKey,
|
||||
night, startSeg: GIG_START_SEG, endSeg: GIG_END_SEG, cover,
|
||||
});
|
||||
}
|
||||
}
|
||||
return gigs;
|
||||
}
|
||||
|
||||
// ── posters, town-wide ────────────────────────────────────────────────────────────────
|
||||
// Each venue playing TONIGHT (night 0) gets a poster on its own frontage plus a seeded run along the
|
||||
// spine. Different venues' posters share the same pole runs — that IS the district reading.
|
||||
//
|
||||
// `ry` follows the ONE house convention, the same one lot.ry uses and selfcheck enforces: it is the
|
||||
// Y-rotation of a thing whose FRONT/outward normal is local −Z at ry=0, i.e. world facing
|
||||
// (−sin ry, −cos ry). A poster faces the people who read it.
|
||||
//
|
||||
// ROUND13 debt #5 — R12 put spine posters at `node.x + 2`, i.e. 2m from an intersection's CENTRELINE,
|
||||
// which is mid-bitumen on a 28m corridor. They now seat on the verge via registry.poleOffset(edge)
|
||||
// (just behind the kerb), and the venue's own poster sits on its FACADE rather than at the lot centre,
|
||||
// which is inside the building shell.
|
||||
function buildPosters(plan, citySeed, venues, gigs) {
|
||||
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
|
||||
const add = (x, z, ry, gigId) => posters.push({ id: posters.length, gigId, x: r2(x), z: r2(z), ry: r4(ry) });
|
||||
const lotById = new Map(plan.lots.map(l => [l.id, l]));
|
||||
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
|
||||
|
||||
const tonight = new Map(); // venueShopId → tonight's gig
|
||||
for (const g of gigs) if (g.night === 0 && !tonight.has(g.venueShopId)) tonight.set(g.venueShopId, g);
|
||||
if (!tonight.size) return posters; // every venue dark tonight — no posters, reads true
|
||||
|
||||
// 1. one on each playing venue's own frontage
|
||||
for (const v of venues) {
|
||||
const gig = tonight.get(v.shop.id);
|
||||
const lot = gig && lotById.get(v.shop.lot);
|
||||
if (!lot) continue;
|
||||
const fx = -Math.sin(lot.ry), fz = -Math.cos(lot.ry); // house convention: front = local −Z
|
||||
const off = lot.d / 2 + 0.06; // out onto the facade plane, proud of it
|
||||
add(lot.x + fx * off, lot.z + fz * off, lot.ry, gig.gigId);
|
||||
}
|
||||
|
||||
// 2. the spine run — seeded pole positions along main edges, on the verge, facing the footpath.
|
||||
const spine = plan.streets.edges.filter(e => e.kind === 'main');
|
||||
const runs = spine.length ? spine : plan.streets.edges.slice();
|
||||
const bill = [...tonight.values()];
|
||||
if (!runs.length || !bill.length) return posters;
|
||||
|
||||
const pr = rng(citySeed, 'posters', 0);
|
||||
const PER_EDGE = 2;
|
||||
let k = 0;
|
||||
for (const e of runs) {
|
||||
const a = nodeById.get(e.a), b = nodeById.get(e.b);
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz);
|
||||
if (len < 8) continue;
|
||||
const ux = dx / len, uz = dz / len;
|
||||
const off = poleOffset(e); // verge, just behind the kerb — never bitumen
|
||||
for (let i = 0; i < PER_EDGE; i++) {
|
||||
const t = 0.2 + 0.6 * pr(); // somewhere along the block, not on the corner
|
||||
const side = pr() < 0.5 ? 1 : -1;
|
||||
// outward normal (road → pole), exactly marchStrip's convention: a pole is a tiny lot, its front
|
||||
// faces back across its own footpath, so ry = atan2(nx, nz).
|
||||
const nx = side > 0 ? -uz : uz, nz = side > 0 ? ux : -ux;
|
||||
const x = a.x + ux * len * t + nx * off, z = a.z + uz * len * t + nz * off;
|
||||
add(x, z, Math.atan2(nx, nz), bill[k++ % bill.length].gigId);
|
||||
}
|
||||
}
|
||||
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.
|
||||
// ── public: augment a finished plan with the gig layer ─────────────────────────────────
|
||||
// Mutates + returns `plan` (caller passes a fresh plan). Adds `plan.gigs` + `plan.posters` and converts
|
||||
// 2–4 shops into venues 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)
|
||||
const venues = pickVenues(plan, citySeed);
|
||||
if (!venues.length) { plan.gigs = []; plan.posters = []; return plan; } // no plain shops at all
|
||||
|
||||
// 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]);
|
||||
for (const v of venues) convertVenue(citySeed, v.shop, v.kind);
|
||||
plan.gigs = buildSchedule(plan, citySeed, venues, opts.customBands);
|
||||
plan.posters = buildPosters(plan, citySeed, venues, plan.gigs);
|
||||
return plan;
|
||||
}
|
||||
|
||||
@ -10,7 +10,11 @@
|
||||
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';
|
||||
export { withGigs, gigKeyFor } from './gigs.js';
|
||||
// The street-corridor law + venue vocabulary, re-exported so consumers get the whole Lane A contract
|
||||
// from one import (ROUND13). `roadWidth`/`vergeBand`/`poleOffset` are the road-vs-verge split of
|
||||
// `edge.width` — see the note in registry.js; `gigKeyFor` is the one and only genre→audio-key mapping.
|
||||
export { SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth, vergeBand, poleOffset } from '../core/registry.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
|
||||
|
||||
@ -80,6 +80,14 @@ const PATTERNS = {
|
||||
pub: [ // v3 venue (?gigs=1): the town pub/hotel
|
||||
'{Pub}', 'The {Suburb} Hotel', 'The {Family} Hotel', 'The {Adj} Arms', '{Pub}',
|
||||
],
|
||||
band_room: [ // v3 venue (ROUND13): the tin shed on the warehouse fringe
|
||||
'The {Adj} Warehouse', '{Suburb} Band Room', 'The {Animal} Room', 'The {Word} Shed',
|
||||
'The Tote-ish', 'The {Adj} Room', '{Word} Rehearsal Rooms',
|
||||
],
|
||||
rsl: [ // v3 venue (ROUND13): the returned & services league club — carpet, bistro, meat raffle
|
||||
'{Suburb} RSL Club', '{Suburb} RSL', '{Suburb} Services Club', 'The {Suburb} Memorial Club',
|
||||
'{Suburb} Workers Club', '{Suburb} Returned Servicemen’s Club',
|
||||
],
|
||||
};
|
||||
|
||||
// ── band-name patterns (v3 gigs, 90s Aussie pub-rock) ──────────────────────────────
|
||||
@ -99,7 +107,9 @@ 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'],
|
||||
pub: ['HOTEL', 'THE ROYAL', '{Suburb} HOTEL', 'PUB'],
|
||||
band_room: ['BAND ROOM', 'LIVE MUSIC', '{Word} ROOM', 'THE SHED'],
|
||||
rsl: ['RSL', '{Suburb} RSL', 'SERVICES CLUB', 'MEMORIAL CLUB'],
|
||||
};
|
||||
|
||||
// Public: name a shop from its stable seed + type. Returns { name, sign }.
|
||||
|
||||
@ -9,8 +9,8 @@ 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 { generatePlanFor, gigKeyFor } from './index.js';
|
||||
import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js';
|
||||
import { xmur3 } from '../core/prng.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
@ -143,23 +143,67 @@ function structuralSuite(plan, label) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// invariants. Validates the DISTRICT 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`);
|
||||
structuralSuite(plan, label); // full coverage; venues exempt from late-shop rule
|
||||
const venues = plan.shops.filter(s => s.venue);
|
||||
const byId = new Map(venues.map(v => [v.id, v]));
|
||||
|
||||
// ── the district: 2–4 venues, kinds from the registry, never the openLate landmark ──
|
||||
ok(venues.length >= 2 && venues.length <= 4, `${label}: 2–4 venues (${venues.length})`);
|
||||
ok(venues.every(v => VENUE_KINDS.includes(v.venueKind)), `${label}: every venue has a known venueKind`);
|
||||
ok(venues.every(v => v.type === v.venueKind), `${label}: venue type ≡ venueKind (C keys its recipe off type)`);
|
||||
ok(venues.some(v => v.venueKind === 'pub'), `${label}: the town has a pub`);
|
||||
ok(venues.every(v => !v.openLate), `${label}: no venue displaced the openLate landmark`);
|
||||
ok(venues.every(v => (v.hours || [0, 0])[1] >= 22), `${label}: every venue is open into night so its gig can run`);
|
||||
ok(new Set(venues.map(v => v.lot)).size === venues.length, `${label}: venues occupy distinct lots`);
|
||||
ok(venues.every(v => v.genreKey === genreForVenueKind(v.venueKind)), `${label}: genreKey matches the registry kind→genre map`);
|
||||
// debt #1: the manifest key IS the gigKey. One key, zero mapping.
|
||||
ok(venues.every(v => gigKeyFor(v.genreKey) === `gig-${v.genreKey}`), `${label}: gigKey ≡ 'gig-'+genreKey`);
|
||||
|
||||
// ── the week ──
|
||||
ok(Array.isArray(plan.gigs) && plan.gigs.length >= 1, `${label}: has a gig schedule`);
|
||||
ok(plan.gigs.every(g => byId.has(g.venueShopId)), `${label}: every gig plays a real 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`);
|
||||
ok(plan.gigs.every(g => Number.isInteger(g.night) && g.night >= 0 && g.night < 7), `${label}: night index in [0,7)`);
|
||||
ok(plan.gigs.every(g => g.genreKey === byId.get(g.venueShopId).genreKey), `${label}: gig genre ≡ its venue's genre`);
|
||||
ok(new Set(plan.gigs.map(g => g.gigId)).size === plan.gigs.length, `${label}: gigIds unique across town`);
|
||||
// ~4–6 gigs per venue per week, and never two on the same night at one venue
|
||||
for (const v of venues) {
|
||||
const mine = plan.gigs.filter(g => g.venueShopId === v.id);
|
||||
ok(mine.length >= 4 && mine.length <= 6, `${label}: venue ${v.id} (${v.venueKind}) plays 4–6 nights (${mine.length})`);
|
||||
ok(new Set(mine.map(g => g.night)).size === mine.length, `${label}: venue ${v.id} plays ≤1 gig per night`);
|
||||
}
|
||||
// no band plays two venues at once (≤1 slot per name per night across town)
|
||||
let clash = null;
|
||||
for (let n = 0; n < 7 && !clash; n++) {
|
||||
const names = plan.gigs.filter(g => g.night === n).map(g => g.bandName);
|
||||
if (new Set(names).size !== names.length) clash = n;
|
||||
}
|
||||
ok(clash === null, `${label}: no band plays two venues on one night` + (clash !== null ? ` (night ${clash})` : ''));
|
||||
|
||||
// ── posters: town-wide, on the verge, never on the bitumen (debt #5) ──
|
||||
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`);
|
||||
ok(plan.posters.every(p => plan.gigs.find(g => g.gigId === p.gigId).night === 0), `${label}: posters advertise tonight's gigs`);
|
||||
// every poster clears its nearest edge's carriageway — the R12 bug was posters mid-road on nodes.
|
||||
let onRoad = null;
|
||||
for (const p of plan.posters) {
|
||||
let best = Infinity, bestEdge = null;
|
||||
for (const e of plan.streets.edges) {
|
||||
const a = nodeById2(plan, e.a), b = nodeById2(plan, e.b);
|
||||
if (!a || !b) continue;
|
||||
const [nx, nz] = nearestOnSeg(p.x, p.z, a.x, a.z, b.x, b.z);
|
||||
const d = Math.hypot(p.x - nx, p.z - nz);
|
||||
if (d < best) { best = d; bestEdge = e; }
|
||||
}
|
||||
if (bestEdge && best < roadWidth(bestEdge) / 2) { onRoad = [p.id, bestEdge.id, +best.toFixed(2)]; break; }
|
||||
}
|
||||
ok(onRoad === null, `${label}: no poster stands on a carriageway` + (onRoad ? ` (poster ${onRoad[0]} is ${onRoad[2]}m from edge ${onRoad[1]}'s centreline)` : ''));
|
||||
}
|
||||
const nodeById2 = (plan, id) => plan.streets.nodes.find(n => n.id === id);
|
||||
|
||||
// ── 1. determinism: two runs byte-identical ─────────────────────────────────────────
|
||||
section('determinism (deep-equal across two runs)');
|
||||
@ -243,7 +287,7 @@ ok(JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })) === JSON.str
|
||||
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 GIG_GOLDEN = 0x1f636349; // ROUND13 re-pin: multi-venue district changed the gig-plan output (was 0xa6ae5a5e, the one-pub alpha)
|
||||
{
|
||||
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')}`);
|
||||
|
||||
@ -8,7 +8,15 @@
|
||||
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', 'pub'];
|
||||
export const SHOP_TYPE_IDS = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall', 'pub', 'band_room', 'rsl'];
|
||||
|
||||
// ── v3 venue kinds (?gigs=1) ───────────────────────────────────────────────────────
|
||||
// The three gig venues. All three are registry shop types with NO district weights, so the base
|
||||
// generator never places them; the gig layer converts a chosen shop into one, in place.
|
||||
// `genre` is the single source of truth for the kind→genre map (ROUND13 §A1). The audio bed key is
|
||||
// ALWAYS `gig-<genre>` — see gigKeyFor() in citygen/gigs.js; the manifest key IS the gigKey, there is
|
||||
// no mapping table anywhere (ROUND13 debt #1).
|
||||
export const VENUE_KINDS = ['pub', 'band_room', 'rsl'];
|
||||
|
||||
// ── the registry ──────────────────────────────────────────────────────────────────
|
||||
// Per type:
|
||||
@ -125,12 +133,49 @@ export const SHOP_TYPES = {
|
||||
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)
|
||||
// `interior` is the Lane C RECIPE key, which C resolves from shop.type (theme.js canonicalType) —
|
||||
// so it must equal the type id. R12 shipped 'band_room' here, which was harmless only because C
|
||||
// keys off shop.type and nothing reads this field; now that `band_room` is itself a venue type it
|
||||
// would be an outright collision, so it's corrected to 'pub' (Lane A amendment, ROUND13).
|
||||
interior: 'pub', // 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 },
|
||||
venueKind: 'pub',
|
||||
genre: 'pubrock', // ⇒ audio bed key 'gig-pubrock'
|
||||
placement: 'spine_end', // a destination you follow the sound to (gigs.js pickVenues)
|
||||
},
|
||||
band_room: {
|
||||
label: 'Band Room',
|
||||
// A tin shed on the warehouse fringe: sticky floor, low ceiling, PA louder than the room.
|
||||
facades: ['facade-warehouse-tin.jpg', 'facade-besser.jpg', 'facade-corrugated.jpg'],
|
||||
sign: { bg: '#181818', fg: '#e8e6e1', accent: '#7ac74f', style: 'stencil' },
|
||||
interior: 'band_room',
|
||||
fittings: ['bar_counter', 'stage', 'pa_stack', 'stools'], // no tables — it's a standing room
|
||||
storeys: [1, 1],
|
||||
hours: { open: 18, close: 23 },
|
||||
weights: {},
|
||||
footprint: { fmin: 18, fmax: 28, dmin: 20, dmax: 30 },
|
||||
venueKind: 'band_room',
|
||||
genre: 'grunge', // ⇒ 'gig-grunge' (John may re-flavour — names only)
|
||||
placement: 'warehouse_fringe',
|
||||
},
|
||||
rsl: {
|
||||
label: 'RSL Club',
|
||||
// Carpet, bistro, a stage at one end, and the biggest crowd cap in town.
|
||||
facades: ['facade-brickveneer.jpg', 'facade-redbrick.jpg'],
|
||||
sign: { bg: '#0f2d4a', fg: '#f6e7c1', accent: '#c9a227', style: 'club' },
|
||||
interior: 'rsl',
|
||||
fittings: ['bar_counter', 'stage', 'pa_stack', 'bistro_tables', 'stools'],
|
||||
storeys: [1, 2],
|
||||
hours: { open: 16, close: 23 }, // opens early — the bistro trades before the band
|
||||
weights: {},
|
||||
footprint: { fmin: 20, fmax: 30, dmin: 22, dmax: 32 },
|
||||
venueKind: 'rsl',
|
||||
genre: 'covers', // ⇒ 'gig-covers'
|
||||
placement: 'off_spine',
|
||||
},
|
||||
};
|
||||
|
||||
@ -157,6 +202,43 @@ export function pickWeightedType(r, districtKind, exclude = null) {
|
||||
return list[list.length - 1].type;
|
||||
}
|
||||
|
||||
// The genre a venue kind plays (ROUND13 §A1 kind→genre map). Unknown kind ⇒ null.
|
||||
export function genreForVenueKind(kind) {
|
||||
const reg = SHOP_TYPES[kind];
|
||||
return (reg && reg.genre) || null;
|
||||
}
|
||||
|
||||
// ── street corridor law (Lane A publishes it as a FUNCTION, not plan data) ──────────
|
||||
// `edge.width` is the full CORRIDOR — carriageway + verge/footpath, centreline ± width/2 — as
|
||||
// CITY_SPEC states twice ("road corridor (road + verge, centreline ± width/2)") and as chunkIndex
|
||||
// buckets. It is NOT the carriageway width. The plan never carried the split, so consumers had to
|
||||
// guess it; these helpers are the split, published so nobody guesses again (ROUND13 debt #5).
|
||||
// Pure functions over an existing edge ⇒ no plan fields change ⇒ base goldens stay frozen.
|
||||
export function roadWidth(edge) {
|
||||
const w = (edge && edge.width) || 0;
|
||||
switch (edge && edge.kind) {
|
||||
case 'main': return 10; // 28m corridor = 10m carriageway + two ~9m verandah'd footpaths
|
||||
case 'side': return w >= 18 ? 8 : 6; // the two central rungs are second high streets (wider)
|
||||
case 'lane': return w; // a service laneway is all carriageway — no footpath
|
||||
case 'arcade': return 0; // covered pedestrian lane — no carriageway at all
|
||||
default: return Math.min(10, w);
|
||||
}
|
||||
}
|
||||
|
||||
// The band, as distance from the centreline, where footpath furniture lives: [kerb, corridor edge].
|
||||
// Street furniture, poles, verandah posts and gig posters belong in here — never inside `kerb`.
|
||||
export function vergeBand(edge) {
|
||||
const inner = roadWidth(edge) / 2;
|
||||
const outer = ((edge && edge.width) || 0) / 2;
|
||||
return [inner, Math.max(inner, outer)];
|
||||
}
|
||||
|
||||
// Where a pole-mounted thing (poster, sign) seats: just behind the kerb, clamped into the verge.
|
||||
export function poleOffset(edge, clear = 1) {
|
||||
const [inner, outer] = vergeBand(edge);
|
||||
return outer <= inner ? inner : Math.min(inner + clear, outer - 0.25);
|
||||
}
|
||||
|
||||
// Every facade filename referenced anywhere (Lane A selfcheck asserts each exists on disk).
|
||||
export function allFacadeSkins() {
|
||||
const set = new Set();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user