diff --git a/B-progress.md b/B-progress.md index 0510c92..c200052 100644 --- a/B-progress.md +++ b/B-progress.md @@ -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. diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index 04c4a30..8564346 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -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 · · 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//` 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/(1−du)` 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 — diff --git a/web/js/world/hud.js b/web/js/world/hud.js index f9a0371..2f15452 100644 --- a/web/js/world/hud.js +++ b/web/js/world/hud.js @@ -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 — 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 = `day · $ + + `; + 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(); } diff --git a/web/js/world/venue.js b/web/js/world/venue.js index 4ea37ce..5ee9096 100644 --- a/web/js/world/venue.js +++ b/web/js/world/venue.js @@ -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; },