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>
378 lines
23 KiB
JavaScript
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;
|
|
}
|