PROCITY/web/js/interiors/theme.js
m3ultra eeba920d68 Lane C R39 §39.3: the op shop gets a bin — the verb table, and the margin was never six
THE TABLE (LANE_C_PUB §10, the deliverable): what a player can actually do in each of
the 12 shop types, counted in 48 rooms per type. `record` digs (4.6 bins/room) and is the
only room where anything can be sold. `stall` digs — in 27 of 48 rooms; the other 21 (44%)
have NO crate, a measured correction to the round's binding fact #5. The other ten types
offer looking. `?stock=real` is opt-in, so the default boot has zero `buyMesh` anywhere;
garments/snacks/magazines/treasures are unbuyable in any mode; `interactable:true` on
cases/fridges/returns/arcade is read by nothing.

THE MECHANISM: `rummageBin` — a castored galvanised dump tub, rim 0.76 m, heaped with
stickered bric-a-brac and a hand-lettered "EVERYTHING $2" card. 72 rooms: placed 72/72,
reachable 72/72 (0.77–1.15 m), 0 carves, pathOK 72/72, deterministic 72/72, and the rest
of the room byte-identical to pre-R39 72/72 (the spec is appended last). Armed end-to-end:
aim → dig → 16 offers → BUY → bin depletes. Default: 0 bins, dig never opens.

THE CONTENTS ARE NOT SHIPPED. `o.dig` defaults false, so the tub is furniture and the
economy is untouched; `opts.fittingOpts.rummageBin.dig` (or `?opshopdig=1`) arms it. Held
for John with the numbers: 44 record + 36 stall vs 147 op shops across 23 towns.

THE BUDGET: `C-progress.md:841`'s GLB-on 344 is R9-era and stale — today's tree measured
319 before I touched it, so the margin was 31, not six. The bin costs +5 GLB-off / +10
GLB-on at opshop/hall; every other type byte-identical. Margin now 21. Also: `slot.pool`
stops the scatter fill minting a material per item, so 12 goods cost less than 9 did.

FILED, NOT FIXED: Lane E's bookshelf GLB ships transmission:1, which triggers three.js's
transmission pre-pass and issues every draw in the room twice — opshop/hall 191→104,
book/hall 101→51. Roughly half the interior budget. E's asset, so E/F rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:59:42 +10:00

321 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane C — type theming. Every CITY_SPEC shop type gets a distinct interior recipe so a
// `record` shop instantly reads different from a `toy` shop the moment the door opens.
//
// LANE_C says "import SHOP_TYPES from core/registry.js" — Lane A hasn't landed registry.js yet, so
// this lane stays FULLY STANDALONE: the canonical recipes live here. When registry.js exists, Lane F
// can call `mergeRegistry(SHOP_TYPES)` to let authored registry data override any field. No hard
// dependency, so the test page runs with zero other lanes.
// Fitting `kind`s referenced below must exist in fittings.js FITTINGS registry.
// Zones the layout placer understands: 'window' (front, by glazing), 'aisle' (centre rows),
// 'wall' (perimeter, uses wall-slots), 'centre' (free-standing island), 'counter' (till corner).
//
// Recipe fields:
// archetypeBias : weighted room-shape preference (keys of shell.ARCHETYPES) — 'auto' picks from here
// wallpaperBias : biased pick from web/assets/gen/wall-*.jpg (names without prefix/suffix)
// floorBias : biased pick from carpet/lino/board floor names
// clutter : 0..1 density multiplier (some shops crammed, some sparse)
// counterPos : 'door' | 'forward' | 'corner' | 'back' — where the till lives
// fittings : the mix. Each {kind, zone, min, max, priority, o?}. Counts scale with clutter+area.
// priority: higher = more essential; flood-fill removes low-priority first if the
// door→counter path is ever blocked. Counter is always highest.
// o (R39, optional): opts handed to the fitting builder. Pure data, reads no stream.
// A per-build override lives at buildInterior opts.fittingOpts[kind].
// fallbackZones / anywhere (R39, optional): opt-in placement fallbacks for a fitting
// the recipe PROMISES will be there. Absent ⇒ pre-R39 behaviour (drop it).
// stockKind : what stock.js renders into this shop's carriers
// signFlavour : seeded inside signage strings, themed
export const ARCH = { cosy: 'cosy', gallery: 'gallery', wide: 'wide', hall: 'hall', pokey: 'pokey' };
const RECIPES = {
// ── RECORD ─────────────────────────────────────────────────────────────────────
record: {
label: 'Record Store',
archetypeBias: [['cosy', 3], ['gallery', 2], ['pokey', 1]],
wallpaperBias: ['woodchip-white', 'stripe-sage', 'geo-orange'],
floorBias: ['boards-polished', 'carpet-greygreen', 'lino-cork'],
clutter: 0.85,
counterPos: 'corner',
fittings: [
{ kind: 'listeningCorner', zone: 'window', min: 1, max: 1, priority: 6 }, // focal hero prop — placed first + high priority
{ kind: 'recordBin', zone: 'aisle', min: 2, max: 5, priority: 5 },
{ kind: 'crate', zone: 'centre', min: 1, max: 3, priority: 3 },
{ kind: 'wallShelf', zone: 'wall', min: 1, max: 3, priority: 3 },
{ kind: 'crateStack', zone: 'wall', min: 1, max: 2, priority: 2 }, // back-room dressing (crate_stack GLB)
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'sleeves',
signFlavour: ['NEW ARRIVALS', 'VINYL $5 EA', 'SORRY NO REFUNDS', '2ND HAND CDs', 'JAZZ · SOUL · FUNK'],
},
// ── OP SHOP ────────────────────────────────────────────────────────────────────
opshop: {
label: 'Op Shop',
archetypeBias: [['cosy', 3], ['wide', 2], ['hall', 1]],
wallpaperBias: ['floral-cream', 'damask-mauve', 'trellis-blue', 'floral-gold'],
floorBias: ['carpet-swirl', 'carpet-mustard', 'lino-check'],
clutter: 1.0,
counterPos: 'corner',
fittings: [
{ kind: 'clothesRack', zone: 'centre', min: 2, max: 5, priority: 5 },
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 3, priority: 4 },
{ kind: 'bookshelf', zone: 'wall', min: 1, max: 2, priority: 3 },
{ kind: 'trestleTable', zone: 'aisle', min: 1, max: 2, priority: 3 },
{ kind: 'crateStack', zone: 'wall', min: 1, max: 2, priority: 2 }, // back-room dressing (crate_stack GLB)
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
// [R39 — THE OP SHOP CAN'T BE DUG] the op shop's own floor bin. APPENDED LAST on purpose: the
// recipe loop draws counts from `lay-count` and placements from `lay-place` in array order, so a
// spec added at the end leaves every existing op-shop fitting's draws — and therefore its
// placement — byte-identical, and only adds the tub. `dig:false` is the CONTENTS gate: off, this
// is furniture (kind 'prop'); `{ rummageBin:{dig:true} }` in opts.fittingOpts makes every op shop
// diggable. WHAT IT YIELDS IS JOHN'S CALL — LANE_C_PUB §10. Do not flip the default in passing.
{ kind: 'rummageBin', zone: 'aisle', min: 1, max: 1, priority: 4,
fallbackZones: ['centre', 'window'], anywhere: true, o: { dig: false } },
],
stockKind: 'garments',
signFlavour: ['ALL CLOTHES $2', 'BAG SALE SAT', 'BRIC-A-BRAC', 'PROCEEDS TO CHARITY', 'HALF PRICE'],
},
// ── TOY ────────────────────────────────────────────────────────────────────────
toy: {
label: 'Toy Shop',
archetypeBias: [['wide', 3], ['cosy', 2], ['hall', 1]],
wallpaperBias: ['geo-orange', 'diamond-green', 'trellis-blue'],
floorBias: ['lino-check', 'carpet-greygreen', 'lino-cork'],
clutter: 0.75,
counterPos: 'corner',
fittings: [
{ kind: 'cubeShelf', zone: 'wall', min: 2, max: 4, priority: 5 },
{ kind: 'trestleTable', zone: 'aisle', min: 1, max: 3, priority: 3 },
{ kind: 'glassCase', zone: 'counter', min: 1, max: 1, priority: 4 },
{ kind: 'metalShelf', zone: 'aisle', min: 1, max: 2, priority: 3 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'boxes',
signFlavour: ['NEW! ', 'AS SEEN ON TV', 'PLAY & SAVE', 'BATTERIES EXTRA', 'LAYBY WELCOME'],
},
// ── BOOK ───────────────────────────────────────────────────────────────────────
book: {
label: 'Book Barn',
archetypeBias: [['hall', 3], ['gallery', 2], ['cosy', 1]],
wallpaperBias: ['damask-mauve', 'woodchip-white', 'floral-gold'],
floorBias: ['carpet-greygreen', 'boards-polished', 'carpet-swirl'],
clutter: 0.9,
counterPos: 'corner',
fittings: [
{ kind: 'bookshelf', zone: 'aisle', min: 3, max: 5, priority: 5 },
{ kind: 'bookshelf', zone: 'wall', min: 2, max: 3, priority: 4 },
{ kind: 'spinnerRack', zone: 'centre', min: 1, max: 2, priority: 3 },
{ kind: 'armchair', zone: 'window', min: 1, max: 1, priority: 2 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'spines',
signFlavour: ['ALL BOOKS $2', 'FICTION AM', 'RARE & COLLECTABLE', 'NO HAGGLING (haggle at till)', 'POETRY ↑'],
},
// ── VIDEO ──────────────────────────────────────────────────────────────────────
video: {
label: 'Video Rental',
archetypeBias: [['gallery', 3], ['cosy', 2], ['wide', 1]],
wallpaperBias: ['diamond-green', 'geo-orange', 'stripe-sage'],
floorBias: ['carpet-swirl', 'carpet-greygreen', 'lino-cork'],
clutter: 0.85,
counterPos: 'door',
fittings: [
{ kind: 'arcadeCabinet', zone: 'aisle', min: 1, max: 1, priority: 6 }, // focal hero prop — placed first + high priority so the door-counter path loop won't pull it
{ kind: 'vhsAisle', zone: 'aisle', min: 2, max: 4, priority: 5 },
{ kind: 'vhsAisle', zone: 'wall', min: 1, max: 2, priority: 4 },
{ kind: 'returnsSlot', zone: 'counter', min: 1, max: 1, priority: 4 },
{ kind: 'pegboard', zone: 'wall', min: 1, max: 2, priority: 2 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'spines',
signFlavour: ['NEW RELEASES', 'OVERNIGHT $3', 'BE KIND REWIND', 'WEEKLY $2', 'DROP RETURNS HERE'],
},
// ── PAWN ───────────────────────────────────────────────────────────────────────
pawn: {
label: 'Pawnbroker',
archetypeBias: [['pokey', 3], ['cosy', 2], ['gallery', 1]],
wallpaperBias: ['woodchip-white', 'stripe-sage', 'damask-mauve'],
floorBias: ['lino-cork', 'boards-polished', 'lino-check'],
clutter: 0.7,
counterPos: 'forward', // counter is right up front; goods behind it
fittings: [
{ kind: 'glassCase', zone: 'counter', min: 1, max: 3, priority: 6 },
{ kind: 'pegboard', zone: 'wall', min: 1, max: 3, priority: 4 }, // wall hooks
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 3 },
{ kind: 'magazineRack', zone: 'wall', min: 1, max: 1, priority: 2 }, // magazine_rack GLB
{ kind: 'barredWindow', zone: 'window', min: 1, max: 1, priority: 3 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'mixed',
signFlavour: ['WE BUY ANYTHING', 'CASH LOANS', 'GOLD · TOOLS · GEAR', 'ID REQUIRED', 'UNREDEEMED PLEDGES'],
},
// ── MILK BAR ─────────────────────────────────────────────────────────────────────
milkbar: {
label: 'Milk Bar',
archetypeBias: [['pokey', 3], ['cosy', 2]],
wallpaperBias: ['diamond-green', 'geo-orange', 'stripe-sage'],
floorBias: ['lino-check', 'lino-cork', 'carpet-mustard'],
clutter: 0.8,
counterPos: 'door', // counter faces the door — you're served on the way in
fittings: [
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
{ kind: 'fridge', zone: 'wall', min: 1, max: 3, priority: 6 },
{ kind: 'magazineRack', zone: 'wall', min: 1, max: 2, priority: 4 }, // wall (was window — conflicted with the door-counter)
{ kind: 'glassCase', zone: 'counter', min: 1, max: 1, priority: 3 }, // lolly/treat display case (glass_case GLB)
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 3, priority: 4 },
{ kind: 'cubeShelf', zone: 'aisle', min: 0, max: 1, priority: 2 },
],
stockKind: 'snacks',
signFlavour: ['MILK · BREAD · SMOKES', 'PIES HOT', 'PAPERS IN', 'PHONECARDS', 'NO SCHOOL KIDS AT LUNCH'],
},
// ── DEPT (anchor) ──────────────────────────────────────────────────────────────
dept: {
label: 'Department Store',
archetypeBias: [['hall', 4], ['wide', 1]],
wallpaperBias: ['floral-gold', 'damask-mauve', 'trellis-blue'],
floorBias: ['carpet-greygreen', 'carpet-swirl', 'boards-polished'],
clutter: 0.7,
counterPos: 'corner',
fittings: [
{ kind: 'cubeShelf', zone: 'aisle', min: 2, max: 4, priority: 4 },
{ kind: 'clothesRack', zone: 'centre', min: 2, max: 4, priority: 4 },
{ kind: 'glassCase', zone: 'aisle', min: 1, max: 2, priority: 3 },
{ kind: 'metalShelf', zone: 'wall', min: 2, max: 4, priority: 3 },
{ kind: 'escalator', zone: 'centre', min: 1, max: 1, priority: 5 }, // the grand-hall centrepiece
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'mixed',
signFlavour: ['MANCHESTER ↑', 'MENSWEAR', 'HABERDASHERY', 'GROUND FLOOR', 'STAFF ONLY'],
},
// ── STALL (market) ───────────────────────────────────────────────────────────────
stall: {
label: 'Market Stall',
archetypeBias: [['wide', 3], ['hall', 1]],
wallpaperBias: ['woodchip-white', 'stripe-sage'], // barely walled; open feel
floorBias: ['lino-cork', 'boards-polished'],
clutter: 0.95,
counterPos: 'forward',
fittings: [
{ kind: 'trestleTable', zone: 'aisle', min: 3, max: 6, priority: 5 },
{ kind: 'crate', zone: 'centre', min: 2, max: 5, priority: 3 },
{ kind: 'clothesRack', zone: 'wall', min: 0, max: 2, priority: 2 },
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 8 },
],
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)
// venueSpec (round 13): per-kind stage/crowd tuning read by layout.js placeStage/placeWatchPoints.
// watchMax = crowd cap (== max watchPoints); deckH = deck height; deckDepth/deckWidthFrac size the
// deck. pub values == the R12 constants, so the pub stage is byte-identical (bar the new drums pose).
venueSpec: { watchMax: 8, deckH: 0.32, deckDepth: 2.0, deckWidthFrac: 0.58 },
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'],
},
// ── BAND ROOM (venue, v3.0-beta · round 13, behind ?gigs=1) ──────────────────────────
// A tin shed on the warehouse fringe: small, scuzzy, low ceiling, the PA louder than the room and the
// stage barely a riser. No tables — you stand and you sweat. `genre: 'grunge'` ⇒ gigKey 'gig-grunge'.
// Reuses the pub venue machinery; only the dressing + venueSpec differ.
band_room: {
label: 'Band Room',
archetypeBias: [['cosy', 3], ['pokey', 2], ['wide', 1]], // small rooms; low ceilings come from storeys=1
wallpaperBias: ['woodchip-white', 'stripe-sage', 'damask-mauve'], // dim + dingy
floorBias: ['lino-cork', 'carpet-swirl', 'boards-polished'], // bare/sticky
clutter: 0.35, // near-empty standing room — the crowd IS the furniture
counterPos: 'corner', // a plank bar in the corner (keeper = barkeep)
venue: true,
venueSpec: { watchMax: 8, deckH: 0.2, deckDepth: 1.6, deckWidthFrac: 0.62 }, // barely a riser, wide + shallow
fittings: [
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 2 }, // bar back-shelf (tinnies)
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'snacks',
signFlavour: ['ALL AGES SUN', 'PAY THE BAND', 'NO STAGE DIVING', 'MERCH OUT BACK', '$5 ON THE DOOR'],
},
// ── RSL CLUB (venue, v3.0-beta · round 13, behind ?gigs=1) ───────────────────────────
// Carpet, flock wallpaper, a bistro round the edge, a proper stage at one end, and the biggest crowd
// cap in town — the round-13 crowd-cap stress case. `genre: 'covers'` ⇒ gigKey 'gig-covers'.
rsl: {
label: 'RSL Club',
archetypeBias: [['hall', 3], ['wide', 2]], // big floor for the biggest crowd
wallpaperBias: ['damask-mauve', 'floral-gold', 'trellis-blue'], // clubby flock
floorBias: ['carpet-mustard', 'carpet-swirl', 'carpet-greygreen'], // patterned club carpet
clutter: 0.5, // bistro tables round the edges, floor kept open for the crowd
counterPos: 'corner', // the members' bar
venue: true,
venueSpec: { watchMax: 12, deckH: 0.4, deckDepth: 2.4, deckWidthFrac: 0.5 }, // a raised stage at one end
fittings: [
{ kind: 'trestleTable', zone: 'wall', min: 2, max: 4, priority: 2 }, // bistro tables round the edge
{ kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 2 }, // bar back-shelf
{ kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
],
stockKind: 'snacks',
signFlavour: ['MEMBERS & GUESTS', 'BISTRO OPEN', 'BINGO THURS', 'MEAT RAFFLE 7PM', 'LEST WE FORGET'],
},
};
// Aliases: accept thriftgod / Overpass-style type names and map to canonical recipes.
const ALIAS = {
music: 'record', records: 'record',
charity: 'opshop', second_hand: 'opshop', secondhand: 'opshop', antiques: 'opshop', op_shop: 'opshop',
toys: 'toy', toyshop: 'toy',
books: 'book', bookshop: 'book', bookbarn: 'book',
video_games: 'video', games: 'video', vhs: 'video', rental: 'video',
pawnbroker: 'pawn', pawnshop: 'pawn',
milk_bar: 'milkbar', deli: 'milkbar', corner_store: 'milkbar',
department: 'dept', anchor: 'dept',
market: 'stall', stalls: 'stall',
};
export const SHOP_TYPES = Object.keys(RECIPES);
export function canonicalType(type) {
if (!type) return 'opshop';
const t = String(type).toLowerCase();
if (RECIPES[t]) return t;
if (ALIAS[t]) return ALIAS[t];
return 'opshop'; // sensible default: an op shop takes anything
}
export function getRecipe(type) {
const key = canonicalType(type);
return { key, ...RECIPES[key] };
}
// Lane F hook: let an authored registry override recipe fields without editing this lane.
// registry: { [typeKey]: partialRecipe }. Shallow-merges over the built-in recipe.
export function mergeRegistry(registry) {
if (!registry) return;
for (const [k, patch] of Object.entries(registry)) {
const key = canonicalType(k);
if (RECIPES[key]) Object.assign(RECIPES[key], patch);
}
}