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
+ },
};
}