Lane C: round-12 pub venue — archetype + watchPoints + audio.gigKey (?gigs=1)

v3.0-alpha band room. New pub recipe (venue:true): stage + PA + amps at the back wall (new stage/paSpeaker/ampStack fittings, GLB slots stage/pa_speaker/amp for E), bar counter (keeper), sticky carpet, blocked back door = green room.

buildInterior returns for venues: room.watchPoints[] (6-8 seeded audience poses {x,z,ry,dance} facing the stage, dance ~1/3, counter.stand ry convention); room.stage {x,z,w,d,deckY,frontZ,bandPoses[3]} for Lane D's band; room.audio.gigKey ('gig-'+genreKey) set when Lane F passes opts.gig.

Flags-off byte-identical: venue code runs only for recipe.venue on its own seed sub-streams; non-pub rooms have watchPoints:[], stage:null, unchanged placement/audio.

Verified: 40-seed pub sweep (0 throws/carves/path-fails, 8 watchPoints, dance 0.38) + 60-room mixed soak (0 det/path fails, leak geo0/tex0, worst 19ms). Non-pub unchanged. Interface for D/F: docs/LANES/LANE_C_PUB.md. scaffold + manifest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 18:59:48 +10:00
parent acf734a1e5
commit 05e020e3b8
6 changed files with 239 additions and 6 deletions

View File

@ -3,7 +3,30 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-15 (round 11) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-15 (round 12) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-15 (round 12, v3.0-alpha) — the pub venue: archetype + watchPoints
R12 §Lane C (venues & gigs, behind `?gigs=1`). Interface published in
[docs/LANES/LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) — the "publish interface first" deliverable D/F build on.
- **`pub` archetype** (`theme.js`, `venue:true`): low **stage** + PA cabinets + amps at the back wall (new
`stage`/`paSpeaker`/`ampStack` fittings, GLB slots `stage`/`pa_speaker`/`amp` for E), bar counter (`corner`
— the barkeep is just a keeper), sticky-carpet floor, the blocked back doorway = the green room.
- **`room.watchPoints[]`** — 68 seeded audience floor poses `{x,z,ry,dance,slotIndex}` facing the stage
centre (`counter.stand` ry convention). `dance` seeded **~⅓ true** (John: "a bit of both"). Walkable,
reachable, clear of keeper/browse/stage. **`room.stage`** = `{x,z,w,d,deckY,frontZ,bandPoses[3]}` so D
drops the band on the deck.
- **`room.audio.gigKey`** — F passes `opts.gig` (gig state); a gig-on pub returns `gigKey='gig-'+genreKey`
(`gig-pubrock` for the alpha), else a quiet-night interior. F prefers gigKey over musicKey. (→ E: name the
live bed `gig-pubrock`.)
- **Flags-off byte-identical**: venue code runs only for `recipe.venue`, on its own `stage`/`watch` sub-
streams — non-pub rooms have `watchPoints:[]`, `stage:null`, unchanged audio + placement.
- Verified: 40-seed pub sweep — 0 throws · **0 carves** (PA `areaFree`-guarded off the counter strip) · 0
path-fails · 8 watchPoints each · dance 0.38. 60-room mixed soak (incl. pub) — 0 throws/det/path fails,
**leak geo0/tex0**, worst 19 ms. Non-pub rooms confirmed unchanged.
---

55
docs/LANES/LANE_C_PUB.md Normal file
View File

@ -0,0 +1,55 @@
# LANE C — pub venue interface (round 12, v3.0-alpha) → for Lane D + Lane F
*PROCITY-C, 2026-07-15. R12 §Lane C. The band room ships behind `?gigs=1` (A only emits `pub` shops
when the flag is on). Interface published here + in this commit's code; non-pub rooms are byte-identical
(the venue code only runs for `recipe.venue`, on its own seed sub-streams). Verified: 40-seed pub sweep +
60-room mixed soak — 0 throws · 0 carves · 0 path-fails · 0 determinism-fails · leak geo0/tex0 · worst 19 ms.*
## What `buildInterior` returns for a pub (type `'pub'`)
Everything below is **pure data on the return** (room-local coords, `counter.stand` ry convention:
rig-front = local Z), so Lane D drops rigs straight in and Lane F reads state — no new lifecycle.
### `room.stage` — the band stage (null for non-venues)
```js
room.stage = {
x, z, // deck centre (against the back wall)
w, d, // deck size (m)
deckY, // top surface height (m) — band rigs stand at y = deckY
frontZ, // z of the deck lip (the crowd stands in front of this, +Z)
bandPoses: [ { x, z, ry, role } ×3 ], // guitar / vocal / bass, across the deck front, facing the crowd (ry=π → +Z)
}
```
**Lane D:** put the 3-piece band at `bandPoses` (lift to `y = deckY`); `role` hints instrument. Drummer
can sit up-stage on the riser (there's a raised riser at deck-centre-back) — your call, C just gives 3 front poses.
Instruments = E's GLBs when present, primitives otherwise (asset law).
### `room.watchPoints` — the audience (empty [] for non-venues)
```js
room.watchPoints = [ { x, z, ry, dance, slotIndex } ] // 6..8 seeded floor poses, all facing the stage centre
```
- `dance` is seeded **~⅓ true** ("a bit of both"). **Lane D:** `dance:false` → stand-and-watch idle
(subtle weight-shift); `dance:true` → bob/sway/step loop with a **seeded phase offset** so the crowd
doesn't metronome (use `slotIndex` or the pose coords to derive the phase).
- Every point is walkable, reachable from the door, and clear of the keeper stand, browse points, and the
stage/PA footprint. Deterministic per shop.seed. Crowd cap = `watchPoints.length` (F's smoke asserts ≤).
### `room.counter` / keeper — unchanged
The bar is a normal counter; the **barkeep is just a keeper** at `room.counter.stand` (existing R9 seam).
### `room.audio.gigKey` — the live bed
`room.audio = { musicKey, toneKey, gigKey? }`. **Lane F** passes gig state via `opts.gig` to `buildInterior`:
```js
buildInterior(pubShop, THREE, { gig: { on: true, genreKey: 'pubrock' } })
// → room.audio.gigKey === 'gig-pubrock' (gigKey = 'gig-' + genreKey)
```
- `gigKey` is present **only when a gig is on**. A quiet-night pub has no `opts.gig` → normal interior
(`toneKey` room-tone + maybe the seeded radio `musicKey`).
- **Prefer `gigKey` over `musicKey`** while the gig plays: `play(room.audio.gigKey || room.audio.musicKey)`.
- **Lane E:** please name the pub-rock live bed **`gig-pubrock`** in `manifest.audio` (music or a `gig`
section) so the key resolves; missing key → silence (audio law), band still visible.
## The archetype (C-owned, FYI)
`pub` recipe (`theme.js`, `venue:true`): stage + PA + amps at the back wall, bar counter (`corner`),
sticky-carpet floor, pub tables + bar-back shelf against the walls, the blocked back doorway doubles as
the **green room**. Primitive stage/PA/amp fittings today with **GLB slots for Lane E**: `stage`,
`pa_speaker`, `amp` (house GLB law; primitive fallback holds — a gig with no GLBs is still a gig).

View File

@ -392,12 +392,51 @@ function artFrame(ctx, o = {}, r) {
return { group: g, footprint: { w: sz, d: 0.08 }, height: sz, slots: [], places: [], wallMounted: true, mountY: 1.5 };
}
// ── VENUE (pub, round 12 · behind ?gigs=1) ───────────────────────────────────────────
// Low band stage — a timber deck with a dark backdrop + a drum riser, against the back wall. The band
// (Lane D) stands on the deck (`deckY` = top surface). GLB slot: `stage` (E's when present, else this).
function stage(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 3.4, d = o.d || 2.0, h = o.h || 0.32;
const deck = ctx.mat('#3a2a1e', 0.85), riser = ctx.mat('#2a2018', 0.85);
ctx.box(w, h, d, deck, 0, h / 2, 0, g); // deck
ctx.box(w + 0.06, 0.06, d + 0.06, ctx.mat('#1c130d', 0.9), 0, h, 0, g); // deck edge trim
ctx.box(w, 2.2, 0.06, ctx.mat('#161016', 0.95), 0, h + 1.1, -d / 2 + 0.05, g); // backdrop curtain
ctx.box(1.0, 0.16, 0.9, riser, 0, h + 0.08, -d / 4, g); // drum riser, up-stage centre
g.userData = { kind: 'stage' };
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 2.3, slots: [], places: [g], deckY: h };
}
// PA speaker stack — a tall black cabinet flanking the stage. GLB slot: `pa_speaker`.
function paSpeaker(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = 0.6, d = 0.55, h = o.h || 1.7;
ctx.box(w, h, d, ctx.mat('#1a1a1e', 0.7), 0, h / 2, 0, g); // cabinet
const grille = ctx.mat('#0c0c10', 0.6);
ctx.box(w - 0.1, 0.5, 0.02, grille, 0, h * 0.32, d / 2, g); // woofer face
ctx.box(w - 0.14, 0.24, 0.02, grille, 0, h - 0.32, d / 2, g); // horn face
g.userData = { kind: 'prop' };
return { group: g, footprint: { w, d }, height: h, slots: [], places: [] };
}
// Guitar/bass combo amp — sits ON the stage deck behind the band. GLB slot: `amp`.
function ampStack(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = 0.62, d = 0.36, h = 0.5;
ctx.box(w, h, d, ctx.mat(r() < 0.5 ? '#2a2320' : '#20242a', 0.8), 0, h / 2, 0, g);
ctx.box(w - 0.06, 0.3, 0.02, ctx.mat('#100f10', 0.6), 0, h * 0.55, d / 2, g); // grille cloth
g.userData = { kind: 'prop' };
return { group: g, footprint: { w, d }, height: h, slots: [], places: [] };
}
export const FITTINGS = {
recordBin, crate, crateStack, listeningCorner,
wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack, arcadeCabinet,
clothesRack,
trestleTable, counter, glassCase,
pegboard, fridge, magazineRack, armchair, barredWindow, returnsSlot, escalator, artFrame,
stage, paSpeaker, ampStack,
};
// Build a fitting by kind. Unknown kinds fall back to a wall shelf (never crash).

View File

@ -19,8 +19,12 @@
// // stands, in room-local space, facing the customer.
// browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands
// // browser rigs, facing the goods (same frame + ry as counter.stand)
// audio: { musicKey, toneKey }, // R11 — keys into Lane E manifest.audio (interior music bed +
// // room-tone), seeded per shop. musicKey may be null (no music).
// audio: { musicKey, toneKey, gigKey? }, // R11/R12 — keys into Lane E manifest.audio (music bed +
// // room-tone), seeded per shop. musicKey may be null. gigKey set
// // only when opts.gig is on (venue) — F prefers gigKey over musicKey.
// watchPoints: [ {x,z,ry,dance} ], // VENUE (R12, pub only, else []) — 6..8 audience poses facing the
// // stage; dance ~1/3 true. Same frame + ry convention as counter.stand.
// stage: { x,z,w,d,deckY,frontZ,bandPoses }, // VENUE (R12, else null) — the band stage; bandPoses[3] on the deck.
// dims: { W, D, H, archetype, type }, // room metrics
// placement, // deterministic placement summary (deep-equal per seed)
// pathOK, // door→counter connectivity held (always true; carved if needed)
@ -88,14 +92,19 @@ const MUSIC_BY_TYPE = { // manifest.audio.music dedi
dept: 'arcade', arcade: 'arcade',
}; // default → null (no dedicated bed)
function audioFor(type, ctx) {
function audioFor(type, ctx, opts) {
const toneKey = ROOMTONE_BY_TYPE[type] || 'roomtone-retail';
let musicKey = MUSIC_BY_TYPE[type] || null;
// Shops with no dedicated bed: a seeded ~1-in-3 keeps a general radio on (the milk-bar bed carries the
// manifest "general" tag). Seeded on its own sub-stream so the same shop is silent-or-playing every
// revisit and adding it never shifts another subsystem's seeded picks.
if (!musicKey && ctx.stream('audio')() < 0.34) musicKey = 'milkbar';
return { musicKey, toneKey };
const audio = { musicKey, toneKey };
// Venue gig (R12): Lane F passes the live gig state via opts.gig; F should prefer gigKey over musicKey
// while the gig is on. A quiet-night pub has no opts.gig → normal interior (room-tone + maybe radio).
const gig = opts && opts.gig;
if (gig && (gig.on || gig.genreKey)) audio.gigKey = 'gig-' + (gig.genreKey || 'pubrock');
return audio;
}
export function buildInterior(shop, THREE, opts) {
@ -150,7 +159,9 @@ export function buildInterior(shop, THREE, opts) {
places: lay.places,
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
audio: audioFor(recipe.key, ctx), // { musicKey, toneKey } — Lane E manifest.audio keys (R11), seeded per shop
watchPoints: lay.watchPoints || [], // venue (R12): [ {x,z,ry,dance,slotIndex} ] 6..8 audience poses (else [])
stage: lay.stage || null, // venue (R12): { x,z,w,d,deckY,frontZ,bandPoses[3] } (else null)
audio: audioFor(recipe.key, ctx, opts), // { musicKey, toneKey, gigKey? } — Lane E manifest.audio keys (R11/R12)
dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement,

View File

@ -209,6 +209,75 @@ function placeBrowsePoints(grid, placed, counterStand, spawnCell, r) {
return pts;
}
// ── VENUE stage (pub, round 12 · behind ?gigs=1) ──────────────────────────────────────
// Places a low stage centred on the back wall, PA cabinets flanking it on the floor, and 12 amps on the
// deck. Stage + PA go into `placed` at priority 8 so the door→counter path loop never pulls them. Returns
// the stage frame the crowd faces + 3 seeded band poses on the deck (Lane D drops the band trio in).
function placeStage(ctx, grid, W, D, roomGroup, r) {
const sw = Math.min(W - 1.4, Math.max(3.0, W * 0.58)), sd = 2.0;
const cx = 0, cz = -D / 2 + sd / 2 + 0.2; // back wall, centred
const f = buildFitting('stage', ctx, { w: sw, d: sd }, r);
f.group.position.set(cx, 0, cz); roomGroup.add(f.group);
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, 0);
const rect = rectCells(grid, cx, cz, hw, hd); stampRect(grid, rect);
const out = [{ fitting: f, x: cx, z: cz, ry: 0, hw, hd, rect, priority: 8, kind: 'stage' }];
for (const sgn of [-1, 1]) { // PA cabinets at the stage front corners, pulled
const px = sgn * (sw / 2 - 0.3), pz = cz + sd / 2 + 0.35; // in + just off the deck lip
const [phw, phd] = halfExtents(0.6, 0.55, 0); // paSpeaker footprint (fixed)
const prect = rectCells(grid, px, pz, phw, phd);
if (!areaFree(grid, prect)) continue; // never stamp over the counter's service strip / a wall
const pf = buildFitting('paSpeaker', ctx, {}, r);
pf.group.position.set(px, 0, pz); roomGroup.add(pf.group);
stampRect(grid, prect);
out.push({ fitting: pf, x: px, z: pz, ry: 0, hw: phw, hd: phd, rect: prect, priority: 8, kind: 'prop' });
}
const deckY = f.deckY || 0.32; // 12 amps on the deck behind the band
for (let i = 0, n = 1 + (r() < 0.5 ? 1 : 0); i < n; i++) {
const af = buildFitting('ampStack', ctx, {}, r);
af.group.position.set(cx + (i === 0 ? -1 : 1) * sw * 0.28, deckY, cz - sd * 0.18);
roomGroup.add(af.group);
}
const bandPoses = [-sw * 0.28, 0, sw * 0.28].map((bx, i) => ({ // 3 across the deck front, facing +Z (audience)
x: round(cx + bx), z: round(cz + sd * 0.12), ry: round(Math.PI), role: ['guitar', 'vocal', 'bass'][i],
}));
return { placed: out, stage: { x: cx, z: cz, w: sw, d: sd, deckY, frontZ: round(cz + sd / 2), bandPoses } };
}
// ── WATCH POINTS (venue, round 12) — 68 seeded audience floor poses facing the stage ──
// Same pure-data pattern as browse points: walkable free cells, reachable from spawn, clear of the keeper
// stand + browse points + stage, spaced for a crowd. `dance` seeded ~1/3 true (John: "a bit of both").
function placeWatchPoints(grid, stage, spawnCell, counterStand, browsePoints, r) {
let start = spawnCell;
if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
const reach = floodFill(grid, start);
const avoid = [counterStand, ...(browsePoints || [])].filter(Boolean);
const near = (x, z, list, d) => list.some(q => Math.hypot(q.x - x, q.z - z) < d);
const cands = [];
for (let z = stage.frontZ + 0.6; z <= grid.D / 2 - 1.6; z += 0.7) // between stage lip and the door zone
for (let x = -grid.W / 2 + 0.8; x <= grid.W / 2 - 0.8; x += 0.8) cands.push({ x, z });
for (const c of cands) c.key = r(); // seed the visiting order up front (determinism)
cands.sort((a, b) => a.key - b.key);
const pts = [];
const valid = (x, z) => {
const [cx, cz] = grid.cellOf(x, z);
if (cx < 1 || cz < 1 || cx >= grid.cols - 1 || cz >= grid.rows - 1) return false;
const idx = cz * grid.cols + cx;
if (grid.occ[idx] !== 0 || grid.reserved[idx] || !reach.has(idx)) return false;
if (near(x, z, avoid, MIN_SEP) || near(x, z, pts, 0.7)) return false;
return true;
};
for (const c of cands) {
if (pts.length >= 8) break;
if (!valid(c.x, c.z)) continue;
const dx = stage.x - c.x, dz = stage.z - c.z; // face the stage centre (counter.stand ry convention)
pts.push({ x: round(c.x), z: round(c.z), ry: round(Math.atan2(-dx, -dz)), dance: r() < 0.34, slotIndex: pts.length });
}
return pts;
}
// ── wall-slot system (shuffled pool → art + signs + wall fittings never overlap) ──────
function makeWallSlots(W, D, r) {
const slots = [];
@ -268,6 +337,15 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
// reserve the strip in front of the till so nothing blocks service
reserveRect(grid, counter.front.x - 0.5, counter.front.z - 0.5, counter.front.x + 0.5, counter.front.z + 0.5);
// 1b) VENUE STAGE (pub, round 12) — placed BEFORE the fitting mix so its footprint is reserved. Only
// for recipe.venue; other rooms never touch the 'stage'/'watch' streams → flags-off byte-identical.
let stageInfo = null, watchPoints = [];
if (recipe.venue) {
const st = placeStage(ctx, grid, W, D, roomGroup, ctx.stream('stage'));
stageInfo = st.stage;
for (const sp of st.placed) placed.push(sp);
}
// 2) FITTINGS from the recipe mix (skip the counter entry — already placed).
const area = W * D;
const wallSlots = makeWallSlots(W, D, rWall);
@ -330,6 +408,10 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
// every point sits on a survivor's open side and on a cell reachable from the door.
const browsePoints = placeBrowsePoints(grid, placed, counter.stand, spawnCell, ctx.stream('browse'));
// 6b) WATCH POINTS (venue, round 12) — audience floor poses facing the stage, computed after the path
// guarantee + browse points (so they avoid both), on cells reachable from the door.
if (stageInfo) watchPoints = placeWatchPoints(grid, stageInfo, spawnCell, counter.stand, browsePoints, ctx.stream('watch'));
// placement summary for the determinism deep-equal test
const placement = placed.filter(p => !p.removed).map(p => ({
kind: p.kind || 'fitting', kindName: p.fitting.group.userData.kind,
@ -340,6 +422,8 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
placed: placed.filter(p => !p.removed),
places, decor: decor.meshes, grid, pathOK, carved,
spawnCell, targetCell, placement, browsePoints,
watchPoints, // venue: 68 audience poses {x,z,ry,dance} facing the stage (else [])
stage: stageInfo, // venue: { x,z,w,d,deckY,frontZ,bandPoses } (else null)
counter: {
mesh: counter.fitting.group,
pose: { x: round(counter.x), z: round(counter.z), ry: round(counter.ry) }, // the bench itself

View File

@ -200,6 +200,27 @@ const RECIPES = {
stockKind: 'boxes',
signFlavour: ['$1 EACH', 'CASH ONLY', 'FIRST IN BEST DRESSED', 'MAKE ME AN OFFER', 'FRESH TODAY'],
},
// ── PUB (venue, v3.0-alpha · round 12, behind ?gigs=1) ───────────────────────────────
// The band room. `venue: true` makes layout.js place a stage (+ PA/amps) at the back wall and emit
// room.watchPoints[] for Lane D's crowd + room.stage for the band. Bar counter = a normal counter, so
// the barkeep is just a keeper. Sticky-carpet floor; the blocked back doorway doubles as the green room.
pub: {
label: 'Pub',
archetypeBias: [['wide', 3], ['hall', 2]], // big open room for a crowd
wallpaperBias: ['damask-mauve', 'woodchip-white', 'stripe-sage'],
floorBias: ['carpet-swirl', 'carpet-mustard'], // sticky carpet
clutter: 0.45, // keep the floor open — the crowd fills it
counterPos: 'corner', // the bar, along the east wall (keeper = barkeep)
venue: true, // → stage + PA + watchPoints (layout.js)
fittings: [
{ kind: 'trestleTable', zone: 'wall', min: 1, max: 3, priority: 2 }, // pub tables against the walls
{ kind: 'metalShelf', zone: 'wall', min: 0, max: 1, priority: 2 }, // bar back-shelf (bottles)
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'snacks',
signFlavour: ['LIVE MUSIC FRI', 'HAPPY HOUR 56', 'NO THONGS AFTER 7', 'COLD BEER ON TAP', 'BANDS OUT BACK'],
},
};
// Aliases: accept thriftgod / Overpass-style type names and map to canonical recipes.