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:
parent
cf6ac64d75
commit
c3384b92c9
@ -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
|
||||
|
||||
@ -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=<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 |
|
||||
|
||||
@ -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`.
|
||||
|
||||
BIN
docs/shots/laneF_r40/r40_bugtour_caption.png
Normal file
BIN
docs/shots/laneF_r40/r40_bugtour_caption.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 437 KiB |
BIN
docs/shots/laneF_r40/r40_f8_note_overlay.png
Normal file
BIN
docs/shots/laneF_r40/r40_f8_note_overlay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 794 KiB |
@ -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.
|
||||
|
||||
24
tools/qa.sh
24
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' \
|
||||
|
||||
290
tools/qa/r40_playtest.py
Normal file
290
tools/qa/r40_playtest.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
@ -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
377
web/js/world/playtest.js
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user