// PROCITY Lane F — playtest.js [R40 §40.6 — THE PLAYTEST HARNESS] // // The thing that turns John's bug-hunt playthrough into tickets. Two halves, both zero-draw DOM: // // F8 — DROP A NOTE, anywhere. Captures the whole "where was I and what was the game doing" // context ({pos, mode, shop, street/locality via Lane A's address layer, seed, town, clock // segment, flags, fps, draws, tris}), fires a screenshot download of the CURRENT view (street // composer frame, or the interior/dig frame via interiorMode.renderFrame — the takeShots/DBG.shot // render-then-toDataURL idiom), then opens a small overlay input for the one-liner. Enter saves, // Esc cancels. Notes persist under THE DELTA LAW (the fog's pattern, save.js §39): bounded at // write AND validated at read, corrupt blobs REJECTED WHOLE, export/import round-trips as one // JSON the lanes consume as tickets (schema: LANE_F_NOTES §40). // // ?bugtour=1 — THE GUIDED WALK. Strings the existing DBG bookmarks into a stop list covering // every system (spawn → street day → shop/buy → dig → op-shop bin (unarmed) → gig night → tram → // rain → night → fog map → second town); N advances, an on-screen caption says what to look at. // The tour drives window.DBG (the shell loads dbg.js under ?bugtour=1 too), so the poses are the // SAME bookmarks every QA shot uses — no second pose scheme to drift. // // CLASSIC-GATED BY CONSTRUCTION (the null-provider house idiom, ground.js/minimap): the shell // dynamic-imports this module only on a non-classic boot with ?playtest!=0, so ?classic=1 never // fetches the file, never grows the DOM, never touches storage — byte-identical, asserted by // tools/qa/r40_playtest.py. Zero cost when unused even ON: no rAF, no per-frame work, no draws — // two key listeners and a lazily-built overlay. import { createStreetLocator } from './discovery.js'; export const NOTE_SCHEMA = 'procity-playtest/1'; export const NOTES_KEY = 'procity-playtest'; export const NOTES_CAP = 200; // bounded (delta law): FIFO at write, hard cap at read export const TEXT_CAP = 240; // the one-liner is a one-liner const MODES = new Set(['street', 'interior', 'dig', 'map']); // Corrupt-reject-whole, the save.js validateSave pattern: schema must match exactly, every field // typed, every bound honoured — a foreign/hand-edited blob is refused entire, never merged. export function validateNotes(obj) { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { ok: false, why: 'not an object' }; if (obj.schema !== NOTE_SCHEMA) return { ok: false, why: `schema ${JSON.stringify(obj.schema)} != ${NOTE_SCHEMA}` }; if (!Array.isArray(obj.notes)) return { ok: false, why: 'notes is not an array' }; if (obj.notes.length > NOTES_CAP) return { ok: false, why: `${obj.notes.length} notes (cap ${NOTES_CAP})` }; const num = (v) => typeof v === 'number' && Number.isFinite(v); const strOrNull = (v) => v === null || typeof v === 'string'; for (let i = 0; i < obj.notes.length; i++) { const n = obj.notes[i], at = `notes[${i}]`; if (!n || typeof n !== 'object') return { ok: false, why: `${at} not an object` }; if (typeof n.id !== 'string' || !n.id) return { ok: false, why: `${at}.id` }; if (!num(n.t)) return { ok: false, why: `${at}.t` }; if (typeof n.text !== 'string' || n.text.length > TEXT_CAP) return { ok: false, why: `${at}.text` }; if (!strOrNull(n.shot)) return { ok: false, why: `${at}.shot` }; if (!n.pos || !num(n.pos.x) || !num(n.pos.y) || !num(n.pos.z)) return { ok: false, why: `${at}.pos` }; if (!MODES.has(n.mode)) return { ok: false, why: `${at}.mode ${JSON.stringify(n.mode)}` }; if (!(n.shopId === null || num(n.shopId) || typeof n.shopId === 'string')) return { ok: false, why: `${at}.shopId` }; if (!strOrNull(n.shopName)) return { ok: false, why: `${at}.shopName` }; if (!strOrNull(n.street) || !strOrNull(n.locality)) return { ok: false, why: `${at}.street/locality` }; if (!num(n.citySeed)) return { ok: false, why: `${at}.citySeed` }; if (!strOrNull(n.town)) return { ok: false, why: `${at}.town` }; if (!Number.isInteger(n.seg) || n.seg < 0 || n.seg > 5) return { ok: false, why: `${at}.seg` }; if (typeof n.clock !== 'string') return { ok: false, why: `${at}.clock` }; if (!n.flags || typeof n.flags !== 'object' || Array.isArray(n.flags)) return { ok: false, why: `${at}.flags` }; for (const k of Object.keys(n.flags)) if (typeof n.flags[k] !== 'boolean') return { ok: false, why: `${at}.flags.${k}` }; if (!(n.fps === null || num(n.fps)) || !num(n.draws) || !num(n.tris)) return { ok: false, why: `${at}.fps/draws/tris` }; } return { ok: true, notes: obj.notes }; } // createPlaytest(deps) → the harness. Constructed ONLY by a non-classic shell (null-provider law). // deps: { plan, player, camera, canvas, renderer, composer, hud, lighting, chunks, getMode, // setMode, interiorMode, addresses, citySeed, town, spawn, flags, storage? } export function createPlaytest(deps) { const { plan, player, canvas, renderer, composer, hud, lighting, getMode, setMode, interiorMode, addresses = null, citySeed, town = null, spawn = null, flags = {} } = deps; const storage = deps.storage !== undefined ? deps.storage : (() => { try { return window.localStorage; } catch { return null; } // private-mode → memory-only, still works })(); // its own locator, built once from the plan (same spatial hash the fog signage uses — µs lookups, // zero draws). Deliberately not shared with B's HUD locator: no cross-lane coupling for a QA tool. const locator = createStreetLocator(plan); // ── the store (delta law) ──────────────────────────────────────────────────────────────────── // LAZY: nothing is read until the harness is actually USED (first F8 / export / count). The R30c // purity gate instruments Storage from before any page script and holds ?game=0 at ZERO calls — // an eager getItem here turned that gate red (measured, this round). A boot that never touches // F8 therefore makes zero storage calls; the first deliberate use pays the one read. let notes = null; const load = () => { if (notes) return notes; notes = []; if (storage) { try { const raw = storage.getItem(NOTES_KEY); if (raw) { const v = validateNotes(JSON.parse(raw)); if (v.ok) notes = v.notes; else console.error(`[procity playtest] stored notes REJECTED (${v.why}) — starting empty`); } } catch (e) { console.error('[procity playtest] stored notes REJECTED (not JSON) — starting empty'); } } return notes; }; const persist = () => { load(); if (notes.length > NOTES_CAP) notes.splice(0, notes.length - NOTES_CAP); // FIFO, like PULLS_CAP if (!storage) return; try { storage.setItem(NOTES_KEY, JSON.stringify({ schema: NOTE_SCHEMA, notes })); } catch {} }; // ── capture ────────────────────────────────────────────────────────────────────────────────── const modeNow = () => (interiorMode && interiorMode.digActive) ? 'dig' : getMode(); function snapshot() { const p = player.position; const mode = modeNow(); const inShop = (mode === 'interior' || mode === 'dig') && interiorMode ? interiorMode.shop : null; const r2 = (v) => Math.round(v * 100) / 100; // street/locality via Lane A's layer (LANE_A_NOTES §39: print label, never branch on town type; // null is handled once, on both town types). addresses may be null (fixture boot) — fields null. let street = null, locality = null; if (addresses) { try { const e = locator.edgeAt(p.x, p.z); if (e != null && typeof addresses.streetOf === 'function') street = addresses.streetOf(e); const sid = inShop ? inShop.id : locator.shopNear(p.x, p.z); if (sid != null && typeof addresses.localityOf === 'function') { const l = addresses.localityOf(sid); if (l) locality = l.label || null; } } catch {} } const clk = lighting.getClock(); const ri = renderer.info.render; // the last completed frame (autoReset off; each mode resets per frame) return { id: Date.now().toString(36) + '-' + (load().length + 1), t: Date.now(), text: '', shot: null, pos: { x: r2(p.x), y: r2(p.y), z: r2(p.z) }, mode, shopId: inShop ? inShop.id : null, shopName: inShop ? inShop.name : null, street, locality, citySeed: citySeed >>> 0, town, seg: clk.seg | 0, clock: String(clk.hour), flags: Object.fromEntries(Object.entries(flags).filter(([, v]) => typeof v === 'boolean')), fps: hud && hud.getFps ? hud.getFps() : null, draws: ri.calls, tris: ri.triangles, }; } // the DBG.shot / takeShots idiom: render THIS mode's frame, then read the canvas (the drawing // buffer is not preserved, so a render must immediately precede toDataURL). function screenshot(name) { try { const m = modeNow(); if ((m === 'interior' || m === 'dig') && interiorMode && interiorMode.renderFrame) interiorMode.renderFrame(); else { renderer.info.reset(); composer.render(); } // street (and map — a fresh street frame) const a = document.createElement('a'); a.download = name; a.href = canvas.toDataURL('image/png'); a.click(); return true; } catch (e) { console.warn('[procity playtest] screenshot failed:', e && e.message || e); return false; } } // ── the overlay (lazy DOM, zero draws) ─────────────────────────────────────────────────────── let box = null, input = null, ctxLine = null, exportBtn = null, pending = null; function buildOverlay() { box = document.createElement('div'); box.id = 'pc-note'; box.style.cssText = 'position:fixed;left:50%;bottom:18%;transform:translateX(-50%);z-index:60;' + 'display:none;flex-direction:column;gap:6px;min-width:380px;max-width:560px;padding:10px 12px;' + 'background:rgba(16,14,8,.92);border:1px solid rgba(255,215,94,.5);border-radius:10px;' + 'font:12px -apple-system,Segoe UI,Roboto,sans-serif;color:#f4efe6'; const head = document.createElement('div'); head.style.cssText = 'display:flex;justify-content:space-between;align-items:center;gap:10px'; const title = document.createElement('b'); title.textContent = '🐛 BUG NOTE — Enter saves · Esc cancels'; title.style.color = '#ffd75e'; exportBtn = document.createElement('button'); exportBtn.id = 'pc-note-export'; exportBtn.style.cssText = 'cursor:pointer;border:1px solid rgba(255,215,94,.4);border-radius:6px;' + 'background:rgba(255,215,94,.12);color:#ffd75e;font:11px inherit;padding:3px 8px'; exportBtn.addEventListener('click', (e) => { e.stopPropagation(); api.exportDownload(); }); head.append(title, exportBtn); ctxLine = document.createElement('div'); ctxLine.style.cssText = 'opacity:.75;font-size:11px'; input = document.createElement('input'); input.id = 'pc-note-text'; input.type = 'text'; input.maxLength = TEXT_CAP; input.placeholder = 'what happened here?'; input.autocomplete = 'off'; input.style.cssText = 'background:rgba(0,0,0,.5);border:1px solid rgba(255,255,255,.25);' + 'border-radius:6px;color:#fff;font:13px inherit;padding:7px 9px;outline:none'; // the game must not hear the typing: stop keydown/keyup at the input so the shell's window // listeners (WASD keys set, [ ] segment stepping, M map…) never see it. input.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') saveNote(); else if (e.key === 'Escape') cancelNote(); }); input.addEventListener('keyup', (e) => e.stopPropagation()); box.append(head, ctxLine, input); document.body.appendChild(box); } function openNote() { if (pending) { input && input.focus(); return; } // already mid-note — refocus, don't double-capture pending = snapshot(); const shotName = `procity-note-${pending.id}.png`; pending.shot = screenshot(shotName) ? shotName : null; // shot BEFORE the overlay (DOM never reaches the canvas anyway) if (!box) buildOverlay(); const whereBits = [pending.shopName, pending.street || pending.locality, pending.town || 'synthetic', `${pending.mode} · seg ${pending.seg} ${pending.clock} · ${pending.draws} draws`].filter(Boolean); ctxLine.textContent = whereBits.join(' · '); exportBtn.textContent = `export all (${load().length + 1})`; input.value = ''; box.style.display = 'flex'; player.unlock(); // typed input needs the cursor (the digOpen contract) setTimeout(() => input && input.focus(), 30); } function saveNote() { if (!pending) return; pending.text = input ? input.value.slice(0, TEXT_CAP) : ''; load().push(pending); persist(); pending = null; box.style.display = 'none'; if (hud && hud.showToast) hud.showToast(`🐛 note ${notes.length} saved — F8 anywhere · export on the note card`, 3); } function cancelNote() { pending = null; if (box) box.style.display = 'none'; } // ── the bug tour (?bugtour=1) ──────────────────────────────────────────────────────────────── // Each stop: seg (day segment, set via the DBG idiom), run (poses the player — DBG bookmarks // where one exists, so the tour walks the exact frames QA shoots), cap (what to LOOK AT — the // checklist John doesn't have to carry). Stops the bookmarks don't cover (spawn, the two // interiors, the fog map, the selector hand-off) get their own runs. const D = () => window.DBG || null; const leaveIfInside = () => { if (getMode() === 'interior' && D()) D().exitShop(); }; const TOUR_STOPS = [ { id: 'spawn', title: 'THE ARRIVAL', seg: 2, run: () => { if (spawn) { player.teleport(spawn.x, spawn.z, spawn.yaw); deps.chunks && deps.chunks.warmup(player.position); } }, cap: 'You open facing the shopfront run. Look for: dirt-facing spawn, late-popping buildings, a HUD street row that says where you are.' }, { id: 'street_day', title: 'MAIN STREET, NOON', seg: 2, shot: 'street_noon', cap: 'Walk the strip. Look for: peds ducking into OPEN doors (through the FRONT), signs/awnings/posts, draws vs the ≤300 law in the HUD.' }, { id: 'shop_buy', title: 'INTO A SHOP — THE BUY', seg: 2, enter: 'book', cap: 'You are inside. Walk the room, let the keeper greet you, buy off a shelf (click) or E at the counter. Walk out the door to leave.' }, { id: 'dig', title: 'THE DIG', seg: 2, enter: 'record', cap: 'The record shop. E at a crate opens the riffle — wheel through, pull a sleeve, BUY. Mind the guide. Esc / WALK OUT closes it.' }, { id: 'opshop_bin', title: 'THE OP-SHOP RUMMAGE BIN (UNARMED)', seg: 2, enter: 'opshop', cap: 'The tub is here but its CONTENTS ARE DELIBERATELY UNARMED this build — E on the bin should do NOTHING. If it opens a dig, F8 that.' }, { id: 'gig_night', title: 'GIG NIGHT', seg: 5, shot: 'queue_night', cap: 'The lit venue: marquee, queue forming, posters. Enter — cover charge some nights. Inside: band + crowd. Look for queue jams.' }, { id: 'tram', title: 'THE TRAM', seg: 2, shot: 'tram_stop', cap: 'Wait at the shelter — the tram loops the spine and dwells 3.5 s at stops. Look for: clipping, floating riders, route through fences.' }, { id: 'rain', title: 'RAIN PASS', seg: 2, shot: 'rain_street', cap: 'Weather is seeded — if this boot rolled dry, re-run the tour with &weather=rain. Look for: wet ground read, peds thinning/sheltering.' }, { id: 'night', title: 'NIGHT — THE ONE OPEN DOOR', seg: 5, shot: 'night_neon', cap: 'The open-late video shop glows among closed neighbours. Look for: windows lit that should be dark, closed shops that let you in.' }, { id: 'fog_map', title: 'THE FOG MAP', seg: 5, run: () => { if (getMode() !== 'map') setMode('map'); }, cap: 'The map draws ONLY what you have walked past — plus street names where earned. Look for: shops you never saw already drawn. M closes.' }, { id: 'second_town', title: 'A SECOND TOWN', seg: 2, run: () => { if (getMode() === 'map') setMode('street'); }, cap: 'Top-left selector: pick a real town (query string survives). Tour ends here — F8 keeps working everywhere. Export from the note card.' }, ]; let tour = null, tourBox = null; function tourCaption(i) { if (!tourBox) { tourBox = document.createElement('div'); tourBox.id = 'pc-bugtour'; tourBox.style.cssText = 'position:fixed;left:50%;top:14px;transform:translateX(-50%);z-index:55;' + 'max-width:640px;padding:9px 14px;background:rgba(16,14,8,.9);border:1px solid rgba(255,215,94,.5);' + 'border-radius:10px;font:12px -apple-system,Segoe UI,Roboto,sans-serif;color:#f4efe6;text-align:center'; document.body.appendChild(tourBox); } if (i < 0) { tourBox.innerHTML = '🐛 BUG TOUR — every system, one walk. ' + 'N = next stop · F8 = drop a note at any of them.'; } else if (i >= TOUR_STOPS.length) { tourBox.innerHTML = '🐛 BUG TOUR COMPLETE — export your notes from the F8 card (or DBG.exportNotes()).'; } else { const s = TOUR_STOPS[i]; tourBox.innerHTML = `🐛 ${i + 1}/${TOUR_STOPS.length} — ${s.title}` + `
${s.cap} · N next`; } } function tourAdvance() { if (!tour) return null; tour.index++; const i = tour.index; if (i >= TOUR_STOPS.length) { tourCaption(i); tour.done = true; return { index: i, done: true }; } const s = TOUR_STOPS[i]; try { leaveIfInside(); if (s.seg != null) { lighting.setPaused(true); lighting.setSegment(s.seg); } if (s.shot && D()) D().shot(s.shot); // the existing bookmark, verbatim else if (s.enter && D()) { D().enterShop(s.enter); } // interior stops ride the one shop selector if (s.run) s.run(); } catch (e) { console.warn(`[procity playtest] tour stop ${s.id} failed:`, e && e.message || e); } tourCaption(i); return { index: i, id: s.id, title: s.title }; } function startTour() { if (tour) return tour; tour = { active: true, index: -1, done: false }; tourCaption(-1); const go = document.getElementById('pc-start'); if (go) go.style.display = 'none'; // the tour IS the start — straight in return tour; } // ── input ──────────────────────────────────────────────────────────────────────────────────── addEventListener('keydown', (e) => { if (e.repeat) return; if (e.code === 'F8') { e.preventDefault(); openNote(); } else if (e.code === 'KeyN' && tour && !pending) tourAdvance(); }); // ── api ────────────────────────────────────────────────────────────────────────────────────── const api = { get noteActive() { return !!pending; }, // the shell's unlock/click guards read this (the digActive contract) get count() { return load().length; }, list: () => load().map((n) => ({ ...n })), dropNote: (text = '') => { // headless-driveable note (smokes; John uses F8) const n = snapshot(); n.text = String(text).slice(0, TEXT_CAP); n.shot = null; load().push(n); persist(); return { ...n }; }, exportJSON: () => JSON.stringify({ schema: NOTE_SCHEMA, exported: new Date().toISOString(), town: town || 'synthetic', citySeed: citySeed >>> 0, notes: load() }, null, 2), exportDownload: () => { const a = document.createElement('a'); a.download = `procity-notes-${town || 'synthetic'}-${new Date().toISOString().slice(0, 10)}.json`; a.href = URL.createObjectURL(new Blob([api.exportJSON()], { type: 'application/json' })); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); if (hud && hud.showToast) hud.showToast(`🐛 ${notes.length} note(s) exported — paste the JSON back to the lanes`, 4); return notes.length; }, import: (json) => { // loud reject leaves CURRENT notes untouched (adopt, not merge) let obj; try { obj = JSON.parse(json); } catch (e) { console.error('[procity playtest] import REJECTED (not JSON):', e && e.message || e); return false; } const v = validateNotes(obj); if (!v.ok) { console.error(`[procity playtest] import REJECTED (${v.why}) — notes untouched`); return false; } notes = v.notes; persist(); return true; }, clear: () => { notes = []; persist(); }, openNote, saveNote, cancelNote, // driveable by the smoke (the F8 path itself is also driven raw) startTour, tourAdvance, get tour() { return tour ? { active: tour.active, index: tour.index, done: !!tour.done, total: TOUR_STOPS.length, stopId: tour.index >= 0 && tour.index < TOUR_STOPS.length ? TOUR_STOPS[tour.index].id : null } : null; }, stops: TOUR_STOPS.map((s) => ({ id: s.id, title: s.title })), schema: NOTE_SCHEMA, caps: { notes: NOTES_CAP, text: TEXT_CAP }, }; // the DBG methods (§40.6 "a DBG method"): dbg.js is Lane B's file, so the harness binds from its // own side — DBG may land a beat later (both are dynamic imports), hence the short retry. const bindDBG = () => { if (!window.DBG) return false; window.DBG.exportNotes = api.exportJSON; window.DBG.dropNote = api.dropNote; window.DBG.playtestNotes = api.list; return true; }; if (!bindDBG()) { let n = 0; const t = setInterval(() => { if (bindDBG() || ++n > 50) clearInterval(t); }, 200); } // (deliberately no stored-note count here — printing it would cost the storage read the lazy // store exists to avoid; the count appears on the note card the moment F8 is used) console.log('[procity] playtest harness ready — F8 drops a note; export via the note card or DBG.exportNotes()'); return api; }