Lane B R30 (v7.0-alpha ledger #4): THE COLLECTION UI + SLEEP surface — the crate panel in hud.js (cover thumb = pack-atlas CSS crop off the sku, per-shop/town-wide bases, parody = glyph by design; where+day honest, foreign towns fall back to townKey), game bar day/$/CRATE count + SLEEP on F's §30.1 contract; construction-gated on PROCITY.game itself (classic/game=0 ⇒ zero DOM/listeners, measured); venue.js compat venueShopId = live weekNight getter (F's §30.4 filing, 116→490 across a sleep). Verified vs F's REAL committed core (nothing stubbed) on no-store :8151 snapshot; qa 6/6

This commit is contained in:
m3ultra 2026-07-18 14:37:15 +10:00
parent f419c1a5e2
commit cb03f07fb2
4 changed files with 229 additions and 1 deletions

View File

@ -4,6 +4,25 @@
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
`generatePlan` (which landed mid-session). Everything below was verified live in a browser.
## Round 30 (v7.0-alpha ledger #4) — THE COLLECTION UI + SLEEP surface · qa GREEN 6/6
The crate panel + game bar in `hud.js` (my DOM-overlay patterns), on F's §30.1 `PROCITY.game`
contract — and verified against F's REAL committed core (`207bcff` landed mid-round; my test
snapshot diffed byte-identical to it — **nothing stubbed**, unlike C's wave-1 run).
- **Game bar** (bottom-right): `day N · $cash` · `CRATE n` (count in the standing HUD; opens the
panel, `C` toggles) · `☾ SLEEP``game.sleep()`, enabled any time (alpha).
- **Crate panel**: cover thumb (pack-atlas CSS crop off the entry's sku — per-shop godverse base or
town-wide; parody = type glyph by design), title/artist, `paid $p · where · day d`, newest first,
header totals. 50-row rebuild 0.9 ms; 0 draws (DOM-only).
- **Construction-gated on the game object itself** (lazy, R19-selector style): `?classic=1`/`?game=0`
⇒ zero game DOM, zero listeners, help line byte-identical, 0 console errors (measured, clean tabs).
`?noassets` ⇒ 0 stock fetches, placeholders stand.
- **SLEEP proven end-to-end**: day 1→2, weekNight 0→1, wake DAWN seg 0, `procity-save/1` on disk,
next boot loads it and the UI reads it. **venue.js §30.4 filing fixed as filed** — compat
`venueShopId` is now a live weekNight-keyed getter (116 → 490 across a sleep, measured).
- Full measurement table + honest notes: LANE_B_NOTES §30. Verified on a port-isolated no-store
:8151 snapshot server (not :8130/:8144 — Lane F was mid-flight in the shared tree).
## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + tram (`?tram=1`) · qa GREEN
Ship-v2.0 round. Two deliverables, both v2-law clean, `qa.sh --strict` GREEN, flags-off byte-identical.

View File

@ -1,5 +1,64 @@
# LANE B — NOTES (measured)
## Round 30 (v7.0-alpha ledger #4) — THE COLLECTION UI + the SLEEP surface: **shipped, verified against F's REAL core (nothing stubbed)**
**The crate is on screen: cover thumb · title/artist · price paid · where + day found; count, day and
cash in the standing HUD; SLEEP saves and wakes tomorrow. All of it built on `PROCITY.game` exactly as
§30.1 publishes it — and by verify time none of it was a stub: F committed the save core (`207bcff`)
mid-round, and every game-layer file my test server carried is byte-identical to that HEAD (diffed,
not assumed). Lane C stubbed because F hadn't landed; B landed after F did — the wave order worked.**
### What ships (`web/js/world/hud.js` — my file, my DOM-overlay patterns; +8 lines in `venue.js`, F's §30.4 filing)
- **The game bar** (bottom-right): `day N · $cash` readout + `CRATE n` (the standing-HUD count, opens
the panel; `C` toggles it on the street) + `☾ SLEEP``game.sleep()` — enabled any time (alpha;
home-base gating is beta). The shell's `onDay` hook owns the wake toast; the bar just re-reads the
contract getters.
- **The crate panel**: newest find first; each row = cover thumb, title/artist (C's §30.1 fallback
chain title → sku → slotId), `paid $p · <where> · day d`. Header totals finds + $ spent. *Where* =
the shop's name off the plan when the entry's `townKey` is this boot's, else the town part of its
townKey (`redhill godverse`) — honest, never guessed.
- **Cover thumbs = the pack atlas, cropped in CSS.** The entry's `sku` resolves against its pack index
(per-shop `assets/stock_godverse/<godverseShopId>/` when set, else the town-wide `assets/models/`),
and the row's 44 px thumb is `background-image` + `background-size: 100/du% × 100/dv%` +
`background-position: u0/(1du)` off the item's `uv` rect (origin top-left = CSS's origin, no flip —
the WebGL V-flip in stockpack.js is exactly the transform NOT to apply here). Zero canvas, zero
texture decode beyond the browser's own; B keeps its own tiny index-only fetch cache (`base|type`,
read-only GETs — can't fight stockpack's texture cache). Parody finds (slotId, no sku) wear a type
glyph by design (§30.4); so does any missing pack / unknown sku / remote (`depot:`/http) atlas.
- **Construction-gated like the R19 selector, but on the game itself:** the HUD builds NOTHING at
`createHUD` — a lazy check (every 30th frame) builds the surface on first sight of a truthy
`window.PROCITY.game` (the shell constructs the game after the HUD). `?classic=1` / `?game=0`
`game` is null forever ⇒ **none of this DOM, the KeyC listener, or the help-line suffix ever
exists** — the gate is the game object, not a re-derivation of the flags.
- **`venue.js` — F's §30.4 filing, fixed as filed:** the compat `venueShopId` ("tonight's venue") was
frozen at night-0's gig; now a live getter keyed to `PROCITY.gigs.weekNight` (0 pre-game/day-1 ⇒
exact pre-v7 value). No consumer of the field exists in-tree today (grepped), so the getter is pure
forward-correctness for whoever reads "tonight" next.
### Measured (fresh contexts, port-isolated no-store server :8151 — NOT :8130/:8144 — serving a code
### snapshot + assets symlink, so F's mid-flight edits couldn't shift under the test; snapshot later
### diffed clean against F's landed commits)
| check | result |
|---|---|
| `?classic=1` / `?game=0` (clean tab) | `game: null` · game DOM **0/5 ids** · help line byte-identical · **0 console errors** |
| default boot | bar lazily built, `day 1 · $191` (wallet-backed start), CRATE 0 |
| 3 finds via the REAL `game.recordFind` (godverse sku · town-wide sku · parody slotId) | CRATE 3 · head `3 finds · $92 spent` · thumbs from **both** pack bases (`stock_godverse/158/`, `models/`) — real cover art on screen (screenshotted), parody = ♪ · exactly 4 fetches (2 index + 2 atlas) |
| foreign-town entry via the REAL `game.import()` | row reads `paid $25 · redhill godverse · day 1` (townKey fallback) · missing sku ⇒ placeholder, 0 errors |
| sell seam (`game.removeFind`) | 4 → 3: bar + open panel both follow within one poll (≤6 frames) |
| **SLEEP** (button) | day 1→2 · weekNight 0→1 · wake DAWN 06:30 seg 0 · `procity-save/1` on disk with day 2 + 3 entries · shell toast `🌅 day 2` · cash untouched · **`venueShopId` re-keyed 116 → 490** (the §30.4 fix, proven behaviourally) |
| next boot (same profile) | the saved game LOADS and the bar/panel read it — persistence proven from the UI side too |
| `?noassets` + a real-sku find | **0** stock/atlas fetches (asset law) · placeholder stands · 0 errors |
| cost | 50-row full rebuild **0.9 ms** · first 3-row open 2.1 ms · DOM-only = 0 draws (C proved the class; unchanged) |
**Honest notes:** (1) the four `THREE.PointerLockControls` console errors seen mid-drive are the
automation browser refusing the Pointer Lock API on my splash click — reproduced click-free at zero,
absent on every clean-tab boot; environment, not code. (2) F's splash ("walk into town") was new in
the tree mid-round and sat over my first screenshot — noted, F's surface, untouched. (3) Beta polish,
not done: foreign-town rows could carry real display names via E's towns index (the selector already
fetches it) — one map at build time, when the money-shot pass comes.
---
## Round 28 (v5.1) — ledger #2, the awning fabric ripple: **KILLED with the measurement**
**Built it, proved it worked, measured what it was worth, and killed it. A 10×-over-driven billow —

View File

@ -27,6 +27,20 @@ const CSS = `
#pc-townpick:hover{opacity:1}
#pc-townpick select{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:5px 9px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;max-width:230px;cursor:pointer}
#pc-townpick select:focus{outline:1px solid rgba(255,215,94,.6)}
#pc-game{position:absolute;right:10px;bottom:12px;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
#pc-gamebar{background:rgba(20,16,10,.72);padding:6px 10px;border-radius:10px;display:flex;gap:10px;align-items:center;pointer-events:auto;text-shadow:0 1px 2px #000}
#pc-gamebar b{color:#ffd75e;font-weight:600}
#pc-gamebar button{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:4px 10px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer}
#pc-gamebar button:hover{border-color:rgba(255,215,94,.7);color:#ffd75e}
#pc-crate{width:344px;max-height:min(58vh,470px);overflow-y:auto;background:rgba(20,16,10,.93);border:1px solid rgba(255,215,94,.35);border-radius:12px;padding:10px 12px;pointer-events:auto;display:none}
#pc-crate h3{font-size:13px;color:#ffd75e;margin:0 0 6px;font-weight:600;letter-spacing:.04em}
.pc-crate-row{display:flex;gap:9px;align-items:center;padding:5px 0;border-top:1px solid rgba(255,244,230,.08)}
.pc-thumb{width:44px;height:44px;flex:none;border-radius:4px;background-color:#3a3128;background-repeat:no-repeat;display:flex;align-items:center;justify-content:center;color:rgba(255,244,230,.55);font-size:17px;box-shadow:0 1px 3px rgba(0,0,0,.5)}
.pc-crate-main{min-width:0}
.pc-crate-title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pc-crate-artist{opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pc-crate-meta{font-size:11px;opacity:.62}
.pc-crate-empty{opacity:.7;padding:4px 0 2px}
`;
export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }) {
@ -98,6 +112,126 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
.catch(() => {}); // index missing/blocked → fallback stands, zero errors
})();
// ── v7 THE GAME (R30, ledger #4): the collection panel + the SLEEP surface + day/cash readout.
// Built LAZILY on the first update() frame that sees a truthy `window.PROCITY.game` — the shell
// constructs the game layer AFTER the HUD, and under ?classic=1 / ?game=0 it is null forever, so
// NONE of this DOM (bar, panel, KeyC listener, help-line suffix) ever exists on those boots: the
// R19 selector's construction-gating, applied to the game (the classic-pure law, ledger #5's gate
// measures it). Consumes ONLY the LANE_F_NOTES §30.1 contract: day / cash / collection (read-only
// — this UI never mutates it) / sleep(). Cover thumbs resolve the entry's sku against its pack
// index (per-shop godverse base when godverseShopId is set, else the town-wide base) — fail-soft:
// missing pack / unknown sku / remote atlas / ?noassets ⇒ the type-glyph placeholder stands,
// zero errors (the R21 towns-index discipline). Parody finds (slotId, no sku) have no thumb by
// design (§30.4). ──
let gameUI = null; // built once; null forever when the game layer is absent
let onGameKey = null; // KeyC crate toggle — registered only when the game UI exists
const shopById = new Map((plan.shops || []).map((s) => [s.id, s]));
const GAME_NOASSETS = (() => { try { const v = new URLSearchParams(location.search).get('noassets'); return v != null && v !== '0'; } catch { return false; } })();
const TYPE_GLYPH = { record: '♪', book: '¶', toy: '✦' };
const _packIdx = new Map(); // `${base}|${type}` → Promise<index.json|null> — B's own read-only cache
function packIndexFor(entry) {
if (GAME_NOASSETS || !entry || !entry.sku || !entry.type) return Promise.resolve(null);
const base = entry.godverseShopId != null ? `assets/stock_godverse/${entry.godverseShopId}/` : 'assets/models/';
const key = base + '|' + entry.type;
if (!_packIdx.has(key))
_packIdx.set(key, fetch(`${base}stock_${entry.type}_index.json`).then((r) => (r.ok ? r.json() : null)).catch(() => null));
return _packIdx.get(key).then((idx) => (idx && Array.isArray(idx.items) ? { idx, base } : null));
}
function applyThumb(el, entry) {
packIndexFor(entry).then((p) => {
if (!p || !el.isConnected) return;
const it = p.idx.items.find((i) => i && String(i.id) === entry.sku);
if (!it || !it.atlas || !Array.isArray(it.uv) || /^depot:|^https?:/.test(it.atlas)) return;
const [u0, v0, u1, v1] = it.uv, du = u1 - u0, dv = v1 - v0;
if (!(du > 0) || !(dv > 0)) return;
el.textContent = ''; // atlas crop via CSS background — uv origin is top-left (image-natural), same as CSS
el.style.backgroundImage = `url("${p.base + it.atlas}")`;
el.style.backgroundSize = `${100 / du}% ${100 / dv}%`;
el.style.backgroundPosition = `${du < 1 ? (u0 / (1 - du)) * 100 : 0}% ${dv < 1 ? (v0 / (1 - dv)) * 100 : 0}%`;
});
}
// where the find happened: this boot's town → the shop's name off the plan; another town's find →
// the town part of its townKey (`plansrc/town@seed`) — honest, never guessed.
function foundAt(entry, game) {
if (entry.townKey === game.townKey) { const s = shopById.get(entry.shopId); if (s && s.name) return s.name; }
const m = /^[^/]+\/([^@]+)@/.exec(entry.townKey || '');
return m ? m[1].replace(/_/g, ' ') : '?';
}
function buildGameUI(game) {
const wrap = document.createElement('div'); wrap.id = 'pc-game';
const panel = document.createElement('div'); panel.id = 'pc-crate';
const bar = document.createElement('div'); bar.id = 'pc-gamebar';
bar.innerHTML = `<span>day <b id="pc-day">—</b> · $<b id="pc-cash">—</b></span>
<button id="pc-crate-btn" title="The collection (C toggles; Esc frees the pointer first)">CRATE 0</button>
<button id="pc-sleep-btn" title="Sleep — save, wake tomorrow"> SLEEP</button>`;
wrap.append(panel, bar); root.appendChild(wrap);
const elDay = bar.querySelector('#pc-day'), elCash = bar.querySelector('#pc-cash'),
btnCrate = bar.querySelector('#pc-crate-btn'), btnSleep = bar.querySelector('#pc-sleep-btn');
let shownDay = null, shownCash = null, shownCount = null, open = false;
function rebuildList() {
const col = game.collection || [];
panel.textContent = '';
const h = document.createElement('h3');
const spent = col.reduce((a, e) => a + (e.pricePaid || 0), 0);
h.textContent = `THE CRATE — ${col.length} find${col.length === 1 ? '' : 's'} · $${spent} spent`;
panel.appendChild(h);
if (!col.length) {
const d = document.createElement('div'); d.className = 'pc-crate-empty';
d.textContent = 'Empty — go dig.'; panel.appendChild(d); return;
}
for (let i = col.length - 1; i >= 0; i--) { // newest find first
const e = col[i];
const row = document.createElement('div'); row.className = 'pc-crate-row';
const th = document.createElement('div'); th.className = 'pc-thumb';
th.textContent = TYPE_GLYPH[e.type] || '♦';
applyThumb(th, e);
const main = document.createElement('div'); main.className = 'pc-crate-main';
const t = document.createElement('div'); t.className = 'pc-crate-title';
t.textContent = e.title || e.sku || e.slotId || 'unknown'; // C's fallback rule (§30.1)
const a = document.createElement('div'); a.className = 'pc-crate-artist';
a.textContent = e.artist || '';
const meta = document.createElement('div'); meta.className = 'pc-crate-meta';
meta.textContent = `paid $${e.pricePaid} · ${foundAt(e, game)} · day ${e.dayFound}`;
main.append(t, a, meta);
row.append(th, main); panel.appendChild(row);
}
}
function setOpen(v) {
open = v; panel.style.display = v ? 'block' : 'none';
if (v) { shownCount = (game.collection || []).length; rebuildList(); }
}
btnCrate.onclick = () => setOpen(!open);
// SLEEP — enabled any time (alpha; home-base gating is beta). The shell's onDay hook owns the
// wake toast; the HUD just refreshes its readouts off the contract getters.
btnSleep.onclick = () => {
try { game.sleep(); } catch (err) { console.warn('[hud] sleep failed:', err); }
refresh();
};
function refresh() {
const col = game.collection || [];
if (game.day !== shownDay) { shownDay = game.day; elDay.textContent = shownDay; }
if (game.cash !== shownCash) { shownCash = game.cash; elCash.textContent = shownCash; }
if (col.length !== shownCount) {
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
if (open) rebuildList();
}
}
refresh();
onGameKey = (e) => { // C toggles the crate on the street (not inside a shop — E/dig territory)
if (e.code !== 'KeyC') return;
const P = window.PROCITY;
if (P && P.mode === 'interior') return;
setOpen(!open);
};
addEventListener('keydown', onGameKey);
const help = root.querySelector('#pc-help');
if (help) help.textContent += ' · C crate';
return { refresh };
}
const ray = new THREE.Raycaster();
ray.far = 6;
const centre = new THREE.Vector2(0, 0);
@ -136,6 +270,15 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
frame++;
if (frame % 6 === 0) raycastDoor();
// v7 game surface — lazy construction (the shell builds the game after the HUD; null under
// ?classic=1 / ?game=0 ⇒ this never fires), then cheap getter polls offset from the raycast frames.
if (!gameUI) {
if (frame % 30 === 0) {
const g = window.PROCITY && window.PROCITY.game;
if (g) gameUI = buildGameUI(g);
}
} else if (frame % 6 === 3) gameUI.refresh();
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
if (hovered) {
const h = hovered.hours;
@ -174,6 +317,7 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
function dispose() {
renderer.domElement.removeEventListener('click', onClick);
if (onGameKey) removeEventListener('keydown', onGameKey);
root.remove();
}

View File

@ -259,7 +259,13 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
return {
group, venues, update, dispose,
venueShopId: primaryId || (venues[0] && venues[0].venueShopId) || null, // alpha compat
// alpha compat — [Lane B R30, F's §30.4 filing] "tonight" now tracks the game day: a live getter
// keyed to gigs' night = weekNight (0 pre-game and on day 1 ⇒ exact pre-v7 value, gigs[0]'s venue).
get venueShopId() {
const n = (typeof window !== 'undefined' && window.PROCITY?.gigs?.weekNight) || 0;
const g = gigs.find((x) => x.night === n) || (primaryId != null ? gigs[0] : null);
return (g && g.venueShopId) || (venues[0] && venues[0].venueShopId) || null;
},
venueShopIds: venues.map((v) => v.venueShopId),
queueZones,
queueZoneFor(id) { const v = venues.find((x) => x.venueShopId === id); return v ? v.queueZone : null; },