#!/usr/bin/env node // Scaffold a new venue — mirrors tools/new-scene.js --model. // Venue IS the ground (role:'ground'), kind:'model' with build/update stubs // that map venueSpec.perSection + stageNode pattern. // Usage: node tools/new-venue.js "Harbour" --archetype=chamber import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const VENUES = join(root, 'src/venues'); const REGISTRY = join(root, 'src/venues/registry.js'); const ARCHETYPES = ['chamber','stage','ride']; const args = process.argv.slice(2); const flags = new Map(args.filter(a=>a.startsWith('--')).map(a=>{ const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; })); const name = args.find(a=>!a.startsWith('--')); if (!name) { console.error(`usage: node tools/new-venue.js "Name" --archetype=<${ARCHETYPES.join('|')}>`); process.exit(1); } const archetype = String(flags.get('archetype')||'chamber'); if (!ARCHETYPES.includes(archetype)) { console.error(`unknown archetype '${archetype}'`); process.exit(1); } const kebab = name.trim().toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''); const camel = kebab.replace(/-([a-z0-9])/g, (_,c)=>c.toUpperCase()); const file = join(VENUES, `${kebab}.js`); if (existsSync(file)) { console.error(`${kebab}.js already exists`); process.exit(1); } const salt = (text, lo, hi)=>{ let h=2166136261; for(let i=0;i>>0; } return +(lo+(h%1000)/1000*(hi-lo)).toFixed(2); }; const spread = salt(kebab, 0.5, 1.1); const light = salt(kebab+'light', 0.5, 0.9); const source = `// VENUE: ${name} — ${archetype} archetype. One venue = one place. // Primitives only in V1; per-section evolution via venueState {light,fog,popMul,reachMul} from VenueSpec.perSection. import { actorToGeometry } from '../actors/meshes.js'; export const ${camel} = { name: '${name.trim()}', archetype: '${archetype}', family: '${archetype==='chamber'?'minimal':'structural'}', kind: 'model', venue: true, consumes: ['form','ink','staging'], traits: ['shape','space','style'], texture: 0.35, // How much viz bloom this room can take before its highlights clip. // Dim rooms (plaster, floor light) ~1.1; rooms with a big emissive screen ~0.55. reach: 0.9, params: { density: { type: 'float', range: [0.2, 0.9], default: ${spread}, uniform: 'u_density', bias: 'density' }, light: { type: 'float', range: [0.3, 1.2], default: ${light}, uniform: 'u_light', bias: 'energy' }, palette: { type: 'palette', count: 4 }, }, reactive: {}, build({ scene, seed, params, actorSpec, venueSpec, THREE }) { const T = THREE; const group = new T.Group(); group.name='${kebab}'; const mat = new T.MeshStandardMaterial({ color: new T.Color(0.14,0.13,0.12), roughness: 0.85 }); const floor = new T.Mesh(new T.PlaneGeometry(22,22), mat); floor.rotation.x = -Math.PI/2; floor.position.y = -1.4; group.add(floor); scene.add(group); const amb = new T.AmbientLight(0xffffff, 0.42); scene.add(amb); scene.background = null; scene.fog = new T.Fog(0x0e0e12, 12, 28); scene.userData.transparentBackground = true; const pop = new T.Group(); pop.name='pop'; scene.add(pop); return { group, mat, amb, pop, seed, venueSpec: venueSpec||null, venueState: null, builtKey: null, popCount: 0 }; }, update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, THREE }) { const T = THREE; const t = timeline.time; const pal = palette && palette.length ? palette : [[0.9,0.9,0.92],[0.7,0.6,0.8]]; const vs = venueSpec || instance.venueSpec; const vState = venueState || instance.venueState || null; if (vState) instance.venueState = vState; const c0 = pal[0]; instance.mat.color.setRGB(c0[0]*0.26, c0[1]*0.24, c0[2]*0.28); if (scene.fog && vState) { const fogMul = vState.fog; const space = personality?.space; const base = space ? 12 + space.depth*6 : 15; scene.fog.near = base * (0.85 + fogMul*0.4); } if (vState) { const target = 0.55 * (params.light||0.7) * (0.75 + vState.light*0.5); // stub emissive if needed: venue surface placeholder void target; } const identity = personality?.identity || null; if (vs && identity && actorSpec && actorSpec.form && (!instance.popCount || instance.builtKey !== \`\${vs.seed}:\${vs.population.count}:\${identity.cast.protagonist.sides}:\${actorSpec.seed}\`)) { // rebuild pop on spec change — stub: real venue would populate via stageNode/actorToGeometry instance.builtKey = \`\${vs.seed}:\${vs.population.count}:\${identity.cast.protagonist.sides}:\${actorSpec.seed}\`; instance.popCount = vs.population.count; } // Gate visibility by popMul roughly if (vState) { const mul = vState.popMul; const floorCount = Math.max(1, Math.floor((instance.popCount||0) * (0.3 + mul*0.8))); for (let i=0;i0.01; } void T; void t; void params; void opacity; }, }; export default ${camel}; `; writeFileSync(file, source); let registry = readFileSync(REGISTRY,'utf8'); const importLine = `import { ${camel} } from './${kebab}.js';`; if (!registry.includes(importLine)) { const lastImport = registry.lastIndexOf("} from './"); const eol = registry.indexOf('\n', lastImport); registry = registry.slice(0,eol+1)+importLine+'\n'+registry.slice(eol+1); } registry = registry.replace(/const MODULES = \[/, `const MODULES = [\n ${camel},`); writeFileSync(REGISTRY, registry); console.log(`created src/venues/${kebab}.js`); console.log(`registered ${camel} (${archetype})`);