Lane F R17 (v3.2): real towns boot in the engine + the town-matrix scout gate (ledger #7)

F wired the last mile of the real-map scout and gated the whole matrix.

- index.js barrel re-exports registerTownCache/validateTownCache; the shell fetches + registers
  assets/towns/<key>.json before generatePlanFor when the town isn't a checked-in fixture — so
  ?plansrc=osm&town=bendigo_real boots a REAL AU town in the engine (the gap: E's caches were on disk +
  in the node selfcheck, but the shell never loaded them → silent Melbourne fallback). Fail-soft
  (404 / invalid / ?noassets -> fixture fallback); opt-in, never on the default or ?classic path.
- tools/qa/town_matrix.py (opt-in qa.sh --matrix): boot / determinism / budget / district / ?noassets
  across 10 towns (2 synthetic hero seeds + 3 osm fixtures + E's 5 real AU caches). ALL 5x10 GREEN —
  Castlemaine at the 6-shop floor still lands a 3-venue district. The existing CityPlan contract eats real
  Australian geography: the evidence John charters v4 = THE REAL MAP on.
- docs: LANE_F_NOTES §17 (the town x gate table), README real-town row, F-progress R17.

qa.sh --strict --soak GREEN 7/7. Verified: ?plansrc=osm&town=bendigo_real -> Bendigo, 9 shops, 3 venues, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-16 15:23:15 +10:00
parent 64ded4ea01
commit e4a49b5bc8
7 changed files with 249 additions and 1 deletions

View File

@ -4,6 +4,22 @@
--- ---
## Round 17 (v3.2 THE TAIL + THE SCOUT) — F: the town matrix + the close
The tail drained (drummer lean + bench-sit, wind sway, backline verify, --verify fix) and E's real-map scout
landed (5 real AU town caches + A's cache contract + hardened plan_osm). **F closed it.**
- **Real towns boot in the engine** — the barrel re-exports `registerTownCache`; the shell fetches +
registers `assets/towns/<key>.json` before the plan generates (fail-soft; opt-in; never on default/classic).
`?plansrc=osm&town=bendigo_real`**Bendigo, 9 shops, 3-venue district, 0 errors**.
- **Town-matrix smoke** (`tools/qa/town_matrix.py`, `qa.sh --matrix`) — **10 towns × 5 gates all green**:
2 synthetic + 3 fixtures + 5 real caches, each boot / deterministic / in-budget (69216 draws) / a 3-venue
district / silent under `?noassets`. Castlemaine at the 6-shop floor still lands a district. **The existing
CityPlan contract eats real Australian geography** — the v4 = THE REAL MAP evidence.
- Close: `qa.sh --strict --soak` green, docs (LANE_F_NOTES §17, README), tag **`v3.2`**.
---
## Round 16 (v3.1 THE FLIP) — F bookends; the town shows its best by default ## Round 16 (v3.1 THE FLIP) — F bookends; the town shows its best by default
The gig layer soaked through three v3 stages, so v3.1 flips it (and the mature v2 flags) ON by default. The gig layer soaked through three v3 stages, so v3.1 flips it (and the mature v2 flags) ON by default.

View File

@ -74,6 +74,7 @@ master off:
| `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster | | `?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 | | `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock |
| `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness | | `?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** (v3.2 scout): fixtures `melbourne`/`katoomba`/`silverton`, or a real OSM cache `bendigo_real`/`castlemaine_real`/`fremantle_real`/`katoomba_real`/`newtown_real` (ODbL, `docs`/`web/assets/towns/SOURCES.md`) |
| `?seed=N` | `20261990` | town seed | | `?seed=N` | `20261990` | town seed |
**Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on **Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on

View File

@ -638,3 +638,40 @@ in the opening commit. Verified live: the seated drummer passes at **1.32 m stat
actual shader is B's own `winmap` smoke. actual shader is B's own `winmap` smoke.
- **`?classic` must zero the fetch delta**, so `sit.glb` is gated on `!CLASSIC` (not just on the fleet) — a - **`?classic` must zero the fetch delta**, so `sit.glb` is gated on `!CLASSIC` (not just on the fleet) — a
raw sit fetch under classic would break the covenant even though the drummer never spawns there. raw sit fetch under classic would break the covenant even though the drummer never spawns there.
---
## 17. Round 17 — v3.2 THE TAIL + THE SCOUT. F: the town matrix + the close (ledger #7).
The tail drained (D's drummer lean + bench-sit, B's wind sway, C's backline verify, E's `--verify` fix) and
the real-map scout ran (E's 5 real AU town caches + A's cache contract + hardened `plan_osm`). F wired the
last mile and gated the whole matrix.
### Real towns boot in the engine (the F wiring)
E's caches sat on disk (`web/assets/towns/*.json`) + registered in the node selfcheck, but **the shell never
loaded them** — `?plansrc=osm&town=bendigo_real` silently fell back to Melbourne. F closed the gap: the barrel
(`index.js`) re-exports `registerTownCache`/`validateTownCache`; the shell fetches `assets/towns/<TOWN>.json`
+ registers it before `generatePlanFor` when the town isn't a checked-in fixture. **Fail-soft** — 404 /
invalid / `?noassets` → plan_osm's fixture fallback; the scout data is opt-in, never on the default or
`?classic` path. Verified: `?plansrc=osm&town=bendigo_real`**Bendigo, 9 shops, a 3-venue district, 0 errors**.
### The town matrix (`tools/qa/town_matrix.py`, opt-in `qa.sh --matrix`) — the v4 evidence
Boots every town on the DEFAULT boot and gates boot / determinism / budget / district / `?noassets`. **All
10 × 5 green:**
| town | shops | budget (draws/tris) | district |
|---|---|---|---|
| synthetic/20261990 · 1234 | 493 · 486 | 124d/57k · 147d/71k | 3v ✓ |
| osm/melbourne · katoomba · silverton | 95 · 19 · 12 | 216d · 70d · 70d | 3v ✓ |
| real/bendigo · castlemaine · fremantle · katoomba · newtown | 9 · 6 · 21 · 20 · 18 | 82 · 86 · 159 · 69 · 93 d | 3v ✓ |
The headline: **the existing CityPlan contract eats real Australian geography** — Castlemaine at the 6-shop
floor still lands a 3-venue district; every real town is deterministic, in-budget, and silent under
`?noassets`. No new game systems — exactly the bounded scout the round asked for. This table is the evidence
John charters **v4 = THE REAL MAP** on.
### Gotchas
- **A published cache + selfcheck ≠ a bootable town.** The node sweep proved the *plan* generates; the
*browser* boot needed the shell to fetch+register the cache. Classic integration seam — F owns it.
- **`?noassets` can't fetch a real cache**, so a real-town + noassets boot correctly falls back to a fixture
(still silent-and-fine). The matrix's noassets gate tests that fallback, not the real town — as it should.

View File

@ -126,6 +126,20 @@ if [ "$SOAK_RUN" = 1 ]; then
fi fi
fi fi
# ── Gate 7 (Lane F, round 17): town-matrix smoke — OPT-IN (--matrix), NON-STRICT ─
# The v4 real-map scout evidence: boot / determinism / budget / district / noassets across the full town
# matrix — synthetic hero seeds + the 3 osm fixtures + Lane E's 5 real AU town caches. One town × gate
# table. Slow (~20 boots) + diagnostic, so OPT-IN + warn-level. Wired per ROUND17 §Lane F (ledger #7).
MATRIX_RUN=0; for a in "$@"; do [ "$a" = "--matrix" ] && MATRIX_RUN=1; done
if [ "$MATRIX_RUN" = 1 ]; then
if [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
warn_gate "town-matrix (synthetic + fixtures + real caches × 5 gates · Lane F R17)" \
tools/.venv/bin/python tools/qa/town_matrix.py
else
soft_skip "town-matrix" "Playwright venv absent"
fi
fi
# ── Summary ────────────────────────────────────────────────────────────────── # ── Summary ──────────────────────────────────────────────────────────────────
hr hr
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \ printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \

169
tools/qa/town_matrix.py Normal file
View File

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""PROCITY Lane F — the town-matrix smoke (round 17, v3.2 · the real-map scout gate · ledger #7).
The gate side of the scout: run the core smokes across the FULL town matrix synthetic hero seeds, the
three checked-in osm fixtures, and Lane E's real AU town caches — and emit one town × gate table. This is
the evidence John charters v4 (THE REAL MAP) on: proof the existing CityPlan contract eats real geography
through the default boot, with no new game systems.
Gates per town (all on the DEFAULT boot gigs/weather/winmap/tram on, post-R16 flip):
boot the plan builds + the shell reaches window.PROCITY.plan, 0 console errors
determ two fresh in-page generations are byte-equal (the generator is deterministic)
budget the busiest default view (venue block at night) 300 draws / 200k tris
district the gig layer placed on this town: 1 venue, a week schedule, posters
noassets ?noassets=1 boots silent (0 errors, 0 network); real caches fall back to a fixture (correct)
Run: tools/.venv/bin/python tools/qa/town_matrix.py
Needs the Playwright venv + Lane B's ?dbg=1 hook + Lane F's real-cache shell wiring (R17).
"""
import sys, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130
HOST = f'http://127.0.0.1:{PORT}'
STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000
# the matrix: (row-label, boot-query). Synthetic hero seeds + 3 fixtures + E's 5 real AU caches.
MATRIX = (
[(f'synthetic/{s}', f'seed={s}') for s in (20261990, 1234)]
+ [(f'osm/{t}', f'plansrc=osm&town={t}') for t in ('melbourne', 'katoomba', 'silverton')]
+ [(f'real/{t}', f'plansrc=osm&town={t}')
for t in ('bendigo_real', 'castlemaine_real', 'fremantle_real', 'katoomba_real', 'newtown_real')]
)
# default-boot gates 1-4, in one page. Determinism regenerates the plan twice in-page (the real cache is
# already registered by the shell) and compares — no second full boot needed.
DEFAULT_JS = r"""
async () => {
const P = window.PROCITY, D = window.DBG;
const plan = P.plan;
const params = new URLSearchParams(location.search);
const src = params.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
const town = params.get('town') || undefined;
const cg = await import('./js/citygen/index.js');
const g1 = JSON.stringify(cg.generatePlanFor(plan.citySeed, src, { town, gigs: true }));
const g2 = JSON.stringify(cg.generatePlanFor(plan.citySeed, src, { town, gigs: true }));
const venues = (plan.shops || []).filter(s => s.venue).length;
D.shot('venue_night'); // busiest default view: the venue block at night
await new Promise(r => setTimeout(r, 500));
const i = D.info();
return { name: plan.name, shops: (plan.shops || []).length, venues,
gigs: (plan.gigs || []).length, posters: (plan.posters || []).length,
determ: g1 === g2, draws: i.drawCalls, tris: i.tris };
}
"""
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4)
return s.connect_ex(('127.0.0.1', port)) == 0
def ensure_server():
if port_up(PORT):
return None
proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)], cwd=str(ROOT / 'web'),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(50):
if port_up(PORT): return proc
time.sleep(0.1)
proc.terminate(); sys.exit(f'could not start http.server on :{PORT}')
def run_town(new_page, query):
"""Returns a dict of gate → (ok, note) for one town."""
row = {}
# ── default boot: boot / determ / budget / district ──
pg = new_page()
errs = []
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
pg.on('pageerror', lambda e: errs.append(str(e)))
try:
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=12000)
except Exception:
pass
d = pg.evaluate(DEFAULT_JS)
row['name'] = d['name']; row['shops'] = d['shops']
row['boot'] = (bool(d['name']) and len(errs) == 0, f"{d['shops']} shops, {len(errs)} err")
row['determ'] = (d['determ'], 'byte-equal' if d['determ'] else 'DIFF')
row['budget'] = (d['draws'] <= STREET_DRAWS_MAX and d['tris'] <= STREET_TRIS_MAX,
f"{d['draws']}d/{d['tris']//1000}k")
row['district'] = (d['venues'] >= 1 and d['gigs'] > 0,
f"{d['venues']}v/{d['gigs']}g/{d['posters']}p")
except Exception as e:
row.setdefault('name', '?'); row.setdefault('shops', 0)
for g in ('boot', 'determ', 'budget', 'district'):
row.setdefault(g, (False, str(e)[:24]))
finally:
pg.close()
# ── ?noassets=1: silent-and-fine (real caches correctly fall back to a fixture — no network) ──
pg = new_page()
reqs, errs2 = [], []
pg.on('request', lambda r: reqs.append(r.url) if ('/assets/towns/' in r.url or '/assets/audio/' in r.url or r.url.endswith('.glb')) else None)
pg.on('pageerror', lambda e: errs2.append(str(e)))
try:
pg.goto(f'{HOST}/index.html?{query}&noassets=1&dbg=1')
pg.wait_for_function('window.PROCITY && window.PROCITY.plan', timeout=20000)
has_plan = pg.evaluate("() => !!(window.PROCITY.plan && window.PROCITY.plan.shops.length)")
row['noassets'] = (has_plan and len(errs2) == 0 and len(reqs) == 0,
f"{len(reqs)} fetch/{len(errs2)} err")
except Exception as e:
row['noassets'] = (False, str(e)[:24])
finally:
pg.close()
return row
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium')
print(f"\033[1mTOWN-MATRIX SMOKE\033[0m ({len(MATRIX)} towns × 5 gates · the v4 real-map scout evidence)")
srv = ensure_server()
GATES = ['boot', 'determ', 'budget', 'district', 'noassets']
rows = []
try:
with sync_playwright() as p:
b = p.chromium.launch()
def factory():
return b.new_page(viewport={'width': 1280, 'height': 720})
for label, query in MATRIX:
r = run_town(factory, query)
rows.append((label, r))
cells = ' '.join(f"{g}:{'' if r[g][0] else ''}" for g in GATES)
print(f" {label:22} {(r.get('name') or '?'):16} {cells}")
b.close()
finally:
if srv: srv.terminate()
# the table John reads
print(f"\n\033[1mTOWN × GATE\033[0m")
hdr = f"{'town':22} {'name':16} " + " ".join(f"{g:>9}" for g in GATES)
print(hdr); print('' * len(hdr))
fails = 0
for label, r in rows:
line = f"{label:22} {(r.get('name') or '?'):16} "
for g in GATES:
ok, note = r[g]
fails += 0 if ok else 1
line += f"{('' if ok else '') + note:>9} "[:10] + " "
print(line)
# per-gate note detail
print("\nnotes:", "; ".join(f"{label}[{g}={r[g][1]}]" for label, r in rows for g in GATES if not r[g][0]) or "all gates green")
print()
if fails:
print(f"\033[31m● {fails} gate failure(s)\033[0m across {len(rows)} towns — the matrix is not clean.")
sys.exit(1)
print(f"\033[32m● MATRIX GREEN\033[0m — {len(rows)} towns (synthetic + fixtures + real caches) pass all 5 gates.")
sys.exit(0)
if __name__ == '__main__':
main()

View File

@ -111,6 +111,17 @@ const customBands = await loadCustomBands();
let plan; let plan;
try { try {
const citygen = await import('./js/citygen/index.js'); const citygen = await import('./js/citygen/index.js');
// [Lane F R17 — real-town caches] a ?plansrc=osm&town=<key> whose key is NOT a checked-in fixture is one of
// Lane E's real AU town caches (web/assets/towns/<key>.json). Fetch + register it before the plan generates,
// so real towns boot in the engine (the v4 scout). Fail-soft: 404 / invalid cache / ?noassets → plan_osm
// falls back to the default fixture. The scout data is opt-in — never on the default or ?classic path.
if (PLANSRC === 'osm' && TOWN && !NOASSETS && citygen.registerTownCache
&& !(citygen.osmTownKeys && citygen.osmTownKeys().includes(TOWN))) {
try {
const r = await fetch(`assets/towns/${TOWN}.json`, { cache: 'force-cache' });
if (r.ok) { citygen.registerTownCache(TOWN, await r.json()); console.log(`[procity] real town cache registered: ${TOWN}`); }
} catch (e) { console.warn(`[procity] town cache '${TOWN}' not loaded (${e && e.message || e}) — falling back to a fixture`); }
}
const gen = citygen.generatePlanFor const gen = citygen.generatePlanFor
? (s) => citygen.generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS_ON, customBands }) // A's selector (synthetic | osm[+town]) + the v3 gig layer ? (s) => citygen.generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS_ON, customBands }) // A's selector (synthetic | osm[+town]) + the v3 gig layer
: (citygen.generatePlan || citygen.default); // pre-R6 fallback (synthetic only) : (citygen.generatePlan || citygen.default); // pre-R6 fallback (synthetic only)

View File

@ -9,7 +9,7 @@
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js'; export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js';
export { shopName, townName, bandName } from './names.js'; export { shopName, townName, bandName } from './names.js';
export { generatePlanOSM, osmTownKeys } from './plan_osm.js'; export { generatePlanOSM, osmTownKeys, registerTownCache, validateTownCache, MIN_TOWN_SHOPS } from './plan_osm.js';
export { withGigs, gigKeyFor, POSTER_CLEAR } from './gigs.js'; export { withGigs, gigKeyFor, POSTER_CLEAR } from './gigs.js';
// The street-corridor law + venue vocabulary, re-exported so consumers get the whole Lane A contract // The street-corridor law + venue vocabulary, re-exported so consumers get the whole Lane A contract
// from one import (ROUND13). `roadWidth`/`vergeBand`/`poleOffset` are the road-vs-verge split of // from one import (ROUND13). `roadWidth`/`vergeBand`/`poleOffset` are the road-vs-verge split of