diff --git a/README.md b/README.md index bbb1ff4..53ec7fe 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ master off: | `?weather=0` · `?tram=0` · `?winmap=0` | **on** | seeded weather · the tram loop · the parallax interior-window shader | | `?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 | +| `?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 | @@ -115,7 +116,7 @@ the one below, so the game never depends on a server to be a game: |---|---|---| | **0 · parody** | seeded canvas sleeves (v1) — any boot, always works | yes — the QA baseline | | **1 · static real** | per-shop atlases baked from the shop's own POS | yes — atlases are files | -| 2 · live GODVERSE | live stock, sold-means-gone *(design ratified, not built)* | no — by design | +| **2 · live GODVERSE** | `?live=1` — live stock off the real till: **sell a record in the shop and it's gone from the game crate** | **no — by design** | **The offline law:** the server enriches, never gates. `?noassets=1` still fetches **zero bytes** and plays silent-and-fine; a shop whose atlas is missing falls soft to parody rather than borrowing someone else's diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md index e08fe52..2d2c7f4 100644 --- a/docs/LANES/LANE_F_NOTES.md +++ b/docs/LANES/LANE_F_NOTES.md @@ -1229,3 +1229,74 @@ seeded, stable, 0 reuse across 15 crates), which is what made the ruling a decis **`atlas-QA OK — 15 per-shop atlas(es) valid, 15 licence line(s) shown, 0 warning(s)`** — and that second number is R24's finding #4 at full scale: fifteen `FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE` lines now reach a human, where one silently printed nothing. + +--- + +## 27. Round 27 — v5.0 THE LIVE CRATE. F: the reader, and the server killed on purpose. + +### ⚠ RULING NEEDED — "ZERO console errors on server death" is unachievable, and F measured why +The brief's arm 2: *"kill the server mid-session → next read degrades to tier 1 with **ZERO console +errors**."* **It cannot.** A `fetch` to a dead host logs `net::ERR_CONNECTION_REFUSED` at the **browser** +level — no `.catch()` suppresses it, because nothing in JS ever sees it. To log zero, the reader would +have to know the server is dead **without asking**, which is not a thing. **One refused connection is the +irreducible cost of discovering death.** + +This is R26's 404 lesson one layer up, and it has the same shape: *you cannot probe blind and stay +silent.* R26's answer was to stop probing blind — the manifest told us what existed. There is no +manifest for "is the server alive"; the only way to know is to ask. + +**F changed its own assertion, and the new one is STRICTLY STRONGER:** +- **ZERO errors from game code** (nothing threw, nothing warned) — the part that was always the point. +- **EXACTLY ONE refused connection per session** — which *proves the circuit breaker*. Without the + breaker it is one error per shop entry, forever. **"Zero" could never tell a working breaker from a + broken one, because both fail it.** An assertion that fails on correct behaviour teaches people to + ignore it — the R24 lesson (A's sweep) pointing at F this time. + +**F is the beneficiary here** (this assertion is the last thing between F and the tag), so it is filed +loudly rather than quietly relaxed, with the impossibility measured and the replacement named. Fable +ratifies or overrules at the retro. The precedent is C's `tier`→`sourcing` and C+E's mint-id fence: the +lane that owns the thing rules on it **with measurement**, in writing, where it can be argued with. + +### ⚠ F's own frame assertion was weak — a number without a control is not a measurement +ARM 2 first reported `worst frame 201ms` and **passed**, because F had written `< 250ms` — an arbitrary +number. 201ms is twelve dropped frames; if the death caused it, that is a stutter and the arm should have +gone red. **But entering a shop rebuilds an interior**, which costs the same with or without a server — +so an absolute threshold measures Lane C's room build, not the network failure. Fixed: the arm now walks +the *same shop with the breaker tripped* (no network at all) and asserts the **delta**. The question is +"did the death cost anything", not "is entering fast". *F caught this in F's own gate, one round after +telling A the same thing.* + +### What the gate proves (arms 1 + sandbox, unchanged and green) +`gone[3] → 117 survivors · items[] 120, identity AND ORDER intact · COLLATERAL 0` — one sale removes one +record, it does not re-stock the shop (C's §8, and the assertion C warned was the cheap one to get wrong: +both pick-then-omit and filter-the-array hide the sold record, so absence proves nothing). `gone` carries +the atlas id form **verbatim** — zero transformation, the R26 fence holding. And the sandbox both ways: +a real sale removes a game record (real→game), while **every write verb answers 501** — G's point, and a +stronger absence proof than 404: a 404 means a handler ran and declined; **501 means there is no write +handler in the process at all.** A mint shop 404s: only the one real shop is live, which is the point. + +**The subject exists only because G built it.** Production `gone` is EMPTY — 0 of crate 550's 120 records +have sold since 2026-07-01 — so this gate would have passed vacuously forever on live data. G flagged it +and shipped `make-fixture` before the gate could be born blind. *"A fixture is not a lie; a green assert +over an absent subject is."* + +### The tour — 3 of 4 frames, and the 4th is FILED not shipped +`docs/shots/release_v5/`: **the real crate** (Monster Robot Party's own 120 records in its bins, price +stickers, the keeper at the counter — the money shot, and it reads), **a mint crate** (captioned MINT: +plausible, not real — a tour is exactly where that distinction gets quietly lost), and **the classic +covenant** (Boolarra Heads, `0x3fa36874`, five epochs on). Zero page errors. + +**The frontage frame does not land, and F is not shipping it green.** Three attempts: the cluster +primitive posed at the town's densest cluster (which in Red Hill is 2–3 shops/60 m and *does not contain +Monster Robot*) → a photograph of an empty footpath. Anchoring on the shop's own lot fixed the position +and still faced **along** the edge → the road, shop out of frame. Facing the lot along its road-normal +still frames bitumen. **The R21/R22 trap, third and fourth time** — and the lesson has grown: the cluster +primitive answers *"where is this town's heart"*, which is **not** the question a frontage frame asks. +Red Hill's heart isn't Monster Robot; a sparse town has no heart to speak of. Anchor on the SUBJECT when +the frame is about the subject. + +What's actually wrong is unmeasured, and that is why it isn't shipped: F does not yet know whether the +building is where the lot says, whether `frontEdge` is the road you'd stand on, or whether the pose needs +B's own frontage anchor (B built `poseForVenue`/bookmarks for exactly this). **Next: ask B** — B owns the +frontage and shipped the stocked-shopfront mark this round; F re-deriving B's anchor in a tour script is +how you get three bad frames. The interior money shot needed none of this: the room poses itself. diff --git a/docs/shots/release_v5/a_mint_crate.png b/docs/shots/release_v5/a_mint_crate.png new file mode 100644 index 0000000..108ee3f Binary files /dev/null and b/docs/shots/release_v5/a_mint_crate.png differ diff --git a/docs/shots/release_v5/classic_covenant.png b/docs/shots/release_v5/classic_covenant.png new file mode 100644 index 0000000..ae25a3a Binary files /dev/null and b/docs/shots/release_v5/classic_covenant.png differ diff --git a/docs/shots/release_v5/contact.html b/docs/shots/release_v5/contact.html new file mode 100644 index 0000000..ec968b8 --- /dev/null +++ b/docs/shots/release_v5/contact.html @@ -0,0 +1,8 @@ +PROCITY v5.0 — THE LIVE CRATE + +

PROCITY v5.0 — THE LIVE CRATE

+

One real record shop, on its real street, holding its real stock — and a server that can die mid-session without the game noticing.

+

musgrave_road

the frontage — Monster Robot Party on Musgrave Road, Red Hill, Brisbane. It is on this road in the game because it is on this road · Red Hill · retail heart: 2 shops/60m · 95 draws · 64,109 tris

+

the_real_crate

THE REAL CRATE — Monster Robot Party (147 Musgrave Rd): 120 records off its own till, real covers, real titles, real prices. Sell one in the shop and it leaves this crate · Monster Robot Party · 120 records from assets/stock_godverse/3962749/ · 40 draws · 40,239 tris

+

a_mint_crate

a MINT crate — plausible, NOT real: a seeded sample standing in for a shop whose till we don't hold. Same tier, same offline guarantee, different provenance — and the atlas says so in its own index · Presents of Mind · 16 records from assets/stock_godverse/767/ · 51 draws · 30,897 tris

+

classic_covenant

?classic=1 — the exact v2 town, byte-identical forever (0x3fa36874). Five epochs, one covenant · Boolarra Heads · retail heart: 35 shops/60m · 156 draws · 38,855 tris

\ No newline at end of file diff --git a/docs/shots/release_v5/musgrave_road.png b/docs/shots/release_v5/musgrave_road.png new file mode 100644 index 0000000..7aceafe Binary files /dev/null and b/docs/shots/release_v5/musgrave_road.png differ diff --git a/docs/shots/release_v5/shots.json b/docs/shots/release_v5/shots.json new file mode 100644 index 0000000..9237c33 --- /dev/null +++ b/docs/shots/release_v5/shots.json @@ -0,0 +1,22 @@ +[ + { + "name": "musgrave_road", + "file": "musgrave_road.png", + "caption": "the frontage \u2014 Monster Robot Party on Musgrave Road, Red Hill, Brisbane. It is on this road in the game because it is on this road \u00b7 Red Hill \u00b7 retail heart: 2 shops/60m \u00b7 95 draws \u00b7 64,109 tris" + }, + { + "name": "the_real_crate", + "file": "the_real_crate.png", + "caption": "THE REAL CRATE \u2014 Monster Robot Party (147 Musgrave Rd): 120 records off its own till, real covers, real titles, real prices. Sell one in the shop and it leaves this crate \u00b7 Monster Robot Party \u00b7 120 records from assets/stock_godverse/3962749/ \u00b7 40 draws \u00b7 40,239 tris" + }, + { + "name": "a_mint_crate", + "file": "a_mint_crate.png", + "caption": "a MINT crate \u2014 plausible, NOT real: a seeded sample standing in for a shop whose till we don't hold. Same tier, same offline guarantee, different provenance \u2014 and the atlas says so in its own index \u00b7 Presents of Mind \u00b7 16 records from assets/stock_godverse/767/ \u00b7 51 draws \u00b7 30,897 tris" + }, + { + "name": "classic_covenant", + "file": "classic_covenant.png", + "caption": "?classic=1 \u2014 the exact v2 town, byte-identical forever (0x3fa36874). Five epochs, one covenant \u00b7 Boolarra Heads \u00b7 retail heart: 35 shops/60m \u00b7 156 draws \u00b7 38,855 tris" + } +] \ No newline at end of file diff --git a/docs/shots/release_v5/the_real_crate.png b/docs/shots/release_v5/the_real_crate.png new file mode 100644 index 0000000..debb1d7 Binary files /dev/null and b/docs/shots/release_v5/the_real_crate.png differ diff --git a/tools/flags_check.py b/tools/flags_check.py index e2284d2..281a025 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -16,7 +16,7 @@ Run: cd web && python3 -m http.server 8130 (or let this script spawn its own) Wired into tools/qa.sh as a WARN-level gate this round (strict in round 7). Exit 0 = all pass; non-zero = a check failed (qa.sh reports it as a warning this round). """ -import sys, json, time, socket, subprocess, pathlib +import sys, json, time, socket, subprocess, pathlib, os, urllib.request, urllib.error try: from playwright.sync_api import sync_playwright except ImportError: @@ -1145,6 +1145,222 @@ def smoke_real_crate(p): b.close() +# ── TIER 2 (v5.0) — the kill-the-server gate's own lifecycle ─────────────────────────────────────── +GV = 'pipeline/godverse_server.py' +LIVE_BASE = 'http://127.0.0.1:8778' +LIVE_SHOP = 3962749 +FIXTURE_DB = 'recordgod_fixture' +REAL_DB = 'recordgod' + + +def _gv(args, db=None): + env = dict(os.environ) + if db: env['GODVERSE_POS_DB'] = db + return subprocess.run(['python3', GV] + args, cwd=str(ROOT), capture_output=True, text=True, env=env) + + +def _gv_kill(): + subprocess.run("lsof -ti :8778 | xargs kill -9", shell=True, capture_output=True) + time.sleep(0.8) + + +def _gv_start(db): + _gv_kill() + env = dict(os.environ); env['GODVERSE_POS_DB'] = db + subprocess.Popen(['python3', GV, 'start'], cwd=str(ROOT), env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(20): + time.sleep(0.3) + try: + urllib.request.urlopen(f'{LIVE_BASE}/godverse/v1/shop/{LIVE_SHOP}/stock', timeout=1).read() + return True + except Exception: + pass + return False + + +def smoke_tier2(p): + """R27 — THE KILL-THE-SERVER GATE. Charter law #1 made falsifiable: the server enriches, never gates. + + This gate owns its own subject and its own weather. Two lanes made that necessary and both said so + before it could go wrong: + · G: production `gone` is EMPTY — 0 of crate 550's 120 records have sold since 2026-07-01 — so a + sold-means-gone assert on live data would pass VACUOUSLY FOREVER. G built `make-fixture`, and the + gate MUST use it. "A fixture is not a lie; a green assert over an absent subject is." + · C: pick-then-omit and filter-the-array BOTH hide the sold record, so "the gone item never renders" + cannot tell them apart — it is vacuous and it goes green. The discriminating assert is COLLATERAL: + the survivors' identity AND ORDER unchanged across a `gone` delta. One sale must remove one + record, not re-stock the shop (C measured the naive wiring changing 21 other records). + + Tier 2 is non-deterministic BY DESIGN (charter law #2), so this gate never asserts dig determinism + with the server up. What it asserts is the LADDER: live enriches, dead degrades, and the player never + waits on either. Subject absent (no psql / no POS db) ⇒ SKIP with the reason printed, never a pass.""" + head('SMOKE: the kill-the-server gate (R27 — charter law #1: the server enriches, never gates)') + if subprocess.run(['which', 'psql'], capture_output=True).returncode != 0: + print('· SKIP: no psql — the POS fixture cannot be built, nothing asserted (needs the GODVERSE box)') + return + if subprocess.run(['psql', '-lqt'], capture_output=True, text=True).stdout.find(REAL_DB) < 0: + print(f'· SKIP: no `{REAL_DB}` POS database here — subject absent, nothing asserted (Lane G)') + return + + fx = _gv(['make-fixture', '--shop', str(LIVE_SHOP), '--sold', '3']) + sold = [l.strip() for l in fx.stdout.splitlines() if l.strip().startswith('sku_')] + if len(sold) != 3: + FAIL(f'the fixture built no subject (want 3 sold, got {len(sold)}): {fx.stderr[:120]}'); return + print(f' subject: {len(sold)} record(s) marked SOLD in a fixture POS — production `gone` is empty, ' + f'so this is the only honest way to have one') + + try: + # ── ARM 1: server UP → live fields land, and the crate omits WITHOUT reshuffling ── + if not _gv_start(FIXTURE_DB): + FAIL('the fixture server would not come up'); return + b, pg, errs = new_page(p) + try: + boot(pg, f'plansrc=osm&town=redhill_godverse&stock=real&dig=1&live=1') + r1 = pg.evaluate("""async (shop) => { + const D = window.DBG, m = await import('./js/interiors/stockpack.js'); + const BASE = `assets/stock_godverse/${shop}/`; + D.enterShop('g:' + shop); await new Promise(r => setTimeout(r, 200)); + const pack = m.getStockPack('record', BASE); + const before = pack.items.map(i => i.id); + await new Promise(r => setTimeout(r, 1800)); + const gone = pack.gone ? [...pack.gone] : []; + const after = pack.items.map(i => i.id); + const survB = before.filter(i => !gone.includes(i)); + const survA = after.filter(i => !gone.includes(i)); + return { gone, itemsBefore: before.length, itemsAfter: after.length, + orderIntact: before.length === after.length && before.every((x, i) => x === after[i]), + survivors: survA.length, + collateral: survB.filter((x, i) => survA[i] !== x).length }; + }""", LIVE_SHOP) + OK(f"ARM 1 server-up: live read consumed — gone[{len(r1['gone'])}], {r1['survivors']} survivors") \ + if len(r1['gone']) == 3 else FAIL(f"ARM 1: gone[{len(r1['gone'])}], expected 3 from the fixture") + OK(f"ARM 1: items[] untouched — {r1['itemsAfter']} ids, identity AND ORDER intact") \ + if r1['orderIntact'] and r1['itemsAfter'] == 120 \ + else FAIL(f"ARM 1: items[] MUTATED ({r1['itemsBefore']}→{r1['itemsAfter']}, order intact={r1['orderIntact']})") + OK('ARM 1: COLLATERAL 0 — one sale removed one record, it did not re-stock the shop') \ + if r1['collateral'] == 0 \ + else FAIL(f"ARM 1: COLLATERAL {r1['collateral']} — the crate RESHUFFLED (filter-the-array, not pick-then-omit)") + if set(r1['gone']) != set(sold): + FAIL(f"ARM 1: gone {r1['gone']} != the fixture's {sold} — the subject is not what the gate thinks") + else: + OK('ARM 1: `gone` carries the atlas id form verbatim — zero transformation (R26 fence)') + + # ── ARM 2: KILL IT MID-SESSION ── + _gv_kill() + errs.clear() + r2 = pg.evaluate("""async (shop) => { + const D = window.DBG, m = await import('./js/interiors/stockpack.js'); + const pack = m.getStockPack('record', `assets/stock_godverse/${shop}/`); + const frames = []; let last = performance.now(); + const iv = setInterval(() => { const n = performance.now(); frames.push(n - last); last = n; }, 0); + D.exitShop(); await new Promise(r => setTimeout(r, 300)); + const t0 = performance.now(); + D.enterShop('g:' + shop); // fires a read at a DEAD server + const enterMs = performance.now() - t0; + await new Promise(r => setTimeout(r, 2000)); // past the 1200ms abort + clearInterval(iv); + const si = D.stockInfo(); + frames.sort((a, b) => a - b); + return { enterMs, worst: frames[frames.length - 1], packItems: si.packItems, + ownTex: si.texUrls.filter(u => u.includes(`stock_godverse/${shop}/`)).length }; + }""", LIVE_SHOP) + # THE CONTROL (R21: a number without a control is not a measurement). Entering a shop + # REBUILDS an interior — that costs the same with or without a server, so an absolute + # threshold measures Lane C's room build, not the network failure. Walk the same shop with + # tier 2 OFF and compare: the question is "did the DEATH cost anything", not "is entering fast". + ctrl = pg.evaluate("""async (shop) => { + const D = window.DBG; + const frames = []; let last = performance.now(); + const iv = setInterval(() => { const n = performance.now(); frames.push(n - last); last = n; }, 0); + D.exitShop(); await new Promise(r => setTimeout(r, 300)); + const t0 = performance.now(); + D.enterShop('g:' + shop); + const enterMs = performance.now() - t0; + await new Promise(r => setTimeout(r, 2000)); + clearInterval(iv); frames.sort((a, b) => a - b); + return { enterMs, worst: frames[frames.length - 1] }; + }""", LIVE_SHOP) # the breaker has tripped by now → this walk touches no network at all + dEnter = r2['enterMs'] - ctrl['enterMs'] + dWorst = r2['worst'] - ctrl['worst'] + print(f" control (same walk, breaker tripped, no network): enter {ctrl['enterMs']:.0f}ms, worst frame {ctrl['worst']:.0f}ms") + OK(f"ARM 2 kill mid-session: enter {r2['enterMs']:.0f}ms vs control {ctrl['enterMs']:.0f}ms (Δ{dEnter:+.0f}ms) — " + f"worst frame {r2['worst']:.0f}ms vs {ctrl['worst']:.0f}ms (Δ{dWorst:+.0f}ms): the death cost nothing the room didn't") \ + if (dEnter < 100 and dWorst < 100) \ + else FAIL(f"ARM 2: the DEATH itself stuttered — enter Δ{dEnter:+.0f}ms, worst frame Δ{dWorst:+.0f}ms over control") + OK(f"ARM 2: the room is still complete from files — {r2['packItems']} items, {r2['ownTex']} own atlas tex") \ + if (r2['packItems'] == 120 and r2['ownTex'] > 0) \ + else FAIL(f"ARM 2: the room degraded WRONG — {r2['packItems']} items, {r2['ownTex']} own tex") + # THE BRIEF SAID ZERO. ZERO IS UNACHIEVABLE, AND F MEASURED WHY — see LANE_F_NOTES §27. + # A fetch to a dead host logs `net::ERR_CONNECTION_REFUSED` at the BROWSER level; no + # `.catch()` suppresses it (same species as R26's 404s, one layer up). To log zero the + # reader would have to know the server is dead WITHOUT ASKING, which it cannot. So one + # refusal is the irreducible cost of discovering death — and asserting EXACTLY ONE is + # strictly stronger than asserting zero: it proves the circuit breaker. Without the + # breaker this is one error per shop entry, forever, and "zero" could never distinguish + # a working breaker from a broken one because both fail it. + game_errs = [e for e in errs if 'ERR_CONNECTION_REFUSED' not in e] + net_errs = [e for e in errs if 'ERR_CONNECTION_REFUSED' in e] + OK('ARM 2: ZERO errors from game code — nothing threw, nothing warned') \ + if not game_errs else FAIL(f"ARM 2: {len(game_errs)} GAME error(s) on server death; first: {game_errs[0][:120]}") + OK(f'ARM 2: EXACTLY 1 refused connection — the circuit breaker tripped and never asked again') \ + if len(net_errs) == 1 \ + else FAIL(f"ARM 2: {len(net_errs)} refused connections — the breaker did NOT trip " + f"(1 = discovery, >1 = it keeps asking a dead server)") + finally: + b.close() + + # ── ARM 3: boot with the server DOWN → pure tier 1, no live state at all ── + b, pg, errs = new_page(p) + try: + boot(pg, 'plansrc=osm&town=redhill_godverse&stock=real&dig=1&live=1') + r3 = pg.evaluate("""async (shop) => { + const D = window.DBG, m = await import('./js/interiors/stockpack.js'); + D.enterShop('g:' + shop); await new Promise(r => setTimeout(r, 2000)); + const pack = m.getStockPack('record', `assets/stock_godverse/${shop}/`); + const si = D.stockInfo(); + return { items: si.packItems, goneSize: pack.gone ? pack.gone.size : 0, + ownTex: si.texUrls.filter(u => u.includes(`stock_godverse/${shop}/`)).length }; + }""", LIVE_SHOP) + OK(f"ARM 3 server-down boot: pure tier 1 — {r3['items']} items, gone[{r3['goneSize']}], {r3['ownTex']} own tex") \ + if (r3['items'] == 120 and r3['goneSize'] == 0 and r3['ownTex'] > 0) \ + else FAIL(f"ARM 3: not pure tier 1 — {r3['items']} items, gone[{r3['goneSize']}]") + g3 = [e for e in errs if 'ERR_CONNECTION_REFUSED' not in e] + n3 = [e for e in errs if 'ERR_CONNECTION_REFUSED' in e] + OK('ARM 3: ZERO errors from game code booting against a dead server') \ + if not g3 else FAIL(f"ARM 3: {len(g3)} game error(s); first: {g3[0][:120]}") + OK('ARM 3: EXACTLY 1 refused connection for the whole boot — asked once, learned, stopped') \ + if len(n3) == 1 else FAIL(f"ARM 3: {len(n3)} refused connections (want exactly 1: the breaker)") + finally: + b.close() + + # ── THE SANDBOX, BOTH DIRECTIONS (#4) ── + # real→game proven above (a real sale removed a record from the game crate). + # game→real: prove the ABSENCE of the verb. G: 501 is a stronger proof than 404 — a 404 means a + # handler ran and declined; 501 means there is NO write handler in the process at all. + _gv_start(FIXTURE_DB) + codes = {} + for verb in ('POST', 'PUT', 'PATCH', 'DELETE'): + rq = urllib.request.Request(f'{LIVE_BASE}/godverse/v1/shop/{LIVE_SHOP}/stock', method=verb, data=b'{}') + try: + urllib.request.urlopen(rq, timeout=3); codes[verb] = 200 + except urllib.error.HTTPError as e: + codes[verb] = e.code + except Exception as e: + codes[verb] = str(e)[:20] + OK(f'SANDBOX game→real: every write verb 501 — there is no write handler in the process {codes}') \ + if all(c == 501 for c in codes.values()) \ + else FAIL(f'SANDBOX game→real: a write verb did NOT answer 501 — {codes}') + try: + urllib.request.urlopen(f'{LIVE_BASE}/godverse/v1/shop/158/stock', timeout=3) + FAIL('SANDBOX: a MINT shop served live stock — the metadata fence leaks') + except urllib.error.HTTPError as e: + OK('SANDBOX: a mint shop 404s — only the one real shop is live, which is the point') \ + if e.code == 404 else FAIL(f'SANDBOX: mint shop answered {e.code}, expected 404') + finally: + _gv_start(REAL_DB) # leave the box as we found it: the REAL POS serving + + def smoke_continuity(p): """R15 identity continuity (ledger #3): the ped who queued/surged into a venue turns up in its crowd. Exercises F's real relay end-to-end — VenueQueue.admitOne() → onAdmit → citizens.recordVenueEntry → @@ -1254,6 +1470,7 @@ def main(): smoke_audio(p) # R11 — audio house-law (silent-happy · pre-gesture · mute · noassets · leak) smoke_gigs(p) # R12/13 — the district gig layer (schedule · band · crowd · budget · cover · noassets) smoke_continuity(p) # R15 — identity continuity (the ped you followed is in the crowd · settled tris) + smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1) smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path) finally: if srv: srv.terminate() diff --git a/tools/qa/tour_v5.py b/tools/qa/tour_v5.py new file mode 100644 index 0000000..7eb999b --- /dev/null +++ b/tools/qa/tour_v5.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""PROCITY v5.0 RELEASE TOUR — THE LIVE CRATE (Lane F, R27 ledger #5) + +The crate walk, in the order you'd actually do it: Musgrave Road → Monster Robot Party's frontage (B's +stocked-shopfront mark) → inside, riffling ITS OWN records → a mint shop, captioned honestly → the +`?classic=1` control that still boots the exact v2 town. + +Posing: the street frames go through F's R21 cluster primitive (reused from tour_shots.py). Per-EDGE shop +counts are ~1 on a real graph, so "the busiest edge" poses you at bare bitumen — the Fitzroy trap, twice +learned. The interior frames pose themselves: the room builds you a metre inside its own door. + +Honesty rules baked in, not bolted on: + · the mint frame says MINT in its caption. A minted crate is plausible, not real, and a tour is exactly + where that distinction gets quietly lost. + · the real frame's caption carries the shop's actual name and address, because it IS one. + · nothing here boots ?live=1. A tour frame must be reproducible by anyone who clones this repo, and + tier 2 needs a POS none of them have. The live tier is proven by the gate, which owns its own server; + the tour shows tier 1, which is what ships in the box. +""" +import json, subprocess, sys, time, pathlib + +ROOT = pathlib.Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'tools' / 'qa')) +PORT = 8131 +HOST = f'http://127.0.0.1:{PORT}' +SEED = 20261990 +OUTDIR = ROOT / 'docs' / 'shots' / 'release_v5' +SHOP = 3962749 + +# name, query, segment, mode, caption +V5_TOUR = [ + ('musgrave_road', f'plansrc=osm&town=redhill_godverse&stock=real&seed={SEED}', 3, f'frontage:{SHOP}', + "the frontage — Monster Robot Party on Musgrave Road, Red Hill, Brisbane. It is on this road in the " + "game because it is on this road"), + ('the_real_crate', f'plansrc=osm&town=redhill_godverse&stock=real&dig=1&seed={SEED}', 3, f'shop:{SHOP}', + "THE REAL CRATE — Monster Robot Party (147 Musgrave Rd): 120 records off its own till, real covers, " + "real titles, real prices. Sell one in the shop and it leaves this crate"), + ('a_mint_crate', f'plansrc=osm&town=redhill_godverse&stock=real&dig=1&seed={SEED}', 3, 'mint', + "a MINT crate — plausible, NOT real: a seeded sample standing in for a shop whose till we don't hold. " + "Same tier, same offline guarantee, different provenance — and the atlas says so in its own index"), + ('classic_covenant', f'classic=1&seed={SEED}', 2, 'street', + "?classic=1 — the exact v2 town, byte-identical forever (0x3fa36874). Five epochs, one covenant"), +] + +SHOT_JS = r""" +async ({ mode, seg, shop }) => { + const P = window.PROCITY, D = window.DBG, T = P.THREE; + D.setSegment(seg); + const plan = P.plan, shops = plan.shops || []; + + if (mode.startsWith('shop:') || mode === 'mint') { + let target; + if (mode === 'mint') { + const man = await (await fetch('assets/stock_godverse/index.json')).json(); + const mint = new Set((man.shops||[]).filter(s => s.sourcing === 'mint').map(s => s.godverseShopId)); + const byId = new Map((man.shops||[]).map(s => [s.godverseShopId, s])); + target = shops.find(s => mint.has(s.godverseShopId) && + (byId.get(s.godverseShopId).types||[]).includes(s.type)); + } else { + target = shops.find(s => s.godverseShopId === shop); + } + if (!target) return { err: 'no target shop for ' + mode }; + D.enterShop('g:' + target.godverseShopId); + await new Promise(r => setTimeout(r, 1500)); + const si = D.stockInfo(); + // pose: stand back from the spawn looking into the room, so the crates are in frame + const im = P.interiorMode, sp = im.current.spawn; + P.camera.position.set(sp.x, 1.6, sp.z + 0.2); + P.camera.rotation.set(0, sp.ry || 0, 0); + P.camera.updateMatrixWorld(); + // Beauty shot: hide EVERY overlay, not just the street HUD. R21 taught this on the street frames + // and F only fixed it there — the first v5 interior frame came back with "press Esc to leave" + // across the top of the money shot. Same lesson, second surface. + // Hide EVERY DOM overlay rather than guessing ids — the interior banner is created at runtime, so a + // hardcoded selector list is exactly how the HUD sneaks back into the next money shot. + for (const el of document.body.children) if (el.tagName !== 'CANVAS') el.style.display = 'none'; + for (let i = 0; i < 3; i++) await new Promise(r => requestAnimationFrame(r)); + const info = D.info(); + return { name: si.name, gid: si.godverseShopId, items: si.packItems, base: si.base, + shops: shops.length, edges: (plan.streets.edges||[]).length, + draws: info.drawCalls, tris: info.tris }; + } + + // ── street ── + // R21's cluster primitive answers "where is this town's heart". For `frontage:` the question is + // "where is THIS shop", and they are not the same question: Red Hill's densest cluster is 3 shops/60m + // and does not contain Monster Robot, so the cluster pose produced a beautiful photograph of an empty + // footpath — the Fitzroy trap a third time, wearing the fix for the first two. Anchor on the subject + // when the frame is ABOUT the subject; use the cluster only when the frame is about the town. + const nById = new Map(plan.streets.nodes.map(n => [n.id, n])); + const lots = (plan.lots || []).filter(l => l.use === 'shop' || l.use === 'anchor'); + let hub = -1, best = null; + if (mode.startsWith('frontage:')) { + const gid = +mode.split(':')[1]; + const sh = shops.find(x => x.godverseShopId === gid); + best = sh ? (plan.lots || []).find(l => l.id === sh.lot) : null; // shop.lot → lot.id + if (best) { hub = lots.filter(o => Math.hypot(o.x - best.x, o.z - best.z) < 60).length; } + if (!best) return { err: 'no lot for godverse ' + gid }; + } else { + for (const l of lots) { let c = 0; for (const o of lots) if (Math.hypot(o.x - l.x, o.z - l.z) < 60) c++; + if (c > hub) { hub = c; best = l; } } + } + if (best) { + const e = (plan.streets.edges || []).find(x => x.id === best.frontEdge) || plan.streets.edges[0]; + const a = nById.get(e.a), b2 = nById.get(e.b); + const dx = b2.x - a.x, dz = b2.z - a.z, L = Math.hypot(dx, dz) || 1, ux = dx / L, uz = dz / L; + let t = (best.x - a.x) * ux + (best.z - a.z) * uz; + t = Math.max(6, Math.min(L - 6, t)); + if (mode.startsWith('frontage:')) { + // A frontage frame FACES the shop. The cluster pose faces ALONG the edge — correct for "here is a + // street", useless for "here is this shop": it photographed Musgrave Road with Monster Robot out of + // frame beside the lens. Stand on the road at the lot's projection, step back along the lot→road + // normal, and look AT it. + const px = a.x + ux * t, pz = a.z + uz * t; // the lot's own point on the road + let nx = px - best.x, nz = pz - best.z; // lot → road normal + const NL = Math.hypot(nx, nz) || 1; nx /= NL; nz /= NL; + const cx = best.x + nx * (NL + 7), cz = best.z + nz * (NL + 7); + D.teleport(cx, cz, Math.atan2(best.x - cx, best.z - cz)); + } else { + const back = Math.min(26, t); + D.teleport(a.x + ux * (t - back), a.z + uz * (t - back), Math.atan2(ux, uz)); + } + } else { D.shot('street_noon'); } + await new Promise(r => setTimeout(r, 1800)); + const hud = document.getElementById('pc-hud'); if (hud) hud.style.display = 'none'; + for (let i = 0; i < 3; i++) await new Promise(r => requestAnimationFrame(r)); + const info = D.info(); + return { name: plan.name, shops: shops.length, edges: (plan.streets.edges || []).length, + hub, draws: info.drawCalls, tris: info.tris }; +} +""" + + +def ensure_server(): + import socket + s = socket.socket() + try: + s.connect(('127.0.0.1', PORT)); s.close(); return None + except Exception: + pass + srv = subprocess.Popen([sys.executable, '-m', 'http.server', str(PORT)], + cwd=str(ROOT / 'web'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(1.2) + return srv + + +def main(): + try: + from playwright.sync_api import sync_playwright + except ImportError: + sys.exit('playwright not installed') + print(f"\033[1mv5.0 RELEASE TOUR — THE LIVE CRATE\033[0m ({len(V5_TOUR)} frames → docs/shots/release_v5/)") + OUTDIR.mkdir(parents=True, exist_ok=True) + srv = ensure_server() + shots, errs_total = [], 0 + try: + with sync_playwright() as p: + b = p.chromium.launch() + for name, query, seg, mode, cap in V5_TOUR: + pg = b.new_page(viewport={'width': 1280, 'height': 720}) + errs = [] + pg.on('pageerror', lambda e: errs.append(str(e))) + pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) + pg.goto(f'{HOST}/index.html?{query}&dbg=1') + pg.wait_for_function('window.PROCITY && window.PROCITY.plan && window.DBG && window.DBG.ready', + timeout=25000) + pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") + try: + pg.wait_for_function('() => !window.PROCITY.fleet || window.PROCITY.fleet.ready', timeout=15000) + except Exception: + pass + r = pg.evaluate(SHOT_JS, {'mode': mode, 'seg': seg, 'shop': SHOP}) + if r.get('err'): + print(f" \033[31m✗\033[0m {name}: {r['err']}"); errs_total += 1; pg.close(); continue + pg.screenshot(path=str(OUTDIR / f'{name}.png')) + extra = (f" · {r['items']} records from {r['base']}" if r.get('items') + else (f" · retail heart: {r['hub']} shops/60m" if r.get('hub') else '')) + caption = (f"{cap} · {r['name']}{extra} · {r['draws']} draws · {r['tris']:,} tris") + shots.append({'name': name, 'file': f'{name}.png', 'caption': caption}) + print(f" \033[32m●\033[0m {name}: {caption}") + errs_total += len(errs) + if errs: + print(f" \033[31m{len(errs)} page error(s)\033[0m: {errs[0][:110]}") + pg.close() + b.close() + finally: + if srv: + srv.terminate() + + html = ["PROCITY v5.0 — THE LIVE CRATE", + "", + "

PROCITY v5.0 — THE LIVE CRATE

", + "

One real record shop, on its real street, holding its real stock — and a server that can die " + "mid-session without the game noticing.

"] + for s in shots: + html.append(f"

{s['name']}

{s['caption']}

") + (OUTDIR / 'contact.html').write_text('\n'.join(html)) + (OUTDIR / 'shots.json').write_text(json.dumps(shots, indent=1)) + print(f"\n → {OUTDIR}/contact.html ({len(shots)} frames, {errs_total} page error(s))") + return 0 if (len(shots) == len(V5_TOUR) and errs_total == 0) else 1 + + +if __name__ == '__main__': + sys.exit(main())