Lane B R40 §40.3 (+E's §40.4 rule): the 307 was fiction, the town speaks its street names

THE ?r=3 BREACH — the carried number was stale by 84 draws. Re-measured with the pin
method of record (stepwalk 2m, 802 stations, fresh headless contexts, no-store server):
default 282/291 (≤300, 9 margin at night) · classic 269 byte-exact to the R38 pin incl.
90,580 tris · ?r=3 382/391, NOT 307. Decision: GATE, not shave — the 91-draw excess is
the R+1 live-chunk window (48 vs 31 chunks) on a per-chunk cost already collapsed to
~1 draw/kind/chunk, so shaving it means cutting default-boot content to legalise a
diagnostic. ?r= above auto now declares PROCITY.budget {draws:420, diagnostic:true} and
console-warns its own law; HUD threshold + DBG.info().budget read it; new
tools/qa/r40_lane_b.py enforces it (386 ≤ 420, and >300 so the exemption is provably
load-bearing).

?classic=1 TOWN SELECTOR (Fable ruled): true by construction, now deliberate and
qa-asserted — 27 options, one fetch (the named POST_V2_EXCEPTION), picks stay classic,
zero draws, 0 errors.

FOG SIGNAGE: three silent surfaces consume A's address layer via new
createStreetLocator(plan) — HUD street row, door tooltip ('Little Paris Cafe ·
Katoomba Street'), fog-map caption. Never branches on town type; classic-gated by
construction (#pc-street absent, tooltip pre-R40-byte).

E's arcade rule applied verbatim (§40.4): roof spans, a spanned roof has no posts;
district.kind keying, explicit ARCADEKIT classic gate + ?arcadekit=0 control.
-34 post instances exactly, lane draws 120→120 (+0). Shot pair vs E's reference.

Goldens 157,647/157,647, 0x5f76e76 unmoved after every wave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-07 12:28:08 +10:00
parent dab5ecd273
commit 066cc1775b
12 changed files with 516 additions and 18 deletions

View File

@ -4,6 +4,57 @@
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
`generatePlan` (which landed mid-session). Everything below was verified live in a browser.
## Round 40 (§40.3) — the ?r= breach GATED at its true number, classic's selector ratified, the fog signed — plus §40.4's arcade kit (Fable-routed)
Full measurement detail in `docs/LANES/LANE_B_NOTES.md` §40. Everything below: today's tree,
headless fresh contexts, no-store servers, pin method (stepwalk 2 m · yaw 0 · laps 2 · 802 stations).
**1. THE ?r=3 BREACH — re-measured, then GATED (option b), because the carried 307 was fiction.**
| mode | before (carried) | re-measured (pin method, final tree) | law now |
|---|---|---|---|
| default noon / night | 282 / 292 (R38 pin) | **282 / 291** ✅ pin reproduced | ≤300 (unchanged) |
| `?classic=1` night | 269 | **269 / 90,580 tris — byte-exact** | ≤300 (unchanged) |
| `?r=3` noon / night | "307 / 317" (stale bookmark) | **382 / 391** at 4548 live chunks | **≤420, its OWN declared law** |
Decision: **gate, not shave** — the 91-draw excess is the R+1 live-chunk window (48 vs 31) on a
per-chunk cost already collapsed to ~1 draw/kind/chunk; a shave that size is a content cut to the
DEFAULT town to legalise a diagnostic. So: `?r=` above auto now declares
`PROCITY.budget = {draws: 420, tris: 200k, diagnostic: true}`, console-warns its law, the HUD warn
threshold reads it, `DBG.info().budget` carries it, and **NEW `tools/qa/r40_lane_b.py`** asserts it
with both arms non-vacuous (measures 386 ≤ 420 at 8 m stations AND 386 > 300, so the exemption is
load-bearing; ≤300 someday ⇒ "retire the exemption", printed not failed). Ceiling 420 = 391 + ~7%
jitter margin; growth past it goes red and is re-pinned consciously. → F: one qa.sh line; README
`?r=N` row still says 307.
**2. ?classic=1 TOWN SELECTOR (Fable ruled yes) — verified true by construction, made deliberate.**
Classic boots with the selector present + visible, **all 27 options** (3 fixtures + 23 real/godverse
towns), one towns-fetch = exactly the named POST_V2_EXCEPTION (`assets/towns/index.json`), picks
FROM classic stay classic (query string preserved), zero draws (DOM chrome), 0 errors. Ruling
written into the selector block (hud.js) and asserted in r40_lane_b.py. → F: the flags_check
POST_V2_EXCEPTIONS label still calls this a debt-ledger question; it's ruled now.
**3. FOG SIGNAGE — three surfaces that said nothing now consume A's layer** (`street || label`,
null handled once, no town-type branch): **HUD `street` row** ("Lurline Street" on katoomba_real,
supplier `ways`; "the south end of the main street" on the synthetic) · **door tooltip**
("🚪 Little Paris Cafe · Katoomba Street — click to enter") · **fog-map caption**
("… 2 streets walked · Lurline Street · press M to close"). New `createStreetLocator(plan)` in
discovery.js (the probe's hash, read-only; signage ≠ discovery, so not fog-keyed); rides the
existing frame%6 throttles; zero draws. **Classic-gated by construction**: locator built only
`!CLASSIC`, so `#pc-street` doesn't exist in classic's DOM and the tooltip is the pre-R40 byte —
A/B'd on the same door, asserted both arms in r40_lane_b.py.
**4. §40.4 ARCADE KIT (routed mid-round by Fable, E's spec applied verbatim):** roof spans (depth
2.5 = half-lane, slabs meet flush), **a spanned roof has no posts**; `ctx.arcadeBlocks` keyed on
`district.kind` never `edge.kind`; explicit `ARCADEKIT` gate (classic forces off — mandatory;
`?arcadekit=0` = falsifiability control). Measured: **34 post instances exactly, lane draws
120 → 120 (+0)**, shot pair `docs/shots/laneB_r40/`, classic pin cell byte-exact after the change.
Goldens **157,647/157,647, 0x5f76e76 unmoved** (selfcheck after every wave). r40_lane_b gate:
**all assertions green**. Files: `web/index.html` (`[Lane B R40]` marks), `hud.js`, `minimap.js`,
`discovery.js`, `dbg.js`, `chunks.js`, `buildings.js`, new `tools/qa/r40_lane_b.py`,
`docs/shots/laneB_r40/`, these docs. **No git commands run** (lane treaty; Fable commits).
## Round 39 (v9 Layer 2 — THE FOG, item 39.2) — **zero draws, and the map is finally readable**
Full detail + every number in LANE_B_NOTES §39. `minimap.js` drew all 493 synthetic shops from the

View File

@ -1,5 +1,127 @@
# LANE B — NOTES (measured)
## Round 40 (§40.3 + routed §40.4) — the ?r= flag learns its own law, classic keeps its shop window, and the fog gets its signage
All numbers this section: today's tree, headless Chromium, fresh contexts, port-isolated no-store
servers, 1280×720, seed 20261990 unless a town is named. Budget cells use **the pin method of
record** (R37/R38: `budget_walk.py --method stepwalk --stepm 2 --fixed-yaw 0 --laps 2` = 802
stations down the main spine at yaw 0, the worst bearing).
### §40.3-1 — THE ?r=3 BREACH: re-measured 391, and the ruling is GATE, not shave
**The carried "307" was a stale bookmark, as the round suspected — the true number is 391.** The
R37-era 307/317 pin predates five rounds of default-on content; nobody had re-measured the override
since. On today's tree, same method as the budget of record:
| synthetic, pin method | noon (seg 2) | NIGHT (seg 5) | live chunks max | tris worst |
|---|---|---|---|---|
| default (`r` auto = 2) | **282** | **291** | 32 | 120,093 ✅ pin reproduced (R38: 282/292) |
| `?classic=1` | — | **269** | 32 | 90,580 ✅ **byte-exact to the R38 pin** |
| **`?r=3`** | **382** | **391** | **48** | 132,829 (tris legal) |
**Why shave was refused, with the numbers:** the breach is structural, not fat. `?r=3` carries
4548 live chunks against the default's 31 (R+1 dispose window over a 7×7 vs 5×5 set), and the
excess is 91100 draws. Lane B's per-chunk cost is already collapsed to ~1 draw per kind per chunk
(facade atlas, merged instancing — R2 close-out); there is no 91-draw shave available in a
diagnostic boot that is not a content cut to the DEFAULT town. Shaving a diagnostic override by
making the shipped town poorer is backwards.
**So the flag now documents its own budget law and qa asserts it (the round's option b):**
- `index.html` (`[Lane B R40]`, the RADIUS block): any `?r=` **above** the auto radius declares
`PROCITY.budget = { draws: 420, tris: 200000, radius, auto, diagnostic: true }` and prints a
`console.warn` naming the law ("DIAGNOSTIC boot … ≤420 draws … not a supported boot"). At or
below auto: `{ draws: 300, diagnostic: false }` — the street law, unchanged.
- **The ceiling is 420** = 391 measured + ~7% for crowd/streamer jitter (the R37 lesson: settle
policy and station phase move a walked worst by single digits). If the town grows past it, the
gate goes red and the ceiling is re-pinned CONSCIOUSLY — that is what a pinned law is for.
- `hud.js` warn threshold reads the boot's own law (`budget.draws`), so the readout stops shaming a
documented diagnostic at 300 and starts telling the truth at 420. Default boots render identical
DOM (same 300).
- `dbg.js` `info().budget` carries the law, so **a harness asserts against the boot's declared
ceiling, never a hardcoded 300**.
- **`tools/qa/r40_lane_b.py` (NEW file — races no lane) is the enforcing gate**, all arms
non-vacuous: default declares 300/not-diagnostic · `?r=3` declares 420/diagnostic + the warn
fired · a night stepwalk under `?r=3` measures ≤ its own ceiling (read 386 ≤ 420 at 8 m
stations) **and** > 300 (the exemption is load-bearing, not vacuous — if it ever reads ≤300 the
gate prints "retire the exemption" rather than failing) · tri law unchanged (131,893 ≤ 200k) ·
0 console errors. **→ Lane F: one line wires it into qa.sh** — and README.md's `?r=N` row still
quotes 307; please update it to the law (391 measured R40, ceiling 420, gated + warned).
### §40.3-2 — CLASSIC'S TOWN SELECTOR: the ruling was already true by construction; now it is deliberate and gated
Fable ruled classic SHOWS the selector ("information, not gameplay state"). Verified on today's
tree: `?classic=1` boots with the selector **present and visible, all 27 options** (synthetic + 3
OSM fixtures + the 23-town real/godverse pack), its towns-fetch surface is **exactly the one named
POST_V2_EXCEPTION** (`assets/towns/index.json`, Fable's R37 ruling), navigation preserves the query
string so a town picked FROM classic STAYS classic, **zero draws** (a `<select>` the renderer never
sees — draws at spawn 148 with it, and `renderer.info` cannot see DOM), zero console errors. The
ruling is now written INTO the selector block (`hud.js`, `[Lane B R40]`) and **r40_lane_b.py
asserts it** (selector present, ≥20 towns, fetch surface = the named exception only).
**→ Lane F:** `flags_check.py`'s `POST_V2_EXCEPTIONS` label still carries "debt ledger R38+: should
classic show the selector?" — that question is now RULED (R40); update the label when convenient.
### §40.3-3 — FOG SIGNAGE: the HUD and the map now say where you are, and classic never grows the DOM
Three surfaces that said nothing now consume A's layer (LANE_A_NOTES §39 contract verbatim: print
`street || label`, handle null once, never branch on town type). All zero-draw DOM/canvas:
| surface | before | after (measured strings) |
|---|---|---|
| HUD readout | no location line | **`street` row**: `Lurline Street` (katoomba_real, supplier `ways`) · `the south end of the main street` (synthetic, district supplier) |
| door tooltip | `🚪 Little Paris Cafe — click to enter` | `🚪 Little Paris Cafe · Katoomba Street — click to enter` (and `· the south end of the main street` on the synthetic) |
| fog-map caption | `… 4 shops known · 2 streets walked · press M to close` | `… 4 shops known · 2 streets walked · Lurline Street · press M to close` |
- **`discovery.js` gains `createStreetLocator(plan)`** — the probe's own spatial hash, read-only:
`edgeAt(x,z)` (kerb + 4 m pad, nearest at intersections) and `shopNear(x,z)` (nearest shopfront
≤28 m, the locality fallback where streets are nameless). Signage is deliberately NOT keyed to
the fog ledger — knowing the name of the street you are standing on is information design, not
discovery. Resolution rides the existing `frame % 6` throttles (µs per lookup, DOM writes only on
change).
- **Classic-gated by construction, the house pattern:** the shell builds the locator only
`!CLASSIC` and `hud.setAddresses` is then never called ⇒ the `#pc-street` node **does not
exist** in classic's DOM and the tooltip string is the pre-R40 byte. A/B on the same door, same
seed: classic `🚪 Anglibarn Op Shop — click to enter` vs default
`🚪 Anglibarn Op Shop · the south end of the main street — click to enter`. The map's caption
suffix lives inside the fog arm, which classic (null store) never enters. r40_lane_b.py asserts
both arms (row resolves on default, node absent on classic).
### §40.4 (routed by Fable mid-round) — THE ARCADE KIT: E's placement rule applied in B's files
E measured, B applied, exactly as specced (LANE_E_NOTES §40): **the roof spans (slab depth 2.5 =
the half-lane, opposing slabs meet flush on the centreline) and a spanned roof has no posts.**
- `chunks.js`: `ctx.arcadeBlocks` derived once per manager from **`district.kind`, never
`edge.kind`** (real towns: 1,102 arcade-KIND edges = 39 km of ordinary footpath, zero arcade
districts).
- `buildings.js`: `ARCADEKIT` flag in the explicit VERGE/YARDS idiom — **`?classic=1` forces it
off** (mandatory: the synthetic IS the classic boot), `?arcadekit=0` is the standing
falsifiability control. In-arcade lots get depth 2.5 and skip the post loop; everywhere else the
code path is byte-identical pre-R40.
- **Measured on the final tree:** post instances **34 exactly** (E's number), lane-interior view
**draws 120 → 120 (+0)**, 0 console errors; shot pair
`docs/shots/laneB_r40/r40_arcade_lane_{before,after}.png` (before = `?arcadekit=0`) matches E's
reference pair; classic lane view unchanged by construction (flag forced off ⇒ pre-R40 branch)
and classic's pin cell re-measured **byte-exact** (269 / 90,580) after the change.
- E's kit note (the `ground-arcade-roof` ceiling skin at +1 draw × 2 chunks) is NOT taken this
round — it is a visible-cost spend and the round's items didn't ask for it; filed as the next
arcade polish.
### The budget table of record, FINAL tree (all edits in), pin method
| mode | worst draws | law | margin | worst tris |
|---|---|---|---|---|
| default noon | 282 | ≤300 | 18 | 100,538 |
| **default night** | **291** | ≤300 | **9** | 120,093 |
| `?classic=1` night | **269** | ≤300 | 31 | **90,580 (byte-exact to R38 pin)** |
| `?r=3` night (diagnostic) | **391** → gate reads 386 @ 8 m | **≤420 (its own declared law)** | 2934 | 132,421 |
Goldens: `selfcheck.js` **157,647/157,647, fingerprint 0x5f76e76 — unmoved** (run after every edit
wave). Zero console errors on every boot exercised. Files touched: `web/index.html` (RADIUS block,
createHUD budget, address wiring, PROCITY.budget — all `[Lane B R40]`), `web/js/world/hud.js`,
`minimap.js`, `discovery.js`, `dbg.js`, `chunks.js`, `buildings.js`, NEW `tools/qa/r40_lane_b.py`,
`docs/shots/laneB_r40/`.
## Round 39 (v9 Layer 2 — **THE FOG**, item 39.2) — the map costs what it always cost: nothing
**`minimap.js` drew every lot in the plan from the first frame. It now draws what you have walked past,

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

138
tools/qa/r40_lane_b.py Normal file
View File

@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""PROCITY Lane B — R40 §40.3 gate: the ?r= budget law · classic's town selector · fog signage.
[Lane B R40] NEW FILE (races no other lane's edits). Three rulings, each with both arms asserted so
none of them is vacuous. Lane F: one line in qa.sh wires it (see LANE_B_NOTES §40).
1. THE ?r= BUDGET LAW (§40.3-1). `?r=` above the auto radius is a DIAGNOSTIC boot and now declares
its own law (index.html: `PROCITY.budget` = {draws: 420, tris: 200k, diagnostic: true} + a
console warn). Measured R40 (pin method, stepwalk 2 m yaw 0 laps 2, synthetic, seed 20261990):
default 282 noon / 291 night vs ?r=3 382 noon / 391 night at 45-48 live chunks structural
(the live-chunk window, not a regression), so the round's ruling is GATE, not shave.
Asserted here: the declaration (both arms: default says 300/not-diagnostic, r=3 says
420/diagnostic + warns) AND the measurement a night stepwalk under r=3 must stay the boot's
OWN declared ceiling. If the town grows past 420 this gate goes red and the ceiling is re-pinned
consciously. If r=3 ever measures 300 the exemption is unexercised reported, not failed.
2. CLASSIC'S TOWN SELECTOR (§40.3-2, Fable's R40 ruling: classic SHOWS it "information, not
gameplay state"). Asserted: `?classic=1` boots with the selector present, ≥20 town options, and
its towns-fetch surface is exactly the one named POST_V2_EXCEPTION (assets/towns/index.json).
3. FOG SIGNAGE (§40.3-3, classic-gated by construction). Asserted both arms: the default boot's HUD
carries the `#pc-street` row and it RESOLVES (non-"—" standing at a shopfront — on the synthetic
that is a locality label, LANE_A_NOTES §39: print `label`, never branch on town type); classic's
HUD contains NO `#pc-street` node at all (setAddresses never called ⇒ byte-identical DOM).
Run: tools/.venv/bin/python tools/qa/r40_lane_b.py (own no-store server, fresh contexts)
"""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from budget_walk import SEED, SPINE_JS, STEPWALK_JS, boot, serve, summarise # the pinned method, not a reimplementation
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit("playwright not installed — tools/.venv/bin/pip install playwright")
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8967
DIAG_CAP = 420 # the ?r>auto diagnostic ceiling (index.html declares it; this must match)
STREET_CAP = 300
fails = []
def OK(m): print(f" \033[32m✓\033[0m {m}")
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
def NOTE(m): print(f" \033[33m·\033[0m {m}")
def new_page(b):
pg = b.new_page(viewport={'width': 1280, 'height': 720})
state = {'errs': [], 'warns': [], 'reqs': []}
pg.on('console', lambda m: (state['errs'] if m.type == 'error' else state['warns']).append(m.text)
if m.type in ('error', 'warning') else None)
pg.on('request', lambda r: state['reqs'].append(r.url))
return pg, state
def main():
srv = serve(ROOT / 'web', PORT)
host = f'http://127.0.0.1:{PORT}'
with sync_playwright() as p:
b = p.chromium.launch()
# ── arm A: default boot declares the street law; signage row present and resolving ──
print("\n\033[1m▶ default boot — street law declared · fog signage resolves\033[0m")
pg, st = new_page(b)
boot(pg, host, '', None)
r = pg.evaluate("""() => {
const B = window.PROCITY.budget;
return { budget: B, streetRow: !!document.getElementById('pc-street'),
selector: !!document.getElementById('pc-town-select') }; }""")
(OK if r['budget'] and r['budget']['draws'] == STREET_CAP and not r['budget']['diagnostic'] else FAIL)(
f"default PROCITY.budget = {r['budget']} (expect draws {STREET_CAP}, diagnostic false)")
(OK if r['streetRow'] else FAIL)("default HUD has the #pc-street signage row")
# stand at a shopfront (the arcade bookmark fronts the dept anchor) and let the throttle run
pg.evaluate("() => window.DBG.shot('street_noon')")
pg.wait_for_timeout(700) # ≥ 6 street frames at any sane fps
txt = pg.evaluate("() => (document.getElementById('pc-street')||{}).textContent || ''")
(OK if txt and txt != '' else FAIL)(f"street row RESOLVES at a shopfront: '{txt}'")
(OK if not st['errs'] else FAIL)(f"0 console errors (got {len(st['errs'])})")
pg.close()
# ── arm B: classic — selector shown (the R40 ruling), signage absent by construction ──
print("\n\033[1m▶ ?classic=1 — town selector shown · signage DOM absent\033[0m")
pg, st = new_page(b)
boot(pg, host, 'classic=1', None)
pg.wait_for_timeout(1200) # let the index.json upgrade land
r = pg.evaluate("""() => {
const sel = document.getElementById('pc-town-select');
return { nOpts: sel ? sel.options.length : 0,
streetRow: !!document.getElementById('pc-street'),
budget: window.PROCITY.budget }; }""")
(OK if r['nOpts'] >= 20 else FAIL)(f"classic selector present with {r['nOpts']} options (≥20)")
town_fetches = [u for u in st['reqs'] if '/assets/towns/' in u]
only_index = all(u.endswith('/assets/towns/index.json') for u in town_fetches)
(OK if town_fetches and only_index else FAIL)(
f"classic towns-fetch surface is exactly the named exception (index.json): {town_fetches}")
(OK if not r['streetRow'] else FAIL)("classic HUD has NO #pc-street node (byte-identical by construction)")
(OK if r['budget'] and not r['budget']['diagnostic'] else FAIL)("classic declares the street law")
(OK if not st['errs'] else FAIL)(f"0 console errors (got {len(st['errs'])})")
pg.close()
# ── arm C: ?r=3 — declares its own law, warns, and MEASURES inside it (night, worst class) ──
print("\n\033[1m▶ ?r=3 — diagnostic law declared + warned · measured ≤ its own ceiling\033[0m")
pg, st = new_page(b)
boot(pg, host, 'r=3', 5)
r = pg.evaluate("() => window.PROCITY.budget")
(OK if r and r['draws'] == DIAG_CAP and r['diagnostic'] else FAIL)(
f"?r=3 PROCITY.budget = {r} (expect draws {DIAG_CAP}, diagnostic true)")
warned = any('DIAGNOSTIC boot' in w for w in st['warns'])
(OK if warned else FAIL)("the ?r= console warning fired (the flag documents its own law)")
path = pg.evaluate(SPINE_JS, 800)
raw = pg.evaluate(STEPWALK_JS, {'path': path, 'stepm': 8, 'laps': 1, 'fixedYaw': 0})
s = summarise('r3/night/stepwalk8', raw)
worst = s['draws_worst']
(OK if worst <= DIAG_CAP else FAIL)(
f"?r=3 night worst {worst} draws ≤ its declared ceiling {DIAG_CAP} "
f"(p50 {s['draws_p50']}, tris worst {s['tris_worst']:,}, n={s['n']})")
(OK if s['tris_worst'] <= 200_000 else FAIL)(f"?r=3 tris worst {s['tris_worst']:,} ≤ 200,000 (tri law unchanged)")
if worst <= STREET_CAP:
NOTE(f"?r=3 measured ≤{STREET_CAP} — the exemption is unexercised; consider retiring it")
else:
OK(f"non-vacuous: ?r=3 exceeds the street law ({worst} > {STREET_CAP}) — the exemption is load-bearing")
(OK if not st['errs'] else FAIL)(f"0 console errors (got {len(st['errs'])})")
pg.close()
b.close()
srv.shutdown()
print()
if fails:
print(f"\033[31m✗ r40_lane_b: {len(fails)} assertion(s) failed\033[0m")
return 1
print("\033[32m✓ r40_lane_b: all assertions green\033[0m")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@ -265,6 +265,24 @@ const YARDS = (() => {
} catch (e) { return false; } // unparseable search ⇒ pre-R38 geometry, never a throw
})();
// [Lane B R40 §40.4 — THE ARCADE KIT (E's placement rule, Fable-routed)] The one arcade lane is
// 42.00 × 5 m with lot faces at ±2.50 m, and the R39-era awning rule planted two rows of posts
// 1.00 m apart down its centreline — 34 colliderless posts standing in the lane's own doorway.
// The kit designs posts + roof as ONE: a real arcade's roof SPANS (slab depth 2.5 = the half-lane,
// so opposing slabs meet flush and the 0.60 m centre slot closes) AND A SPANNED ROOF HAS NO POSTS.
// No new geometry — deeper awning instances, deleted post instances; E measured +0 draws.
// The gate is EXPLICIT, not by-absence (R39's warning): the arcade district exists only on the
// synthetic and the synthetic IS the classic boot, so ungated this moves the golden render.
// `?classic=1` keeps today's geometry byte-identical; `?arcadekit=0` is the standing
// falsifiability control (the `?verge=0` / `?yards=0` idiom).
const ARCADEKIT = (() => {
try {
const p = new URLSearchParams(location.search);
const classic = p.has('classic') && p.get('classic') !== '0';
return !classic && p.get('arcadekit') !== '0';
} catch (e) { return false; } // unparseable search ⇒ pre-R40 geometry, never a throw
})();
// Measured against the picture, not the spec sheet: a paling fence and a Hills Hoist are within
// 30 cm of each other in real life, and the first cut had the fence TALLER (1.75) than the washing
// line's drop, which hid an entire street of back yards behind their own fences from every angle a
@ -632,17 +650,24 @@ export function buildChunkBuildings(chunkData, ctx) {
// posted verandah + awning over the footpath (main-street shopfronts only)
if (lot.use === 'shop' || lot.use === 'anchor') {
// [Lane B R40 §40.4 — THE ARCADE KIT] in an arcade DISTRICT the roof spans the lane (2.5 =
// the half-lane, opposing slabs meet flush on the centreline) and a spanned roof has no
// posts. Everywhere else: the unchanged posted verandah. See ARCADEKIT above for the gate.
const inArcade = ARCADEKIT && ctx.arcadeBlocks && ctx.arcadeBlocks.has(lot.block);
const depth = inArcade ? 2.5 : AWNING_DEPTH;
const askin = AWNING_SKINS[(seed >>> 3) % AWNING_SKINS.length]; // unsigned: seed is uint32
// awning box: thin slab reaching over the footpath at the front
_p.copy(new THREE.Vector3().fromArray(toWorld(lot, 0, AWNING_Y, d / 2 + AWNING_DEPTH / 2, [])));
_s.set(w, 0.16, AWNING_DEPTH);
_p.copy(new THREE.Vector3().fromArray(toWorld(lot, 0, AWNING_Y, d / 2 + depth / 2, [])));
_s.set(w, 0.16, depth);
awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
// two posts at the awning's outer edge
const postY = AWNING_Y / 2;
for (const ox of [-w / 2 + 0.25, w / 2 - 0.25]) {
_p.fromArray(toWorld(lot, ox, postY, d / 2 + AWNING_DEPTH - 0.2, []));
_s.set(0.12, AWNING_Y, 0.12);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#2b2b2b') });
// two posts at the awning's outer edge — never inside a spanned arcade
if (!inArcade) {
const postY = AWNING_Y / 2;
for (const ox of [-w / 2 + 0.25, w / 2 - 0.25]) {
_p.fromArray(toWorld(lot, ox, postY, d / 2 + AWNING_DEPTH - 0.2, []));
_s.set(0.12, AWNING_Y, 0.12);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#2b2b2b') });
}
}
}

View File

@ -16,6 +16,15 @@ export function createChunkManager(plan, scene, ctx) {
const index = chunkIndex(plan);
prefetchStockIndex(plan); // R26 #5: keyed plans only — warm the stock manifest before any chunk builds
// [Lane B R40 §40.4 — THE ARCADE KIT, district plumbing] The builder needs to know which BLOCKS
// sit in an arcade DISTRICT (lots carry `block`, but block→district lives only on the plan).
// Keyed on `district.kind`, NEVER `edge.kind` — the R39 rule: real towns carry 1,102 arcade-KIND
// edges (39 km of ordinary covered footpath) and zero arcade DISTRICTS, so an edge-kind test
// would re-roof half a real town. Derived once per manager, read by buildings.js's awning block.
const kindById = new Map((plan.districts || []).map((d) => [d.id, d.kind]));
ctx.arcadeBlocks = new Set((plan.blocks || [])
.filter((b) => kindById.get(b.district) === 'arcade').map((b) => b.id));
const live = new Map(); // key → { buildings, furniture, colliders, cx, cz }
const doorMeshes = []; // live door meshes (for HUD raycast)
let queue = []; // keys pending build, nearest-first

View File

@ -40,6 +40,11 @@ export function installDBG(deps) {
geometries: r.memory.geometries, textures: r.memory.textures,
chunk: `${Math.round(player.position.x / CS)},${Math.round(player.position.z / CS)}`,
chunks: chunks.count, mode: getMode(),
// [Lane B R40 §40.3] the draw/tri law THIS boot answers to ({draws, tris, radius, auto,
// diagnostic}) — a harness asserts info().budget.draws, never a hardcoded 300, so a ?r=
// diagnostic boot is measured against its own declared ceiling (420) and a default boot
// against the street law. Read off window.PROCITY so DBG needs no new wiring.
budget: (window.PROCITY && window.PROCITY.budget) || null,
};
};

View File

@ -73,6 +73,66 @@ function segDist2(px, pz, ax, az, bx, bz) {
return dx * dx + dz * dz;
}
// [Lane B R40 §40.3 — FOG SIGNAGE] createStreetLocator(plan) → "where am I standing?" for the HUD
// and the map caption. The same plan-derived spatial hash the probe uses (edges by padded bbox,
// shopfronts by point), read-only: it answers with IDS — `edgeAt(x,z)` → the street edge under your
// feet (kerb + footpath tolerance, nearest wins at an intersection) and `shopNear(x,z)` → the
// nearest shopfront within `shopRadius` — and the consumer asks Lane A's address layer for the
// words (streetOf / localityOf, LANE_A_NOTES §39: print `label`, never branch on town type).
// Pure plan math, zero draws, no store, no probe side effects: knowing the name of the street you
// are on is signage, not discovery, so it is deliberately NOT keyed to the fog ledger. Classic
// never constructs it (the shell's address block is `!CLASSIC`-gated for signage), so classic's
// HUD/map DOM stays byte-identical by construction.
export function createStreetLocator(plan, { streetPad = FOG_STREET_PAD_M, shopRadius = 28 } = {}) {
const nodes = new Map((plan.streets?.nodes || []).map((n) => [n.id, n]));
const edges = [];
for (const e of plan.streets?.edges || []) {
const a = nodes.get(e.a), b = nodes.get(e.b);
if (!a || !b) continue;
const pad = (e.width || 0) / 2 + streetPad;
edges.push({ id: e.id, ax: a.x, az: a.z, bx: b.x, bz: b.z, pad2: pad * pad, pad });
}
const fronts = shopFronts(plan);
const cells = new Map();
const key = (cx, cz) => cx + ',' + cz;
const cellOf = (cx, cz) => { const k = key(cx, cz); let c = cells.get(k); if (!c) cells.set(k, c = { s: [], e: [] }); return c; };
for (const f of fronts) cellOf(Math.floor(f.x / CELL), Math.floor(f.z / CELL)).s.push(f);
for (const e of edges) {
const x0 = Math.floor((Math.min(e.ax, e.bx) - e.pad) / CELL), x1 = Math.floor((Math.max(e.ax, e.bx) + e.pad) / CELL);
const z0 = Math.floor((Math.min(e.az, e.bz) - e.pad) / CELL), z1 = Math.floor((Math.max(e.az, e.bz) + e.pad) / CELL);
for (let cx = x0; cx <= x1; cx++) for (let cz = z0; cz <= z1; cz++) cellOf(cx, cz).e.push(e);
}
const R2 = shopRadius * shopRadius; // shopRadius < CELL, so the 3×3 ring always covers it
return {
edgeAt(x, z) {
let best = null, bd = Infinity;
const cx = Math.floor(x / CELL), cz = Math.floor(z / CELL);
for (let ix = cx - 1; ix <= cx + 1; ix++) for (let iz = cz - 1; iz <= cz + 1; iz++) {
const c = cells.get(key(ix, iz));
if (!c) continue;
for (const e of c.e) {
const d2 = segDist2(x, z, e.ax, e.az, e.bx, e.bz);
if (d2 <= e.pad2 && d2 < bd) { bd = d2; best = e.id; }
}
}
return best;
},
shopNear(x, z) {
let best = null, bd = R2;
const cx = Math.floor(x / CELL), cz = Math.floor(z / CELL);
for (let ix = cx - 1; ix <= cx + 1; ix++) for (let iz = cz - 1; iz <= cz + 1; iz++) {
const c = cells.get(key(ix, iz));
if (!c) continue;
for (const f of c.s) {
const d2 = (x - f.x) ** 2 + (z - f.z) ** 2;
if (d2 <= bd) { bd = d2; best = f.id; }
}
}
return best;
},
};
}
// createDiscovery({ plan, known, radius? }) → the probe.
// known — the KNOWN STORE (save.js `game.knownStore(fogKey)`): addShop/addEdge/hasShop/hasEdge/
// counts/version. Anything with that shape works (the headless gates pass a plain object).

View File

@ -43,7 +43,11 @@ const CSS = `
.pc-crate-empty{opacity:.7;padding:4px 0 2px}
`;
export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }) {
// [Lane B R40 §40.3] `budget` — the draw/tri law THIS boot answers to ({ draws, tris }). The default
// is the street law; the shell passes its diagnostic ceiling under a ?r= override so the readout's
// red warn tells the truth about the boot being run rather than shaming a documented diagnostic.
export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop,
budget = { draws: 300, tris: 200_000 } }) {
if (!document.getElementById('pc-hud-style')) {
const st = document.createElement('style'); st.id = 'pc-hud-style'; st.textContent = CSS;
document.head.appendChild(st);
@ -74,6 +78,14 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
$('pc-seed').textContent = plan.citySeed;
// ── town selector (R19; R21 reads E's towns index) — synthetic + OSM fixtures + real-road towns.
// [Lane B R40 §40.3 — RULED, no longer a debt-ledger question] `?classic=1` SHOWS this selector:
// "classic is the shop-window mode; hiding the 23 towns from it serves nobody — it's information,
// not gameplay state" (ROUND40 rulings). It was already true by construction (this block has no
// flag test) and is now DELIBERATE: the selector is zero-draw DOM chrome (a <select> the renderer
// never sees), its one fetch (assets/towns/index.json) is the named POST_V2_EXCEPTION Fable's R37
// ruling carries for classic, and navigation preserves the full query string — so a town picked
// FROM classic stays classic. Verified R40: classic boots with all 27 options, 1 towns-fetch
// (index.json only), 0 console errors. qa asserts it: tools/qa/r40_lane_b.py.
// Navigates to the matching URL params, preserving everything else (seed, flags). Lives in the HUD
// overlay, so it hides with the HUD for money shots; usable when the pointer is free (Esc). No
// index.html seam. The real-town list is built SYNCHRONOUSLY from the fallback (so the picker always
@ -304,6 +316,49 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
let hovered = null; // { shopId, name }
let frame = 0, fpsAcc = 0, fpsCount = 0, fpsShown = 0;
// ── [Lane B R40 §40.3 — FOG SIGNAGE] Lane A's address layer on the HUD ────────────────────────
// GATED BY CONSTRUCTION: none of this DOM exists until the shell calls setAddresses(), and the
// shell only does that on non-classic boots — so ?classic=1's HUD is byte-identical with no flag
// test here (the R19 selector / R30 game-UI pattern). Two surfaces, both zero-draw DOM:
// • a "street" row in the debug readout — the street you are standing on (locator.edgeAt →
// addr.streetOf), falling back to the nearest shopfront's locality label (addr.localityOf →
// street || label, LANE_A_NOTES §39: print `label`, never branch on town type);
// • the door tooltip carries the shop's street/locality between name and action.
// Resolution rides the existing frame%6 throttle (the door-raycast cadence); the address calls
// are wrapped once — a throwing layer disables itself rather than spamming (minimap.js's rule).
let addresses = null, locator = null, elStreet = null, shownStreet = null;
const whereCache = new Map(); // shopId → street/label string | null (localityOf is pure per boot)
function whereOf(shopId) {
if (!addresses || typeof addresses.localityOf !== 'function') return null;
if (whereCache.has(shopId)) return whereCache.get(shopId);
let text = null;
try { const loc = addresses.localityOf(shopId); text = (loc && (loc.street || loc.label)) || null; }
catch (err) { addresses = null; return null; }
whereCache.set(shopId, text);
return text;
}
function setAddresses(a, loc) {
addresses = a || null; locator = loc || null;
whereCache.clear(); shownStreet = null;
if (addresses && locator && !elStreet) {
const row = document.createElement('div');
row.innerHTML = 'street <b id="pc-street">—</b>';
$('pc-town').parentElement.insertAdjacentElement('afterend', row);
elStreet = row.querySelector('#pc-street');
}
if (elStreet) elStreet.textContent = '—';
}
function resolveStreet() {
if (!addresses || !locator || !elStreet) return;
let text = null;
try {
const eid = locator.edgeAt(camera.position.x, camera.position.z);
if (eid != null && typeof addresses.streetOf === 'function') text = addresses.streetOf(eid);
if (!text) { const sid = locator.shopNear(camera.position.x, camera.position.z); if (sid != null) text = whereOf(sid); }
} catch (err) { addresses = null; return; }
if (text !== shownStreet) { shownStreet = text; elStreet.textContent = text || '—'; }
}
function nearestDoor(rects, point) {
let best = null, bd = Infinity;
for (const r of rects) {
@ -334,7 +389,7 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
if (fpsAcc >= 0.4) { fpsShown = Math.round(fpsCount / fpsAcc); fpsAcc = 0; fpsCount = 0; }
frame++;
if (frame % 6 === 0) raycastDoor();
if (frame % 6 === 0) { raycastDoor(); resolveStreet(); } // [Lane B R40] signage rides the raycast cadence
// v7 game surface — lazy construction + refresh both live in tickGame() (R33 fix: the build used
// to happen ONLY here in street frames, so a door entered fast after boot had no crate/bar inside
@ -347,9 +402,12 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
const h = hovered.hours;
const now = window.PROCITY && window.PROCITY.currentHour ? window.PROCITY.currentHour() : null;
const closed = h && now != null && !(now >= h[0] && now < h[1]);
// [Lane B R40 §40.3] the shop's street/locality on the tooltip (null when unresolved or classic
// — setAddresses never called ⇒ `where` is always null ⇒ the pre-R40 string, by construction)
const where = hovered.shopId != null ? whereOf(hovered.shopId) : null;
elTip.textContent = closed
? `🔒 ${hovered.name} — CLOSED · opens ${String(h[0]).padStart(2, '0')}:00`
: `🚪 ${hovered.name} — click to enter`;
: `🚪 ${hovered.name}${where ? ' · ' + where : ''} — click to enter`;
elTip.style.opacity = 1;
} else elTip.style.opacity = 0;
@ -358,8 +416,8 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
if (info.clock) elClock.textContent = `${info.clock.label} ${info.clock.hour}${info.clock.night ? ' 🌙' : ''}`;
elFps.textContent = fpsShown;
const draws = renderer.info.render.calls, tris = renderer.info.render.triangles;
elDraws.innerHTML = draws > 300 ? `<span class="warn">${draws}</span>` : draws;
elTris.innerHTML = tris > 200000 ? `<span class="warn">${(tris / 1000 | 0)}k</span>` : `${(tris / 1000 | 0)}k`;
elDraws.innerHTML = draws > budget.draws ? `<span class="warn">${draws}</span>` : draws; // [Lane B R40] the boot's own law
elTris.innerHTML = tris > budget.tris ? `<span class="warn">${(tris / 1000 | 0)}k</span>` : `${(tris / 1000 | 0)}k`;
if (info.chunks != null) elChunks.textContent = info.chunks;
}
}
@ -399,5 +457,6 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
}
return { update, tickToast, showToast, setVisible, tickGame, dispose, getHovered: () => hovered,
getFps: () => fpsShown };
getFps: () => fpsShown,
setAddresses }; // [Lane B R40 §40.3] fog signage — the shell wires it on non-classic boots only
}

View File

@ -35,6 +35,12 @@ const MAX_LABELS = 40;
export function createMinimap(plan, known = null, opts = {}) {
let addresses = opts.addresses || null; // Lane A's createAddresses(): streetOf / localityOf
// [Lane B R40 §40.3 — FOG SIGNAGE] optional street locator (discovery.createStreetLocator): lets
// the fog-arm caption say where YOU are standing ("· Katoomba Street"). Consumed ONLY inside the
// fog arm (`known !== null`), so the classic/null-arm map — caption included — stays byte-for-byte
// pre-R39 with no flag test here, exactly like `known` itself. The shell passes null under classic.
let locator = opts.locator || null;
let capBase = null, capLoc = null; // caption = capBase + (' · ' + where-you-are) + tail
const wrap = document.createElement('div');
wrap.id = 'pc-map';
@ -258,9 +264,30 @@ export function createMinimap(plan, known = null, opts = {}) {
for (const id of known.edgeIds()) { let n = null; try { n = addresses.streetOf(id); } catch (e) { names.clear(); break; } if (n) names.add(n); }
if (names.size) streets = names.size;
}
cap.textContent = `${plan.name} — seed ${plan.citySeed} · ${c.shops} shop${c.shops === 1 ? '' : 's'} known · `
capBase = `${plan.name} — seed ${plan.citySeed} · ${c.shops} shop${c.shops === 1 ? '' : 's'} known · `
+ (streets != null ? `${streets} street${streets === 1 ? '' : 's'}` : `${c.edges} street segment${c.edges === 1 ? '' : 's'}`)
+ ` walked · press M to close`;
+ ` walked`;
cap.textContent = capBase + (capLoc ? ` · ${capLoc}` : '') + ` · press M to close`;
}
// [Lane B R40 §40.3] where the player dot IS: the street under your feet by name, else the nearest
// shopfront's locality label — A's contract verbatim (`street || label`, print it, never test the
// town type; null is rendered by omission). Fog arm only; a throwing layer disables itself.
function refreshLocation(px, pz) {
if (!locator || !addresses || capBase == null) return;
let text = null;
try {
const eid = locator.edgeAt(px, pz);
if (eid != null && typeof addresses.streetOf === 'function') text = addresses.streetOf(eid);
if (!text && typeof addresses.localityOf === 'function') {
const sid = locator.shopNear(px, pz);
if (sid != null) { const loc = addresses.localityOf(sid); text = (loc && (loc.street || loc.label)) || null; }
}
} catch (err) { locator = null; return; }
if (text !== capLoc) {
capLoc = text;
cap.textContent = capBase + (capLoc ? ` · ${capLoc}` : '') + ` · press M to close`;
}
}
function draw(playerPos, fwd) {
@ -277,6 +304,7 @@ export function createMinimap(plan, known = null, opts = {}) {
}
ctx.clearRect(0, 0, W, H);
ctx.drawImage(off, 0, 0);
refreshLocation(playerPos.x, playerPos.z); // [Lane B R40] caption: where the dot is (µs; writes DOM only on change)
}
const x = T.tx(playerPos.x), z = T.tz(playerPos.z);
// heading (world +X→right, +Z→down on the map)
@ -292,8 +320,9 @@ export function createMinimap(plan, known = null, opts = {}) {
setVisible: (v) => { wrap.style.display = v ? 'flex' : 'none'; },
dispose: () => wrap.remove(),
// [R39] Lane A's address layer, injectable — the shell wires it at boot when citygen exports
// createAddresses, and it can land mid-session without a reload.
setAddresses(a) { addresses = a || null; cacheKey = null; lastCounts = null; },
// createAddresses, and it can land mid-session without a reload. [R40] + the optional street
// locator (fog-arm caption signage; the shell passes null under classic — see the header note).
setAddresses(a, loc) { addresses = a || null; locator = loc || null; cacheKey = null; lastCounts = null; capLoc = null; },
// what the map is actually showing, for the gates: metres across, and the px a shop lot renders at
get view() {
const med = (arr) => { const b = arr.slice().sort((p, q) => p - q); return b.length ? b[b.length >> 1] : 0; };