diff --git a/F-progress.md b/F-progress.md index 0c84747..62e4bbf 100644 --- a/F-progress.md +++ b/F-progress.md @@ -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 diff --git a/README.md b/README.md index b197c65..78261ac 100644 --- a/README.md +++ b/README.md @@ -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 45–48 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=` | **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=` | — | 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 | diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md index 2365d66..4622c98 100644 --- a/docs/LANES/LANE_F_NOTES.md +++ b/docs/LANES/LANE_F_NOTES.md @@ -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": "", "town": "", "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-.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`. diff --git a/docs/shots/laneF_r40/r40_bugtour_caption.png b/docs/shots/laneF_r40/r40_bugtour_caption.png new file mode 100644 index 0000000..2fc237b Binary files /dev/null and b/docs/shots/laneF_r40/r40_bugtour_caption.png differ diff --git a/docs/shots/laneF_r40/r40_f8_note_overlay.png b/docs/shots/laneF_r40/r40_f8_note_overlay.png new file mode 100644 index 0000000..57acb33 Binary files /dev/null and b/docs/shots/laneF_r40/r40_f8_note_overlay.png differ diff --git a/tools/flags_check.py b/tools/flags_check.py index 5931a6c..5682d59 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -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. diff --git a/tools/qa.sh b/tools/qa.sh index 8539a2c..2e317a0 100755 --- a/tools/qa.sh +++ b/tools/qa.sh @@ -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' \ diff --git a/tools/qa/r40_playtest.py b/tools/qa/r40_playtest.py new file mode 100644 index 0000000..711e5fe --- /dev/null +++ b/tools/qa/r40_playtest.py @@ -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() diff --git a/web/index.html b/web/index.html index 27f6619..0b48678 100644 --- a/web/index.html +++ b/web/index.html @@ -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: 45–48 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() diff --git a/web/js/world/interior_mode.js b/web/js/world/interior_mode.js index d2a5049..d7ae543 100644 --- a/web/js/world/interior_mode.js +++ b/web/js/world/interior_mode.js @@ -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; }, diff --git a/web/js/world/playtest.js b/web/js/world/playtest.js new file mode 100644 index 0000000..a261528 --- /dev/null +++ b/web/js/world/playtest.js @@ -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 = '🐛 BUG TOUR — every system, one walk. ' + + 'N = next stop · F8 = drop a note at any of them.'; + } else if (i >= TOUR_STOPS.length) { + tourBox.innerHTML = '🐛 BUG TOUR COMPLETE — export your notes from the F8 card (or DBG.exportNotes()).'; + } else { + const s = TOUR_STOPS[i]; + tourBox.innerHTML = `🐛 ${i + 1}/${TOUR_STOPS.length} — ${s.title}` + + `
${s.cap} · N next`; + } + } + function tourAdvance() { + if (!tour) return null; + tour.index++; + const i = tour.index; + if (i >= TOUR_STOPS.length) { tourCaption(i); tour.done = true; return { index: i, done: true }; } + const s = TOUR_STOPS[i]; + try { + leaveIfInside(); + if (s.seg != null) { lighting.setPaused(true); lighting.setSegment(s.seg); } + if (s.shot && D()) D().shot(s.shot); // the existing bookmark, verbatim + else if (s.enter && D()) { D().enterShop(s.enter); } // interior stops ride the one shop selector + if (s.run) s.run(); + } catch (e) { console.warn(`[procity playtest] tour stop ${s.id} failed:`, e && e.message || e); } + tourCaption(i); + return { index: i, id: s.id, title: s.title }; + } + function startTour() { + if (tour) return tour; + tour = { active: true, index: -1, done: false }; + tourCaption(-1); + const go = document.getElementById('pc-start'); + if (go) go.style.display = 'none'; // the tour IS the start — straight in + return tour; + } + + // ── input ──────────────────────────────────────────────────────────────────────────────────── + addEventListener('keydown', (e) => { + if (e.repeat) return; + if (e.code === 'F8') { e.preventDefault(); openNote(); } + else if (e.code === 'KeyN' && tour && !pending) tourAdvance(); + }); + + // ── api ────────────────────────────────────────────────────────────────────────────────────── + const api = { + get noteActive() { return !!pending; }, // the shell's unlock/click guards read this (the digActive contract) + get count() { return load().length; }, + list: () => load().map((n) => ({ ...n })), + dropNote: (text = '') => { // headless-driveable note (smokes; John uses F8) + const n = snapshot(); + n.text = String(text).slice(0, TEXT_CAP); + n.shot = null; + load().push(n); persist(); + return { ...n }; + }, + exportJSON: () => JSON.stringify({ schema: NOTE_SCHEMA, exported: new Date().toISOString(), + town: town || 'synthetic', citySeed: citySeed >>> 0, notes: load() }, null, 2), + exportDownload: () => { + const a = document.createElement('a'); + a.download = `procity-notes-${town || 'synthetic'}-${new Date().toISOString().slice(0, 10)}.json`; + a.href = URL.createObjectURL(new Blob([api.exportJSON()], { type: 'application/json' })); + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 5000); + if (hud && hud.showToast) hud.showToast(`🐛 ${notes.length} note(s) exported — paste the JSON back to the lanes`, 4); + return notes.length; + }, + import: (json) => { // loud reject leaves CURRENT notes untouched (adopt, not merge) + let obj; + try { obj = JSON.parse(json); } + catch (e) { console.error('[procity playtest] import REJECTED (not JSON):', e && e.message || e); return false; } + const v = validateNotes(obj); + if (!v.ok) { console.error(`[procity playtest] import REJECTED (${v.why}) — notes untouched`); return false; } + notes = v.notes; + persist(); + return true; + }, + clear: () => { notes = []; persist(); }, + openNote, saveNote, cancelNote, // driveable by the smoke (the F8 path itself is also driven raw) + startTour, tourAdvance, + get tour() { return tour ? { active: tour.active, index: tour.index, done: !!tour.done, + total: TOUR_STOPS.length, + stopId: tour.index >= 0 && tour.index < TOUR_STOPS.length ? TOUR_STOPS[tour.index].id : null } : null; }, + stops: TOUR_STOPS.map((s) => ({ id: s.id, title: s.title })), + schema: NOTE_SCHEMA, caps: { notes: NOTES_CAP, text: TEXT_CAP }, + }; + + // the DBG methods (§40.6 "a DBG method"): dbg.js is Lane B's file, so the harness binds from its + // own side — DBG may land a beat later (both are dynamic imports), hence the short retry. + const bindDBG = () => { + if (!window.DBG) return false; + window.DBG.exportNotes = api.exportJSON; + window.DBG.dropNote = api.dropNote; + window.DBG.playtestNotes = api.list; + return true; + }; + if (!bindDBG()) { let n = 0; const t = setInterval(() => { if (bindDBG() || ++n > 50) clearInterval(t); }, 200); } + + // (deliberately no stored-note count here — printing it would cost the storage read the lazy + // store exists to avoid; the count appears on the note card the moment F8 is used) + console.log('[procity] playtest harness ready — F8 drops a note; export via the note card or DBG.exportNotes()'); + return api; +}