diff --git a/C-progress.md b/C-progress.md index 9959103..a8eb114 100644 --- a/C-progress.md +++ b/C-progress.md @@ -3,7 +3,52 @@ *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-14 · owner: PROCITY-C · reviewer: Fable +Last updated: 2026-07-14 (b) · owner: PROCITY-C · reviewer: Fable + +--- + +## Update 2026-07-14 (b) — keeper stand pose (resolves Lane F §8 · unblocks §3.2/§3.3) + +Lane F's runbook asked (LANE_F_NOTES §8): *"does `places` tag the counter slot with a stable +`kind:'counter'` so keepers find it?"* **Yes** — the counter interactable has always carried +`mesh.userData.kind === 'counter'` (+ `interactable:true`). But Lane D's keeper needs more than a +tag: `keepers.spawn(group,{x,z,ry})` needs *where the shopkeeper stands and which way it faces* +(keepers.js:28 — "`ry` = the counter's outward facing"). I computed the customer-facing side +internally but never surfaced it — so Lane D would have read the bench's raw transform and stood a +keeper embedded in the counter, possibly facing a wall. Closed that gap (my files only, purely additive): + +- **New: `room.counter = { mesh, pose:{x,z,ry}, stand:{x,z,ry} }`** (interiors.js). `pose` is the + bench; **`stand` is the keeper pose** — behind the bench on the service side, facing the customer, + clamped inside the room, in **room-local space** (the same space Lane D adds the keeper into via + `interior.group`). `ry` uses the rig-front = local −Z convention keepers.js expects. +- **The same `{x,z,ry}` also rides the counter interactable**: `place.userData.keeperStand` — for the + `places`-iteration path Lane D/F described. Deep-equal to `room.counter.stand`. + +**Lane D/F wiring (drop-in for §3.2/§3.3):** +```js +const room = buildInterior(shop, THREE); +const s = room.counter.stand; // or: room.places.find(p=>p.userData.kind==='counter').userData.keeperStand +keepers.spawn(room.group, { ...s, shopId: shop.id, type: shop.type }); +// on exit: keepers.remove(handle) / keepers.disposeAll(), then room.dispose() +``` + +Verified in-browser (9 types × 4 seeds = 36 builds, + 50-room soak): +`stand in-bounds ✓ · faces-customer ✓ (dot>0 for all) · places-carries-keeperStand ✓ (deep-equal) · +stand deterministic ✓ · placement determinism unchanged ✓ · soak avg 3.6ms / worst 7.7ms · leak geo0 tex0 · +pathFail 0 · every room has a counter+stand`. No change to layout/placement of existing rooms. + +**Minor known cosmetic (corner counters):** a corner counter sits ~0.55 m off the east wall, so the +service gap (~0.25 m) is narrower than a person — the keeper stands *at* the till with a small hidden +overlap into the solid waist-high bench (torso reads above the counter, legs behind it). Facing and +side are correct; it reads as "keeper at the till." If Lane D/Fable want real standing clearance I can +nudge corner counters ~0.2 m off the wall — but that changes those rooms' committed placement + +screenshots, so it's a decision, not a bug. + +**Two contract-drift corrections for the F runbook** (§2's Lane-C line was written against an earlier +draft of my API): (1) there is **no `opts.registry`** — registry override is the one-time global +`mergeRegistry(registry)` (a per-build registry mutation broke same-seed determinism; removed in the +review pass). (2) `recipe.counterPos` is a **placement-strategy string** (`door|forward|back|corner`), +**not** a world position — use `room.counter.stand` for the keeper pose. --- @@ -41,6 +86,7 @@ room.dispose(); // frees ALL per-room GPU resources; removes group // spawn: { x, z, ry }, // enter just inside the door, facing −Z (into the shop) // exits: [ { x, z, w, toStreet } ], // door back to street // places: [ …meshes w/ userData ], // interactables: counter, bin, case, fridge, returns, exit +// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = keeper (Lane D) spawn pose // dims: { W, D, H, archetype, type }, // placement, // deterministic summary (deep-equal per seed) // pathOK, buildMs, counts(), dispose() @@ -120,6 +166,9 @@ Serve: `cd web && python3 -m http.server 8130` → open `/interior_test.html`. - **Lane E `web/assets/manifest.json`** — its `fittings` section (9 depot GLBs with footprints) is now wired through `glb.js` (opt-in `useGLB`, primitive fallback). Depot GLBs may not be uploaded yet; verified the fallback (unreachable depot → primitives persist, no crash, no leak). +- **Lane D `citizens/keepers.js`** — its `keepers.spawn(group,{x,z,ry,shopId,type})` now has a clean + source: `room.counter.stand` (also on `place.userData.keeperStand`). See the *Update (b)* section at + the top. Verified the pose is on the service side, faces the customer, and is deterministic. ## Adversarial review — 5 findings, all fixed diff --git a/web/interior_test.html b/web/interior_test.html index 8866ce0..cd52732 100644 --- a/web/interior_test.html +++ b/web/interior_test.html @@ -132,7 +132,8 @@ function updateHUD() { `room ${d.W.toFixed(1)}×${d.D.toFixed(1)}×${d.H.toFixed(1)} m\n` + `build ${current.buildMs.toFixed(1)} ms · path ${current.pathOK ? 'ok' : 'FAIL'}${current.carved ? ' (carved)' : ''}\n` + `geo ${c.geometries} · mat ${c.materials} · tex ${c.canvasTextures}\n` + - `places ${current.places.length} · counter ${current.recipe.counterPos}`; + `places ${current.places.length} · counter ${current.recipe.counterPos}` + + (current.counter ? `\nkeeper stand ${current.counter.stand.x.toFixed(2)}, ${current.counter.stand.z.toFixed(2)} · ry ${current.counter.stand.ry.toFixed(2)}` : ''); } // ── debug overlays ─────────────────────────────────────────────────────────── @@ -167,6 +168,19 @@ function buildPathHelper() { grp.add(m); }; mk(start, '#3ad66a'); mk(target, '#ffd75e'); + // keeper stand pose (Lane D spawns a shopkeeper here) — magenta post + a white nose showing the + // facing (local −Z = rig front, the convention keepers.js uses). + const ks = current.counter && current.counter.stand; + if (ks) { + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.13, 1.6, 12), + new THREE.MeshBasicMaterial({ color: '#d63ad6', transparent: true, opacity: 0.6 })); + post.position.set(ks.x, 0.8, ks.z); + const noseGrp = new THREE.Group(); + const nose = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.5), new THREE.MeshBasicMaterial({ color: '#ffffff' })); + nose.position.set(0, 0, -0.4); // local −Z = the way the keeper faces + noseGrp.add(nose); noseGrp.position.set(ks.x, 1.4, ks.z); noseGrp.rotation.y = ks.ry; + grp.add(post, noseGrp); + } pathHelper = grp; scene.add(grp); } function applyWire() { diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js index 5312206..8cbd0bd 100644 --- a/web/js/interiors/interiors.js +++ b/web/js/interiors/interiors.js @@ -15,6 +15,8 @@ // spawn: { x, z, ry }, // where/what-heading the player enters at (just inside the door) // exits: [ { x, z, w, toStreet } ], // door(s) back to the street // places: [ …meshes with userData ], // interactables: counter, bins, case, fridge, returns, exit +// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = where a shopkeeper (Lane D) +// // stands, in room-local space, facing the customer. // 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) @@ -97,6 +99,7 @@ export function buildInterior(shop, THREE, opts) { spawn: shell.spawn, exits: shell.exits, places: lay.places, + counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose 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 fb57f7f..22cdc9f 100644 --- a/web/js/interiors/layout.js +++ b/web/js/interiors/layout.js @@ -104,7 +104,24 @@ function placeCounter(ctx, recipe, W, D, r) { const f = buildFitting('counter', ctx, { w: cw }, r); f.group.position.set(x, 0, z); f.group.rotation.y = ry; const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry); - return { fitting: f, x, z, ry, hw, hd, front, priority: 9, kind: 'counter' }; + const stand = keeperStand(x, z, front, W, D); + return { fitting: f, x, z, ry, hw, hd, front, stand, priority: 9, kind: 'counter' }; +} + +// The pose a shopkeeper (Lane D) stands at: behind the bench on the service side (opposite the +// customer-facing `front`), facing the customer. Clamped inside the room so a corner/back-wall +// counter keeper never clips through a wall. `ry` uses the rig-front = local −Z convention that +// keepers.js expects (spawn's ry is "the counter's outward facing"). Deterministic — a pure +// function of the (already-seeded) counter pose. +function keeperStand(cx, cz, front, W, D) { + let dx = front.x - cx, dz = front.z - cz; // counter → customer + const len = Math.hypot(dx, dz) || 1; dx /= len; dz /= len; + const off = 0.5; // stand this far back from the bench centre + let x = cx - dx * off, z = cz - dz * off; // opposite the customer side (behind the bench) + const mx = W / 2 - 0.45, mz = D / 2 - 0.45; // keep clear of the walls + x = Math.max(-mx, Math.min(mx, x)); + z = Math.max(-mz, Math.min(mz, z)); + return { x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)) }; } // ── wall-slot system (shuffled pool → art + signs + wall fittings never overlap) ────── @@ -159,6 +176,10 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) { counter.rect = rectCells(grid, counter.x, counter.z, counter.hw, counter.hd); stampRect(grid, counter.rect); placed.push(counter); + // Surface the keeper stand pose on the counter's interactable so Lane D (keepers.js) can spawn a + // shopkeeper behind it without guessing which side is the service side. Also exposed top-level as + // room.counter.stand (see interiors.js). The counter is never path-pulled, so this always survives. + counter.fitting.group.userData.keeperStand = counter.stand; // 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); @@ -229,6 +250,11 @@ 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, + counter: { + mesh: counter.fitting.group, + pose: { x: round(counter.x), z: round(counter.z), ry: round(counter.ry) }, // the bench itself + stand: counter.stand, // where the keeper stands + }, }; }