diff --git a/F-progress.md b/F-progress.md index f7e7708..59cb33f 100644 --- a/F-progress.md +++ b/F-progress.md @@ -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/.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 (69–216 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 The gig layer soaked through three v3 stages, so v3.1 flips it (and the mature v2 flags) ON by default. diff --git a/README.md b/README.md index fa5c5fe..dcfad6d 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ master off: | `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster | | `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock | | `?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** (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 | **Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md index 35c027c..a4e6b8a 100644 --- a/docs/LANES/LANE_F_NOTES.md +++ b/docs/LANES/LANE_F_NOTES.md @@ -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. - **`?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. + +--- + +## 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/.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. diff --git a/tools/qa.sh b/tools/qa.sh index 14d04bd..06325c5 100755 --- a/tools/qa.sh +++ b/tools/qa.sh @@ -126,6 +126,20 @@ if [ "$SOAK_RUN" = 1 ]; then 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 ────────────────────────────────────────────────────────────────── hr printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \ diff --git a/tools/qa/town_matrix.py b/tools/qa/town_matrix.py new file mode 100644 index 0000000..7a6b378 --- /dev/null +++ b/tools/qa/town_matrix.py @@ -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() diff --git a/web/index.html b/web/index.html index 95817bd..737a4d9 100644 --- a/web/index.html +++ b/web/index.html @@ -111,6 +111,17 @@ const customBands = await loadCustomBands(); let plan; try { const citygen = await import('./js/citygen/index.js'); + // [Lane F R17 — real-town caches] a ?plansrc=osm&town= whose key is NOT a checked-in fixture is one of + // Lane E's real AU town caches (web/assets/towns/.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 ? (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) diff --git a/web/js/citygen/index.js b/web/js/citygen/index.js index c61cd15..5b4f665 100644 --- a/web/js/citygen/index.js +++ b/web/js/citygen/index.js @@ -9,7 +9,7 @@ export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.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'; // 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