diff --git a/C-progress.md b/C-progress.md index f638ab8..e482fcc 100644 --- a/C-progress.md +++ b/C-progress.md @@ -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[]`** — 6–8 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. --- diff --git a/docs/LANES/LANE_C_PUB.md b/docs/LANES/LANE_C_PUB.md new file mode 100644 index 0000000..fc40787 --- /dev/null +++ b/docs/LANES/LANE_C_PUB.md @@ -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). diff --git a/web/js/interiors/fittings.js b/web/js/interiors/fittings.js index 4e1dc6f..3afef82 100644 --- a/web/js/interiors/fittings.js +++ b/web/js/interiors/fittings.js @@ -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). diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js index d3b718f..bbcdad3 100644 --- a/web/js/interiors/interiors.js +++ b/web/js/interiors/interiors.js @@ -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, diff --git a/web/js/interiors/layout.js b/web/js/interiors/layout.js index b1570ba..5eeb048 100644 --- a/web/js/interiors/layout.js +++ b/web/js/interiors/layout.js @@ -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 1–2 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; // 1–2 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) — 6–8 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: 6–8 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 diff --git a/web/js/interiors/theme.js b/web/js/interiors/theme.js index f63e0de..f30cc79 100644 --- a/web/js/interiors/theme.js +++ b/web/js/interiors/theme.js @@ -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 5–6', 'NO THONGS AFTER 7', 'COLD BEER ON TAP', 'BANDS OUT BACK'], + }, }; // Aliases: accept thriftgod / Overpass-style type names and map to canonical recipes.