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>
This commit is contained in:
m3ultra 2026-08-07 12:29:02 +10:00
parent cf6ac64d75
commit c3384b92c9
11 changed files with 987 additions and 11 deletions

View File

@ -4,6 +4,63 @@
---
## Round 40 (§40.6 — THE PLAYTEST HARNESS) — F8 turns John's session into tickets; the wave-1 asks settled
*Full spec (note schema · tour stops · gate arms · ask detail): [LANE_F_NOTES §40](docs/LANES/LANE_F_NOTES.md).*
**THE KEYSTONE LANDED.** `web/js/world/playtest.js` (NEW, F-owned) + marked shell seams:
- **F8 anywhere drops a note**: `{pos, mode (street/interior/dig/map), shopId+shopName,
street/locality via A's address layer (own `createStreetLocator`, zero draws), citySeed, town,
seg+clock, boolean flags, fps, draws, tris}` + a screenshot download of the CURRENT view
(interiors/open crates render via the new `interiorMode.renderFrame()`) + an overlay one-liner —
**Enter saves, Esc cancels**, typing never reaches the game, and F8 inside a shop does not eject
you (`noteActive` joins the digActive/sellActive unlock contract).
- **Delta-law persistence** (the fog's pattern): key `procity-playtest`, caps 200 notes/240 chars
FIFO at write AND validated at read, **corrupt-reject-WHOLE and loud**, import = adopt (reject
leaves current notes untouched). **Export = one tickets-JSON** (`procity-playtest/1`, 18 typed
fields per note — schema documented §40.2 of the notes) via the note-card button,
`DBG.exportNotes()`, or `PROCITY.playtest.exportJSON()`. **The store is LAZY, and a gate earned
that**: the first cut's constructor `getItem` turned R30c classic purity RED
(`?game=0: storage calls 1`) on the full strict run — fixed to read-on-first-use, R30c re-run
green, and r40_playtest.py grew its own instrumented `?game=0` zero-calls arm so the class stays
caught in F's gate too.
- **Classic-gated by construction**: dynamic import only when `flagOn('playtest')` (default-on,
`?playtest=0` opt-out) — an off boot **never fetches the module** (185/207 requests swept clean),
never grows the DOM, never touches storage (classic: localStorage 0 keys). Control demonstrated:
the default boot fetches + publishes it.
- **`?bugtour=1`**: dbg.js now loads under the flag alone, and N walks **11 stops** — spawn →
street day → shop/buy (book) → dig (record) → **op-shop bin, caption says UNARMED** → gig night →
tram → rain (caption: re-run `&weather=rain` if the seed rolled dry) → night → fog map → second
town via the selector — every stop a caption of what to look at, DBG bookmarks reused verbatim,
F8 live throughout.
- **Gate `tools/qa/r40_playtest.py` GREEN, all arms + controls**: off-byte-identical (+ its
fetched-on-default control) · the REAL F8 key path round-trips (download → overlay → Enter →
export schema-validates 18/18 → reload persists same id → Esc arm → both reject arms) · the tour
advances through all 11 stops with **0 console errors**, no `?dbg=1` needed.
**THE FIVE ASKS:** ① D's `door_footpath_check.mjs` **wired** (qa.sh Gate 9, plain node, ~2 s) ·
② B's `r40_lane_b.py` **wired** (Gate 9, Playwright class) · ③ D's doorRect derivation **measured
and FILED BACK**: through D's own classifier + shipped snap, the `d/2+0.05` doorRect step reads
synthetic strict **447→446**, walkable **473→472**, and **breaks 1 previously-passing point**
(violates D's own FIX-ONLY arm) — real towns 100% either way; the 20 plaza misses are stall-packing
geometry, not nudge length; shell stays `+0.6`, numbers handed to D · ④ A's debt **D2 CLOSED**:
the shell's `isOpen` is A's canonical (no-hours ⇒ OPEN) via the barrel, every `PROCITY.isOpen`
consumer (audio + 19 tools call sites) inherits it; remaining third impls listed in the notes
(sim.js `_openAt` — already A's semantics; dbg.js openLate predicates) · ⑤ README `?r=N` row now
states the R40 law (**391 measured, ≤420 declared, gated**; 307 gone) + flags_check's selector
label now reads **RULED R40**.
**QA:** `bash tools/qa.sh --strict`**GREEN** (summary line in the round-close message; Gate 9's
three new gates all pass) · selfcheck **157,647/157,647 ALL GREEN, fingerprint `0x5f76e76`**
zero goldens moved (the harness is DOM + storage; it renders nothing and writes no plan).
Shots: `docs/shots/laneF_r40/r40_f8_note_overlay.png` · `r40_bugtour_caption.png`.
**Parked (unchanged, per the round brief):** op-shop bin contents · fog default-on · v9 third
layer · the audio listen pass — all become F8 tickets the moment John plays. **No tag.**
---
## Round 39 (v9 WAVE 1 — THE CITY IS THE GAME) — five gates, five controls, and the interior margin is 162
*Full detail: [LANE_F_NOTES §39](docs/LANES/LANE_F_NOTES.md). (R38's close lives in

View File

@ -102,7 +102,7 @@ master off:
| `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster |
| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock |
| `?verge=0` | **off** | reverts the ground to the **pre-kerb-ruling** geometry (bitumen painted to the full corridor). John's v8 ruling made the band between kerb and shopfront **footpath** — 9 m on the synthetic town's 28 m main, **7 m** on a real 24 m main, and **3 m** on the `side`@12 edge that is 72% of the corpus. The flag stays forever as the gate's falsifiability control: the poster-clearance leg is asserted to FAIL under it, at exactly 14 posters standing on road paint. `?classic=1` forces the ruling off — the covenanted town keeps its paint |
| `?r=N` · `?shadows=0\|1` | auto | streaming radius and the sun-shadow pass. **Auto is load-bearing**: a big town (>120 shops or >200 street edges) gets radius 2 with shadows off. Forcing `?r=3` on the default town measures **307 draws**, i.e. over the 300 gate — these overrides are diagnostics, not supported boots |
| `?r=N` · `?shadows=0\|1` | auto | streaming radius and the sun-shadow pass. **Auto is load-bearing**: a big town (>120 shops or >200 street edges) gets radius 2 with shadows off. A `?r=` **above** auto is a DIAGNOSTIC boot and declares its own law (R40): `?r=3` measures **382 noon / 391 night** draws at 4548 live chunks — structural, not fat — so it boots under a pinned **≤420** ceiling (`PROCITY.budget`, console-warned, gated by `tools/qa/r40_lane_b.py`), tri law unchanged. Not a supported boot |
| `?live=1` · `?live=<url>` | **off** | **tier 2** — live stock from the GODVERSE server (sold-in-the-real-shop means gone in the game). Opt-in by design: absent ⇒ byte-identical to tier 1, and the game never asks a question it has no reason to expect an answer to |
| `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness |
| `?plansrc=osm&town=<key>` | — | boot a **real Australian town**: fixtures `melbourne`/`katoomba`/`silverton`, or a real OSM cache `bendigo_real`/`castlemaine_real`/`fremantle_real`/`katoomba_real`/`newtown_real` (ODbL, `web/assets/towns/SOURCES.md`). **REAL ROADS:** a cache with `roads[]` (schema v2) builds the CityPlan street graph from the town's **actual OSM streets**; without `roads[]` a cache marches onto synthetic avenues. Or just **pick a town from the HUD selector** — no URL needed |

View File

@ -2999,3 +2999,153 @@ is interior. The pin stands at **292/300**. What moved is the *interior* budget,
favour by 141 draws.
**No tag.**
## §40 — R40: THE PLAYTEST HARNESS (§40.6, the keystone) + the wave-1 asks. F8 turns John's session into tickets.
*(PROCITY-F, 2026-08-04. The no-rulings round: A/B/D/E landed first, F consumed. All numbers below
were run on the final tree in fresh headless contexts off port-isolated no-store servers.)*
### §40.1 — THE HARNESS: `web/js/world/playtest.js` (NEW, F-owned) + the shell seams
**F8 anywhere drops a note.** The keydown captures the full "where was I and what was the game
doing" context, fires a screenshot download of the CURRENT view (street composer frame; interiors
and open crates render through the new `interiorMode.renderFrame()` — the drawing buffer is not
preserved, so capture must render first and only interior_mode knows whether the dig owns the
frame), then opens a small overlay input. **Enter saves, Esc cancels**; keystrokes stop at the
input so WASD/[ ]/M never hear the typing; the shell's unlock→leaveShop guard and the canvas
re-lock guard both learned `playtest.noteActive` (the exact `digActive` contract), so an F8 inside
a shop does not eject you.
**Classic-gated BY CONSTRUCTION** (null-provider idiom): the shell **dynamic-imports** the module
only when `PLAYTEST_ON` (= `flagOn('playtest')` — default-on, `?playtest=0` opts out, `?classic=1`
master-off), so an off boot never fetches the file, never grows the DOM, never touches storage —
asserted, not claimed (gate below). Zero cost when unused even ON: two key listeners, a lazy
overlay, no rAF, no draws.
**Persistence is the fog's delta law** (save.js §39 pattern, mirrored): own key
`procity-playtest`, **bounded at write AND validated at read** (`NOTES_CAP 200` FIFO, `TEXT_CAP
240`), corrupt/foreign blobs **REJECTED WHOLE with a loud console error**, `import()` rejects
without touching current notes (adopt semantics, never merge).
**…and the store is LAZY, because a gate caught me (the honest record):** the first cut read
`localStorage.getItem` at construction, and the full strict run went RED on **R30c classic
purity** — `?game=0: storage calls 1 (first ['getItem:procity-playtest'])` against the
instrumented ZERO-Storage-calls law. Fix: nothing is read until the harness is USED (first
F8/export/count pays exactly one read). Re-measured: R30c green (`?game=0: ZERO Storage calls`,
positive control intact), and r40_playtest.py now carries its own instrumented arm — `?game=0`
boots with playtest LIVE and **0 Storage calls**, first use = exactly one `getItem` (the
instrument's own control) — so this regression class stays caught in F's gate, not just B's.
### §40.2 — THE NOTE SCHEMA (the ticket contract — lanes consume this)
One export JSON (a download button on the note card, `DBG.exportNotes()`, or
`PROCITY.playtest.exportJSON()`):
```json
{ "schema": "procity-playtest/1", "exported": "<ISO>", "town": "<key|synthetic>", "citySeed": 20261990,
"notes": [ {
"id": "msdceymk-1", // unique per note; the screenshot filename carries it
"t": 1754280000000, // epoch ms
"text": "the awning clips", // John's one-liner, ≤240 chars
"shot": "procity-note-msdceymk-1.png", // the download that landed next to it, or null
"pos": { "x": -96.0, "y": 1.7, "z": 9.0 },
"mode": "street", // street | interior | dig | map
"shopId": 12, "shopName": "Anglibarn Op Shop", // null/null when not inside
"street": null, // A's streetOf(edge under your feet) — real name or null, NEVER a guess
"locality": "the south end of the main street", // A's localityOf(...).label (both town types; null-once law)
"citySeed": 20261990, "town": null, // town = CHAR_TOWN (null on the synthetic)
"seg": 3, "clock": "15:30",
"flags": { "gigs": true, "fog": true, "playtest": true, "...": true }, // boolean flags at capture
"fps": 20, "draws": 152, "tris": 17400
} ] }
```
Field-for-field validation lives in `validateNotes()` (playtest.js) and is re-asserted from the
outside by the gate. `street`/`locality` ride A's §39 contract verbatim (print `label`, never
branch on town type — playtest builds its own `createStreetLocator(plan)`, µs lookups, zero
draws). **A ticket = one note: pos+mode+shop locate it, seed+town+seg reproduce it, the shot shows
it, the text names it.**
### §40.3 — ?bugtour=1: THE GUIDED WALK (11 stops, N advances)
`?bugtour=1` forces dbg.js to load (shell seam — the tour drives `DBG.shot`/`DBG.enterShop`, so
its poses are the SAME bookmarks every QA shot uses). Caption bar top-centre; **N** = next; F8
works at every stop; entering a stop leaves any open interior first. The stops, in order:
| # | id | how it poses | what the caption points at |
|---|---|---|---|
| 1 | `spawn` | `player.teleport(SPAWN)` seg 2 | facing the strip · pop-in · HUD street row |
| 2 | `street_day` | bookmark `street_noon` | peds through FRONT doors · signage · ≤300 law |
| 3 | `shop_buy` | `DBG.enterShop('book')` | keeper greet · shelf buy · door exit |
| 4 | `dig` | `DBG.enterShop('record')` | E at a crate · riffle · BUY · guide |
| 5 | `opshop_bin` | `DBG.enterShop('opshop')` | **contents deliberately UNARMED — E must do nothing; if a dig opens, F8 it** |
| 6 | `gig_night` | bookmark `queue_night` seg 5 | marquee · queue · cover · band+crowd |
| 7 | `tram` | bookmark `tram_stop` seg 2 | dwell 3.5 s · clipping · fenced routes |
| 8 | `rain` | bookmark `rain_street` | seeded — caption says re-run `&weather=rain` if dry |
| 9 | `night` | bookmark `night_neon` seg 5 | the one open-late glow · wrongly lit windows |
| 10 | `fog_map` | `setMode('map')` | only-what-you-walked · earned street names · M closes |
| 11 | `second_town` | caption only | top-left selector → a real town; query string survives; tour ends |
### §40.4 — THE GATE: `tools/qa/r40_playtest.py` (wired in qa.sh Gate 9), all arms + controls
- **OFF = BYTE-IDENTICAL**: `?classic=1` and `?playtest=0` — playtest.js **never fetched** (185 /
207 requests swept), no `#pc-note`/`#pc-bugtour` DOM, `PROCITY.playtest` undefined, storage
untouched (classic: **localStorage 0 keys**). **CONTROL**: the default boot fetches it and
publishes it, or the absence arms are vacuous. Also held: a default boot with 0 notes writes
nothing (persist only on save).
- **F8 ROUND-TRIP, the real key path**: keyboard F8 → download `procity-note-<id>.png` → overlay →
typed line → Enter ⇒ 1 note; **Esc cancels without saving**; export validates **18/18 fields
typed, no extras**; context read `mode street · seed 20261990 · 163 draws · seg 2 12:30 ·
locality 'the south end of the main street'`; reload persists (same id); `import()` of a
malformed note **rejects, notes untouched**; a corrupt store **rejects WHOLE and loudly** on the
next boot. **Interior arm**: F8 inside the record shop captures `mode interior · shop 'The Kelpie
Sound Co.' (id 29) · 48 draws` (the `renderFrame` path) and the player is **NOT ejected** — the
`noteActive` unlock guard held.
- **THE TOUR**: under `?bugtour=1` alone (no `?dbg=1`) DBG loads, N advances through **all 11
stops** (ids asserted individually), one more N completes, F8 drops a note mid-tour, **0 console
errors** across the whole walk.
Shots: `docs/shots/laneF_r40/r40_f8_note_overlay.png` (the card mid-note, context line + typed
one-liner, op-shop row behind) · `r40_bugtour_caption.png` (stop 6/11 GIG NIGHT over the lit
Exchange Hotel marquee).
### §40.5 — THE WAVE-1 ASKS, each with its outcome
1. **[D] `door_footpath_check.mjs` wired** — qa.sh Gate 9, `run_gate node …` (rc 0/1, ~2 s, always
runs — no venv needed). Green on the final tree.
2. **[B] `r40_lane_b.py` wired** — qa.sh Gate 9 beside it (Playwright class, soft-skip without the
venv, exactly the R39 pattern). Green on the final tree.
3. **[D] the doorRect derivation — MEASURED AND FILED BACK, not taken.** D's spec (LANE_D_NOTES
§40): replace the shell's `d/2 + 0.6` nudge with `buildShopfront`'s own doorRect offset
(`zd = d/2 + 0.05`, buildings.js:609). Measured through D's OWN classifier + the shipped
`snapDoorToFootpath`, four towns, seed 20261990: synthetic strict **447→446** (90.7→90.5%),
walkable **473→472** (95.9→95.7%), residue recomposes (`shop:in-a-lot` 2→3) — and the snap
**breaks 1 previously-passing point**, which is a straight violation of D's own FIX-ONLY gate
arm (must be 0). Real towns 100% under both. So the doorRect step does NOT recover the 20 plaza
misses (they are stall-packing geometry, not nudge length) and would turn D's gate RED.
**Filed → D**: the derivation stays `+0.6`; if the plaza residue is ever wanted, the fix is a
front-ground-aware derivation (or a capped plaza snap), not the raw doorRect offset — and note
the gate's replica (`DOOR_STEP`, door_footpath_check.mjs:45) must move in the same change as
any future shell change, or the gate certifies a phantom.
4. **[A, debt D2] CLOSED — the shell's `isOpen` is now A's canonical** (plan.js:28 via the barrel;
`citygenMod.isOpen(shop, currentHour())`, inline form kept only as the fixture-boot fallback).
No-hours ⇒ OPEN, the closing-time law — the exact inversion the old inline predicate carried.
Every consumer of `window.PROCITY.isOpen` (audio.js:516 + 19 call sites across 5 tools files)
inherits the fix through the one published symbol; none re-implement it. **Remaining divergent
call sites, NOT touched (other lanes' files, listed per the round brief):**
`citizens/sim.js:383 _openAt` (A's semantics already, its own clock — harmless third impl) ·
`world/dbg.js` ad-hoc hour predicates (`videoShop`/`night_neon`/openLate `hours[1] >= 22`
tests — openLate identity, not open-ness; fine but B may want them reading A's LATE_HOUR).
Behaviour delta today: **zero** (A measured 0/1,838 corpus shops hours-less).
5. **[B] both doc corrections landed**: README `?r=N` row now states the R40 law (382/391 measured,
≤420 declared diagnostic ceiling, gated by r40_lane_b.py — the stale 307 is gone) ·
flags_check.py's POST_V2_EXCEPTIONS label now reads **RULED R40** (classic shows the selector —
information, not gameplay state; still a named exception, never baseline).
### Files touched (all `[Lane F R40]`-marked where shared)
`web/js/world/playtest.js` (NEW) · `web/index.html` (playtest flag+import+guards, dbg-under-bugtour,
canonical isOpen, flags.playtest) · `web/js/world/interior_mode.js` (`get shop`, `renderFrame()`) ·
`tools/qa/r40_playtest.py` (NEW) · `tools/qa.sh` (Gate 9: D's gate + B's gate + F's gate) ·
`tools/flags_check.py` (label) · `README.md` (?r= row) · `docs/shots/laneF_r40/` · this file ·
`F-progress.md`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

View File

@ -168,8 +168,8 @@ DEPOT_HOSTS = ('digalot.fyi',)
POST_V2_EXCEPTIONS = (
('/assets/towns/index.json',
'v4 town-selector machinery (078245f R20, 4b01449 R21) — NOT v2 baseline; carried by Fable\'s '
'R37 ruling as a named exception (no geometry, no identity, no seeded draw). Debt ledger R38+: '
'should ?classic=1 show the town selector at all?'),
'R37 ruling as a named exception (no geometry, no identity, no seeded draw). RULED R40 (Fable): ' # [Lane F R40] the debt question closed — B's r40_lane_b.py asserts the ruling
'classic SHOWS the selector — information, not gameplay state. Still an exception, never baseline.'),
)
# The core that must ALL be observed, or the observer is not looking at a live boot (anti-vacuous):
# the four v2-era depot street GLBs + the covenanted ped fleet's shared clips.

View File

@ -161,6 +161,30 @@ else
soft_skip "R39 transmission + runtime gates" "Playwright venv absent"
fi
# ── Gate 9 (Lane F, round 40): the R40 gates ─────────────────────────────────
# [Lane F R40] Three gates, wired per each lane's §40 ask:
# • D §40.5 — door→footpath (node, ~2s, zero deps): walkable-front ≥95% on all four towns, the
# back-point control non-vacuous, the kerb clamp FIX-ONLY. rc 0/1.
# • B §40.3 — the ?r= budget law (default 300 / diagnostic 420 + warn, measured stepwalk ≤ own
# ceiling), classic's town selector (RULED R40), fog signage both arms. Own no-store server.
# • F §40.6 — the playtest harness: off-boot byte-identical (no fetch, no DOM, no storage), the
# F8 note round-trip (drop → export → schema-validate → reload persists → corrupt-reject), and
# ?bugtour=1 advancing through every stop with zero console errors.
run_gate "R40 door→footpath (D §40.5: walkable ≥95% · back-point control · fix-only clamp)" \
node tools/qa/door_footpath_check.mjs
R40_SKIP=0; for a in "$@"; do [ "$a" = "--no-r40" ] && R40_SKIP=1; done
if [ "$R40_SKIP" = 1 ]; then
soft_skip "R40 browser gates (budget law · selector ruling · playtest harness)" "--no-r40"
elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
run_gate "R40 ?r= budget law + classic selector + fog signage (B §40.3, both arms each)" \
tools/.venv/bin/python tools/qa/r40_lane_b.py
run_gate "R40 playtest harness (F §40.6: off byte-identical · F8 round-trip · bugtour clean)" \
tools/.venv/bin/python tools/qa/r40_playtest.py
else
soft_skip "R40 browser gates (budget law · selector ruling · playtest harness)" "Playwright venv absent"
fi
# ── Summary ──────────────────────────────────────────────────────────────────
hr
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \

290
tools/qa/r40_playtest.py Normal file
View File

@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""PROCITY Lane F — R40 §40.6 gate: THE PLAYTEST HARNESS (F8 notes + ?bugtour=1).
Three arms, each with its non-vacuous control, per the round brief:
OFF = BYTE-IDENTICAL ?classic=1 and ?playtest=0 must never fetch playtest.js, never grow the
DOM (#pc-note / #pc-bugtour absent), never touch storage. CONTROL: the
default boot DOES fetch it and publishes PROCITY.playtest otherwise the
absence arms prove nothing.
F8 ROUND-TRIPS The REAL key path (keyboard F8, not an API call): screenshot download
fires, the overlay opens, a typed line + Enter saves. Export JSON
schema-validates field-for-field (the ticket contract, LANE_F_NOTES §40),
a reload persists the note, Esc cancels without saving, and a corrupt /
wrong-schema blob is REJECTED WHOLE on load and on import() (delta law).
?bugtour=1 ADVANCES N steps through EVERY stop (spawn street shop/buy dig op-shop bin
(unarmed) gig night tram rain night fog map second town) with
ZERO console errors, the caption tracking each stop and the shell loads
DBG under ?bugtour=1 alone (no ?dbg=1), because the tour drives it.
Run: tools/.venv/bin/python tools/qa/r40_playtest.py (own no-store server, fresh contexts)
Exit: 0 green, 1 red.
"""
import sys, os, json, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = int(os.environ.get('PROCITY_R40_PT_PORT', '8974'))
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
SCHEMA = 'procity-playtest/1'
NOTE_FIELDS = {'id': str, 't': (int, float), 'text': str, 'shot': (str, type(None)),
'pos': dict, 'mode': str, 'shopId': (int, str, type(None)), 'shopName': (str, type(None)),
'street': (str, type(None)), 'locality': (str, type(None)), 'citySeed': int,
'town': (str, type(None)), 'seg': int, 'clock': str, 'flags': dict,
'fps': (int, float, type(None)), 'draws': (int, float), 'tris': (int, float)}
fails = []
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
def OK(m): print(f" \033[32m✓\033[0m {m}")
def head(m): print(f"\n\033[1m{m}\033[0m")
def note(m): print(f" \033[33m·\033[0m {m}")
def check(cond, m):
(OK if cond else FAIL)(m)
return cond
NOSTORE = r'''
import sys, http.server, functools
class H(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
super().end_headers()
def log_message(self, *a): pass
http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])),
functools.partial(H, directory=sys.argv[2])).serve_forever()
'''
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0
def serve(root, port):
proc = subprocess.Popen([sys.executable, '-c', NOSTORE, str(port), str(root)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(80):
if port_up(port): return proc
time.sleep(0.1)
proc.terminate(); raise SystemExit(f'could not serve on :{port}')
def new_page(p):
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720}, accept_downloads=True)
errs, reqs, cons = [], [], []
pg.on('console', lambda m: (cons.append(m.text), errs.append(m.text) if m.type == 'error' else None))
pg.on('pageerror', lambda e: errs.append(str(e)))
pg.on('request', lambda r: reqs.append(r.url))
return b, pg, errs, reqs, cons
def boot(pg, query, wait_dbg=True):
pg.goto(f'{HOST}/index.html?seed={SEED}' + (('&' + query) if query else ''))
if wait_dbg:
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000)
else:
pg.wait_for_function('window.PROCITY && window.PROCITY.chunks && window.PROCITY.chunks.count > 0',
timeout=30000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
def gate_off(p):
head('OFF = BYTE-IDENTICAL — ?classic=1 / ?playtest=0 never fetch, never grow, never store')
for q, label in (('classic=1&dbg=1', '?classic=1'), ('playtest=0&dbg=1', '?playtest=0')):
b, pg, errs, reqs, _ = new_page(p)
boot(pg, q)
pg.wait_for_timeout(1200) # give a wrongly-wired dynamic import time to land
fetched = [u for u in reqs if 'playtest.js' in u]
dom = pg.evaluate("() => !!(document.getElementById('pc-note') || document.getElementById('pc-bugtour'))")
pub = pg.evaluate("() => window.PROCITY.playtest !== undefined")
stored = pg.evaluate("() => localStorage.getItem('procity-playtest')")
nstore = pg.evaluate("() => localStorage.length")
check(not fetched, f'{label}: playtest.js never fetched ({len(reqs)} requests swept)')
check(not dom and not pub, f'{label}: no harness DOM, PROCITY.playtest undefined')
check(stored is None, f'{label}: storage untouched (procity-playtest absent'
+ (f', localStorage empty)' if q.startswith('classic') and nstore == 0 else ')'))
if q.startswith('classic'):
check(nstore == 0, f'{label}: localStorage has 0 keys (the classic covenant)')
check(not errs, f'{label}: 0 console errors')
b.close()
# ?game=0 — the harness stays LIVE but its store is LAZY: the R30c purity law holds ?game=0 at
# ZERO Storage-prototype calls (instrumented before any page script), and an eager getItem in
# the constructor turned that gate red once (this round). Boot must make no calls; the first
# deliberate use pays exactly one read (the control that proves the instrument sees anything).
b, pg, errs, reqs, _ = new_page(p)
pg.add_init_script("""(() => { window.__pcStorageCalls = []; const P = Storage.prototype;
for (const m of ['getItem','setItem','removeItem','clear','key']) { const o = P[m];
P[m] = function(...a) { window.__pcStorageCalls.push(m + ':' + (a[0] ?? '')); return o.apply(this, a); }; } })()""")
boot(pg, 'game=0&dbg=1')
pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000)
pg.wait_for_timeout(500)
ncalls = pg.evaluate("() => window.__pcStorageCalls.length")
check(ncalls == 0, f'?game=0: playtest LIVE with ZERO Storage calls at boot (saw {ncalls}) — R30c purity holds')
cnt = pg.evaluate("() => window.PROCITY.playtest.count")
lazy = pg.evaluate("() => window.__pcStorageCalls")
check(cnt == 0 and any(c == 'getItem:procity-playtest' for c in lazy),
'CONTROL: first use pays exactly the one lazy read (the instrument sees it)')
check(not errs, '?game=0: 0 console errors')
b.close()
# CONTROL — the absence arms are only meaningful if the default boot has the harness
b, pg, errs, reqs, _ = new_page(p)
boot(pg, 'dbg=1')
pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000)
fetched = [u for u in reqs if 'playtest.js' in u]
check(bool(fetched), 'CONTROL: default boot fetches playtest.js')
check(pg.evaluate("() => window.PROCITY.flags.playtest === true"), 'CONTROL: flags.playtest true on default')
stored = pg.evaluate("() => localStorage.getItem('procity-playtest')")
check(stored is None, 'default boot with 0 notes still writes nothing (persist only on save)')
check(not errs, 'default boot: 0 console errors')
b.close()
def gate_f8(p):
head('F8 ROUND-TRIP — the real key path: shot download · overlay · Enter saves · export validates')
b, pg, errs, _, cons = new_page(p)
boot(pg, 'dbg=1')
pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000)
pg.wait_for_function('() => window.DBG.exportNotes !== undefined', timeout=15000)
OK('DBG.exportNotes bound (the §40.6 DBG method)')
with pg.expect_download() as dl:
pg.keyboard.press('F8')
name = dl.value.suggested_filename
check(name.startswith('procity-note-') and name.endswith('.png'), f'F8 fired the screenshot download ({name})')
pg.wait_for_selector('#pc-note', state='visible', timeout=5000)
OK('the note overlay opened')
pg.wait_for_function("() => document.activeElement && document.activeElement.id === 'pc-note-text'", timeout=5000)
pg.type('#pc-note-text', 'the awning clips the verandah post here')
pg.press('#pc-note-text', 'Enter')
check(pg.evaluate("() => window.PROCITY.playtest.count") == 1, 'Enter saved — 1 note stored')
check(pg.evaluate("() => document.getElementById('pc-note').style.display") == 'none', 'overlay closed on save')
# Esc cancels — and cancels means NOT saved
with pg.expect_download():
pg.keyboard.press('F8')
pg.wait_for_selector('#pc-note', state='visible', timeout=5000)
pg.press('#pc-note-text', 'Escape')
check(pg.evaluate("() => window.PROCITY.playtest.count") == 1, 'Esc cancelled — still 1 note')
# export → schema-validate field-for-field (the ticket contract)
exported = pg.evaluate("() => window.DBG.exportNotes()")
try:
obj = json.loads(exported)
except Exception as e:
FAIL(f'export is not JSON: {e}'); b.close(); return
check(obj.get('schema') == SCHEMA and isinstance(obj.get('notes'), list) and len(obj['notes']) == 1,
f'export envelope: schema {SCHEMA}, 1 note')
n = obj['notes'][0]
bad = [k for k, t in NOTE_FIELDS.items() if k not in n or not isinstance(n[k], t)]
extra = [k for k in n if k not in NOTE_FIELDS]
check(not bad and not extra, f'note schema exact: {len(NOTE_FIELDS)} fields typed'
+ (f' — BAD {bad} EXTRA {extra}' if bad or extra else ''))
check(n['text'] == 'the awning clips the verandah post here', 'the typed one-liner round-tripped')
check(n['mode'] == 'street' and n['citySeed'] == SEED and n['draws'] > 0
and all(isinstance(n['pos'][k], (int, float)) for k in ('x', 'y', 'z')),
f"context captured: mode {n['mode']} · seed {n['citySeed']} · {n['draws']} draws · pos ✓ · seg {n['seg']} {n['clock']}")
check(n['shot'] == name, 'note links its screenshot filename')
note(f"street {n['street']!r} · locality {n['locality']!r} · fps {n['fps']} · flags {sum(n['flags'].values())} on")
# reload → persisted (delta law: survives, bounded, same note)
pg.reload()
boot(pg, 'dbg=1')
pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000)
check(pg.evaluate("() => window.PROCITY.playtest.count") == 1
and pg.evaluate("() => window.PROCITY.playtest.list()[0].id") == n['id'],
'reload: the note survived, same id')
# import-reject leaves current notes untouched; corrupt store rejected WHOLE on next boot
ok_rej = pg.evaluate("() => window.PROCITY.playtest.import('{\"schema\":\"procity-playtest/1\",\"notes\":[{\"bad\":1}]}')")
check(ok_rej is False and pg.evaluate("() => window.PROCITY.playtest.count") == 1,
'import(): malformed note REJECTED, current notes untouched')
pg.evaluate("() => localStorage.setItem('procity-playtest', '{\"schema\":\"nope\",\"notes\":[]}')")
pg.reload()
boot(pg, 'dbg=1')
pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000)
check(pg.evaluate("() => window.PROCITY.playtest.count") == 0, 'corrupt store on boot: REJECTED WHOLE, empty start')
check(any('REJECTED' in c for c in cons), 'the reject is LOUD (console names it)')
# F8 INSIDE A SHOP — the interiorMode.renderFrame arm, and the no-eject guard (noteActive joins
# the digActive/sellActive unlock contract: taking a note must not walk you out the door).
pg.evaluate("() => window.DBG.setSegment(2)")
pg.evaluate("() => window.DBG.enterShop('record')")
pg.wait_for_function("() => window.PROCITY.mode === 'interior'", timeout=10000)
pg.wait_for_timeout(400)
with pg.expect_download():
pg.keyboard.press('F8')
pg.wait_for_selector('#pc-note', state='visible', timeout=5000)
pg.press('#pc-note-text', 'Enter')
ni = pg.evaluate("() => window.PROCITY.playtest.list()[0]")
check(ni['mode'] == 'interior' and bool(ni['shopName']) and ni['draws'] > 0,
f"interior note: mode {ni['mode']} · shop {ni['shopName']!r} (id {ni['shopId']}) · {ni['draws']} draws")
check(pg.evaluate("() => window.PROCITY.mode") == 'interior', 'F8 did NOT eject the player from the shop')
check(not errs or all('REJECTED' in e for e in errs), '0 unexplained console errors on the F8 arm')
b.close()
def gate_tour(p):
head('?bugtour=1 — N advances through every stop, zero console errors, DBG loads without ?dbg=1')
b, pg, errs, _, _ = new_page(p)
boot(pg, 'bugtour=1', wait_dbg=True) # no ?dbg=1 — the shell must load DBG for the tour itself
OK('DBG present under ?bugtour=1 alone')
pg.wait_for_function('() => window.PROCITY.playtest && window.PROCITY.playtest.tour', timeout=15000)
t0 = pg.evaluate("() => window.PROCITY.playtest.tour")
total = t0['total']
check(t0['active'] and t0['index'] == -1, f'tour armed at the intro caption ({total} stops)')
seen = []
for i in range(total):
pg.keyboard.press('n')
pg.wait_for_function(f"() => window.PROCITY.playtest.tour.index === {i}", timeout=20000)
st = pg.evaluate("() => window.PROCITY.playtest.tour")
cap = pg.evaluate("() => (document.getElementById('pc-bugtour')||{}).innerText || ''")
okc = st['stopId'] is not None and f'{i + 1}/{total}' in cap
seen.append(st['stopId'])
if not okc:
FAIL(f'stop {i}: id {st["stopId"]} caption {cap[:60]!r}')
pg.wait_for_timeout(150)
check(len(seen) == total and all(seen), f'advanced through all {total} stops: {"".join(seen)}')
for req in ('spawn', 'street_day', 'shop_buy', 'dig', 'opshop_bin', 'gig_night', 'tram', 'rain',
'night', 'fog_map', 'second_town'):
if req not in seen:
FAIL(f'required stop missing from the tour: {req}')
pg.keyboard.press('n')
pg.wait_for_function("() => window.PROCITY.playtest.tour.done === true", timeout=10000)
OK('one more N — tour completes')
# F8 works mid-tour (the point of the whole harness)
with pg.expect_download():
pg.keyboard.press('F8')
pg.wait_for_selector('#pc-note', state='visible', timeout=5000)
pg.press('#pc-note-text', 'Enter')
check(pg.evaluate("() => window.PROCITY.playtest.count") >= 1, 'F8 drops a note mid-tour')
check(not errs, f'0 console errors across the whole tour ({len(errs)} seen)'
+ (f'{errs[:3]}' if errs else ''))
b.close()
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit('playwright not installed — tools/.venv/bin/pip install playwright')
srv = serve(ROOT / 'web', PORT)
try:
with sync_playwright() as p:
gate_off(p)
gate_f8(p)
gate_tour(p)
finally:
srv.terminate()
print()
if fails:
print(f'\033[31mRED — {len(fails)} arm(s) failed\033[0m')
for f in fails: print(f' · {f}')
sys.exit(1)
print('\033[32mGREEN — the playtest harness holds (off byte-identical · F8 round-trips · the tour walks)\033[0m')
if __name__ == '__main__':
main()

View File

@ -54,7 +54,7 @@ import { createChunkManager } from './js/world/chunks.js';
import { createPlayer } from './js/world/player.js';
import { createHUD } from './js/world/hud.js';
import { createMinimap } from './js/world/minimap.js';
import { createDiscovery, fogKeyFor } from './js/world/discovery.js'; // [Lane B R39 §39.2] v9 THE FOG
import { createDiscovery, fogKeyFor, createStreetLocator } from './js/world/discovery.js'; // [Lane B R39 §39.2] v9 THE FOG · [R40 §40.3] fog signage locator
import { createInteriorMode, godverseBaseFor } from './js/world/interior_mode.js'; // [Lane F integration] Lane C interior bridge
import { createWallet } from './js/interiors/wallet.js'; // [Lane F R8] Lane C buy loop v0 (session wallet)
import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians
@ -153,7 +153,24 @@ try {
// 20 shops / 799 edges / 5 km) — the shop-count heuristic alone left it on small-town streaming and blew the
// tri budget (280k > 200k). Extend the trigger to the graph size so big-graph towns stream like big cities.
const BIG_CITY = (plan.shops?.length || 0) > 120 || (plan.streets?.edges?.length || 0) > 200;
const RADIUS = parseInt(params.get('r'), 10) || (BIG_CITY ? 2 : 3);
const R_AUTO = BIG_CITY ? 2 : 3;
const RADIUS = parseInt(params.get('r'), 10) || R_AUTO;
// [Lane B R40 §40.3 — THE ?r= BUDGET LAW] A ?r= above the auto radius is a DIAGNOSTIC boot, and it
// is now GATED rather than silently over the street law: it declares its own budget here, prints it,
// and qa asserts it (tools/qa/r40_lane_b.py). Measured on this tree (pin method — stepwalk 2 m,
// yaw 0, laps 2, seed 20261990, synthetic): default 282 noon / 291 night vs ?r=3 382 noon / 391
// night at 48 live chunks — the breach is structural (draws scale with the live-chunk window, and
// r=3 nearly doubles it: 4548 vs 31), so "shave it under 300" would mean cutting content the
// DEFAULT boot ships to legalise a diagnostic. Instead: the diagnostic ceiling is ≤420 draws
// (391 measured + ~7% crowd/streamer jitter) with the tri law unchanged; if the town grows past it,
// the qa gate goes red and the ceiling is re-pinned CONSCIOUSLY. ?r= at or below auto stays under
// the street law and is not a diagnostic.
const R_DIAG = RADIUS > R_AUTO;
const BUDGET = R_DIAG ? { draws: 420, tris: 200_000, radius: RADIUS, auto: R_AUTO, diagnostic: true }
: { draws: 300, tris: 200_000, radius: RADIUS, auto: R_AUTO, diagnostic: false };
if (R_DIAG) console.warn(`[procity] ?r=${RADIUS} is a DIAGNOSTIC boot (auto radius ${R_AUTO}): the `
+ `≤300 street law is the auto radius's; this boot's own law is ≤${BUDGET.draws} draws / `
+ `≤${BUDGET.tris.toLocaleString()} tris (r=3 pinned 391 draws R40). Not a supported boot.`);
const SHADOWS = params.has('shadows') ? params.get('shadows') !== '0' : !BIG_CITY;
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
@ -204,6 +221,7 @@ const hud = createHUD({
camera, renderer, plan,
getDoorMeshes: () => chunks.getDoorMeshes(),
onEnterShop: (shopId, name) => enterShop(shopId, name),
budget: BUDGET, // [Lane B R40 §40.3] the boot's own draw/tri law — diagnostic ?r= boots warn at their pinned ceiling, not at the street law's
});
// [Lane B R39 §39.2] the minimap is built AFTER the game layer now — the fog it draws is player state
// and the game owns it (see the createMinimap call below the createGame block).
@ -222,6 +240,14 @@ if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior
// gate now asserts dig REACHABILITY on a naked boot, so un-flipping this line goes red in qa, not in a
// player's hands. ?noassets composes unchanged: the dig falls to parody canvas (existing law).
const DIG_ON = flagOn('dig');
// [Lane F R40 §40.6 — THE PLAYTEST HARNESS] Default-ON like every flipped flag (?playtest=0 opts
// out, ?classic=1 forces it off): John's bug-hunt session is a default boot and F8 must just work.
// The module is DYNAMIC-imported below the PROCITY export, so an off boot never fetches it —
// classic-gated by construction (null-provider idiom). ?bugtour=1 additionally strings the DBG
// bookmarks into the guided walk (and forces dbg.js to load — the tour drives DBG.shot/enterShop).
const PLAYTEST_ON = flagOn('playtest');
const BUGTOUR = PLAYTEST_ON && params.get('bugtour') === '1';
let playtest = null; // set by the dynamic import; the unlock/click guards below read .noteActive
// [Lane F §F2 stock=real] real GODVERSE sleeves in the crates (v2). Preload the record pack at boot so
// the first interior/dig can batch real covers; fail-soft (missing pack → parody canvas). Off under ?noassets.
const STOCK_REAL = params.get('stock') === 'real' && !NOASSETS;
@ -323,7 +349,13 @@ if (citygenMod && typeof citygenMod.createAddresses === 'function') {
const cache = (citygenMod.getTownCache ? citygenMod.getTownCache(CHAR_TOWN) : null) || townCacheJSON || null;
const a = citygenMod.createAddresses(plan, cache);
if (a && (typeof a.streetOf === 'function' || typeof a.localityOf === 'function')) {
addresses = a; minimap.setAddresses(a);
// [Lane B R40 §40.3 — FOG SIGNAGE] the locator answers "where am I standing" (plan-derived,
// zero draws); HUD street row + tooltip locality + map caption consume it. CLASSIC never
// builds it ⇒ hud.setAddresses is never called ⇒ classic's HUD/map DOM is byte-identical by
// construction (the null-provider house pattern — no flag test inside hud.js/minimap.js).
const locator = CLASSIC ? null : createStreetLocator(plan);
addresses = a; minimap.setAddresses(a, locator);
if (locator) hud.setAddresses(a, locator);
const st = a.stats ? a.stats() : null;
// `supplier` is the honest failure surface: a forgotten cache degrades VISIBLY to district
// labels ('district'), never to a wrong street name. Print it rather than assume it.
@ -538,7 +570,19 @@ function setMode(m) {
// openLate:true landmark per town (always the video rental, closes ≥22). Current hour comes from
// lighting's 6-segment clock (getClock().hour is 'HH:MM'); at night only the openLate shop is open.
function currentHour() { const [h, m] = lighting.getClock().hour.split(':').map(Number); return h + (m || 0) / 60; }
function isOpen(shop) { return shop && shop.hours && currentHour() >= shop.hours[0] && currentHour() < shop.hours[1]; }
// [Lane F R40 — A's debt D2 closed] The live predicate is now Lane A's CANONICAL isOpen (plan.js:28,
// half-open hours, NO-HOURS ⇒ OPEN — the closing-time law), imported through the barrel the shell
// already holds. The old inline predicate here read `shop && shop.hours && …`, i.e. a hours-less
// shop was PERMANENTLY CLOSED — the exact inversion of A's law, published as window.PROCITY.isOpen
// and consumed by audio.js + 19 tools call sites, all of which now inherit the canonical answer.
// Divergence is latent today (0/1,838 corpus shops lack hours — A's R40 measurement) but the first
// hours-less lot kind (carts/stalls/civic, v9+) would have flipped to "never enterable". The inline
// form survives only as the fallback for a fixture-only boot (citygen import failed ⇒ no barrel).
function isOpen(shop) {
return citygenMod && typeof citygenMod.isOpen === 'function'
? citygenMod.isOpen(shop, currentHour())
: !!(shop && shop.hours && currentHour() >= shop.hours[0] && currentHour() < shop.hours[1]);
}
// ── door → interior hand-off (Lane B seam → Lane F bridge → Lane C buildInterior) ──
function enterShop(shopId, name) {
@ -596,9 +640,9 @@ addEventListener('blur', () => keys.clear());
const startPanel = document.getElementById('pc-start');
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); });
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior' && !interiorMode.digActive && !interiorMode.sellActive) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2) or the sell card (R30 §9.4)
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior' && !interiorMode.digActive && !interiorMode.sellActive && !(playtest && playtest.noteActive)) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2), the sell card (R30 §9.4) or an F8 bug note ([Lane F R40] §40.6 — same contract)
// click the world to (re)grab the pointer when walking
canvas.addEventListener('click', () => { if (!player.isLocked && !interiorMode.digActive && !interiorMode.sellActive && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig/sell card
canvas.addEventListener('click', () => { if (!player.isLocked && !interiorMode.digActive && !interiorMode.sellActive && !(playtest && playtest.noteActive) && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig/sell card · [Lane F R40] not while typing a note
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
@ -708,6 +752,9 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
magpie, // [Lane B R38 §1.3] the magpie (null flags-off / classic) — `.state`, `.swoopPos` (pure)
minimap, discovery, addresses, // [Lane B R39 §39.2] THE FOG — `minimap.view` (span/lot px), `discovery.stats`
fogKey: FOG_KEY, knownStore, // …and the ledger itself (null flags-off / classic — the map's null arm)
budget: BUDGET, // [Lane B R40 §40.3] the boot's own draw/tri law — {draws, tris, radius, auto,
// diagnostic}; ?r= above auto ⇒ {draws: 420, diagnostic: true} + a console warn.
// qa asserts against THIS, so a harness never quotes the wrong law for the boot.
tram, // [Lane F R21] Lane B's tram — `.routeInfo` carries the R20 shop-adjacency verdict
// ({fenced, reason, shopsFronted}) + `.stops`; B built it FOR F's smoke. Null flags-off.
// [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs
@ -721,7 +768,9 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
magpie: flagOn('magpie'), ambient: flagOn('ambient'), verge: ground.verge,
// [R39 §39.2] the fog needs the game (it IS player state): ?fog=0 / ?game=0 / ?classic=1
// all land on a null store, which is the pre-R39 map by construction.
fog: FOG_ON },
fog: FOG_ON,
// [Lane F R40 §40.6] the playtest harness (F8 notes + ?bugtour) — default-on, classic off
playtest: PLAYTEST_ON },
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so
@ -730,9 +779,25 @@ import('./js/world/audio.js')
.then((m) => { window.PROCITY.audio = m.createAudioEngine(window.PROCITY, { noassets: NOASSETS }); })
.catch((e) => console.warn('[procity] audio engine load failed:', e));
// [Lane F R40 §40.6 — THE PLAYTEST HARNESS] F8 drops a bug note (full context + screenshot + typed
// one-liner; delta-law persisted, exports as one tickets-JSON); ?bugtour=1 strings the DBG bookmarks
// into the guided walk. DYNAMIC import: ?classic=1 / ?playtest=0 never fetch the module, never grow
// the DOM, never touch storage — byte-identical off, asserted by tools/qa/r40_playtest.py.
if (PLAYTEST_ON) {
import('./js/world/playtest.js')
.then((m) => {
playtest = m.createPlaytest({ plan, player, camera, canvas, renderer, composer, hud, lighting,
chunks, getMode: () => MODE, setMode, interiorMode, addresses, citySeed: plan.citySeed,
town: CHAR_TOWN, spawn: SPAWN, flags: window.PROCITY.flags });
window.PROCITY.playtest = playtest;
if (BUGTOUR) playtest.startTour();
})
.catch((e) => console.warn('[procity] playtest harness load failed:', e));
}
// [Lane B] QA debug harness (window.DBG) — loaded only with ?dbg=1 (LANE_F_NOTES §4). Logic lives
// in js/world/dbg.js; the shell just hands it the systems it owns so shots.py/soak.py can drive it.
if (params.get('dbg')) {
// [Lane F R40 §40.6] …and under ?bugtour=1, which drives DBG.shot/enterShop for its stops.
if (params.get('dbg') || BUGTOUR) {
import('./js/world/dbg.js')
.then((m) => m.installDBG({ plan, camera, renderer, composer, chunks, lighting, player, hud,
setMode, getMode: () => MODE, enterShop, interiorMode })) // [R24] interiorMode → DBG.stockInfo()

View File

@ -538,6 +538,19 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
get crewInfo() { return crewInfo; }, // [R12] { band, crowd } actually spawned — F's smokes assert on this
get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling
get sellActive() { return !!(sellCard && sellCard.active); }, // [R30 §9.4] same contract for the sell card
// [Lane F R40 §40.6] the playtest note's "where am I" — the active shop's identity, or null on the street
get shop() { return currentShop ? { id: currentShop.id, name: currentShop.name, type: currentShop.type } : null; },
// [Lane F R40 §40.6] render one frame of whatever the interior is currently showing (open crate, else the
// room) — the F8 screenshot's interior arm: the drawing buffer isn't preserved, so a capture must render
// first, and only this module knows whether the dig owns the frame. Mirrors update()'s own render calls.
renderFrame() {
if (!current) return false;
renderer.autoClear = true;
renderer.info.reset();
if (dig && dig.active) renderer.render(dig.scene, dig.camera);
else renderer.render(scene, camera);
return true;
},
// [R32 — honest-gate surface] the open bin's offers by identity (index/ask/band/sku); null when no
// dig is open. The bible/scarcity gate asserts on THESE, not on how the crate looked.
get digOffers() { return dig && dig.active ? dig.currentOffers() : null; },

377
web/js/world/playtest.js Normal file
View File

@ -0,0 +1,377 @@
// 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;
}