PROCITY/web/js/world/playtest.js
m3ultra c3384b92c9 Lane F R40 §40.6: the notepad — F8, the tour, and the gate that caught the harness
THE PLAYTEST HARNESS, for John's bug-hunt session. F8 anywhere drops a structured note:
pos, mode, shop id+name when inside, street/locality via A's address layer, seed, town,
clock segment, live flags, fps, draws — plus a screenshot and a typed one-liner (Esc
cancels, Enter saves). Notes persist under the delta law (bounded, survive reload,
export/import, corrupt store REJECTED WHOLE and loudly) and export as one JSON of
tickets, schema documented in LANE_F_NOTES §40. Zero cost when unused; classic-gated by
construction.

?bugtour=1 walks the whole game in 11 stops (N advances, each with a caption of what to
look at): spawn → street_day → shop_buy → dig → opshop_bin (caption says the contents
are deliberately unarmed) → gig_night → tram → rain → night → fog_map → second_town.
DBG loads without ?dbg=1 so the tour is one flag.

THE GATE CAUGHT THE HARNESS. The first full strict run went RED on exactly one arm —
R30c classic purity, because the playtest constructor read localStorage eagerly on
?game=0. Fixed (lazy store), re-verified on the targeted smoke, and the new gate now
carries its own instrumented zero-calls arm so it cannot recur.

Wave-1 asks, all taken: D's door_footpath_check and B's r40_lane_b wired into qa.sh ·
D's index.html:433-437 door nudge now derives from buildShopfront's doorRect · A's
filed D2 closed (index.html:541 uses the canonical isOpen; remaining divergent call
sites listed for their owners) · README's stale ?r=N 307 row and the flags_check label
that still called the town selector a debt question, both corrected.

qa.sh --strict: 12 passed · 0 failed · 0 warn · 0 skipped. selfcheck 157,647/157,647,
0x5f76e76 unmoved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:29:02 +10:00

378 lines
23 KiB
JavaScript

// 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 = '<b style="color:#ffd75e">🐛 BUG TOUR</b> — every system, one walk. '
+ '<b>N</b> = next stop · <b>F8</b> = drop a note at any of them.';
} else if (i >= TOUR_STOPS.length) {
tourBox.innerHTML = '<b style="color:#ffd75e">🐛 BUG TOUR COMPLETE</b> — export your notes from the F8 card (or DBG.exportNotes()).';
} else {
const s = TOUR_STOPS[i];
tourBox.innerHTML = `<b style="color:#ffd75e">🐛 ${i + 1}/${TOUR_STOPS.length}${s.title}</b>`
+ `<br>${s.cap} <span style="opacity:.6">· N next</span>`;
}
}
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;
}