diff --git a/docs/A-progress.md b/docs/A-progress.md index 1db1cd5..b4934ab 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,10 +1,30 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check all-green (1283/1283); round-2 (B/E/F requests) and -round-3 (F finding #5 verified stale + golden hash confirmed) closed** (see bottom). `qa.sh --strict` -GREEN. Awaiting Fable review.* +*Status: **all deliverables landed; self-check all-green (1301/1301); rounds 2–4 closed** (see bottom). +`qa.sh --strict` GREEN. Awaiting Fable review.* *Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)* +## Round 4 (2026-07-14) — v1-tag round: 2 confirmations, 1 hardening fix + +**A1 — storeys checker (post-F2): CONFIRMED.** `tools/qa/consistency_check.mjs` `permittedMax` scoping +(Lane F's F2, matching my R3 spec) gives **0 storeys warnings** across 7 seeds; `qa.sh --strict` GREEN +(4/4). False-negative test: injected a real `video@2` into a scratch copy of the checker's input → +caught on every seed. No benign-anchor false positives, no real-drift false negatives. + +**A2 — openLate contract: AUDITED + HARDENED.** F's night gate `hours[1] >= 22` was matching 4–15 +shops/seed (regular videos jittered from base-21 to 22), not the single landmark it assumes. Fixed in +plan.js so `hours[1] >= 22` ⟺ `openLate` (exactly one, always the video rental): regular shops close +by 21:00; only the deterministically-chosen video landmark reaches ≥22 (`LATE_HOUR`). This also makes +the generator finally match what `web/index.html:159-161` and `soak.py:68` already assumed +("at night only the openLate shop is open"). Added selfcheck assertions; verified across 15 seeds. + +**⚠ GOLDEN.hash: `0x098eec2b` → `0x3fa36874`** — intended output change from the A2 fix (not drift; +F2 is tooling-only and didn't touch generation). Same lots/shops/types/names/skins; only a few videos' +close hour moved 22→21. Details for Fable in `LANE_A_NOTES.md` (round 4). + +**Self-check:** `✓ ALL GREEN — 1301/1301` (was 1283; +18 are the new openLate exactly-one / field⟺threshold +/ is-video assertions). Seed 20261990 → "Boolarra Heads": 681 lots, 493 shops, unchanged. + > **⚠ Fable — commit-label mixup (no action needed on the code):** my round-2 changes are committed > and correct, but a shared-working-tree race landed them under Lane C's message — > commit **`4235060` "Lane C: keeper stand pose…" actually contains only my 5 Lane A files** (a diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index e571267..19eb20c 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,36 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 4 (2026-07-14) + +### → Lane F (A1): storeys checker (F2) confirmed — 0 warnings, no false negatives + +`tools/qa/consistency_check.mjs` `permittedMax` scoping (your F2) is **correct**. Verified this round: +- `node tools/qa/consistency_check.mjs` across 7 seeds → **0 storeys warnings**; `qa.sh --strict` GREEN (4/4). +- **False-negative test**: injected a real `video@2` (registry `[1,1]`) into a scratch copy of the + checker's plan input (NOT plan.js) across 5 seeds → the storeys gate still fired every time. So the + scoping suppresses benign 3-storey corner anchors without hiding real single-storey-type drift. ✓ + +### → Fable / Lane F (A2): openLate contract HARDENED — `hours[1] ≥ 22` now ⟺ `openLate` (and it IS the video) + +Your night gate `plan.shops.some(s => s.hours[1] >= 22)` was matching **4–15 shops/seed**, not one: +regular video rentals (base close 21) jittered up to 22. That contradicted the shell's own contract +(`web/index.html:159-161`: "exactly one openLate landmark, always the video rental… at night only the +openLate shop is open") and `soak.py:68`. Rather than ask you to change the gate, I made reality match it: + +- **plan.js**: regular shops now close by `LATE_HOUR-1` (21:00); only the deterministically-chosen + `openLate` landmark reaches ≥22. The openLate pick is now **video-first** (milk-bar / any-non-stall + only if a town somehow has no video — never happens in practice). +- **Result (verified 15 seeds)**: exactly ONE shop has `hours[1] ≥ 22`, it is exactly the `openLate` + shop, and it is always the video rental. `{s: s.hours[1] >= 22}` and `{s: s.openLate}` are now the + same singleton — **gate on either**, they agree. +- selfcheck.js asserts all of this now (exactly-one, field⟺threshold, is-video). + +**⚠ GOLDEN.hash changed: `0x098eec2b` → `0x3fa36874`.** This is the *intended* consequence of the A2 +plan.js fix (a handful of video shops now close at 21 instead of 22 for seed 20261990). It is **not** +drift, and **F2 (your tooling change) did not touch generation** — that half of A1 holds. selfcheck.js +carries the new golden and is GREEN. Nothing else in a plan changed (same lots/shops/types/names/skins). + ## Round 3 (2026-07-14) ### → Lane F: the `qa.sh` "storeys outside registry range" warnings are BENIGN (finding #5 closed) diff --git a/web/js/citygen/plan.js b/web/js/citygen/plan.js index deaabfb..6d55d32 100644 --- a/web/js/citygen/plan.js +++ b/web/js/citygen/plan.js @@ -15,6 +15,10 @@ import { shopName, townName } from './names.js'; const r2 = v => Math.round(v * 100) / 100; // position/size precision (2dp metres) const r4 = v => Math.round(v * 10000) / 10000; // angle precision (radians) const clampHour = h => Math.max(0, Math.min(23, h | 0)); +// The town has exactly ONE late-night landmark. Regular shops close by LATE_HOUR-1; only the +// deterministically-chosen `openLate` shop reaches ≥ LATE_HOUR — so `hours[1] >= LATE_HOUR` +// and the `openLate` field are equivalent, and both identify that single shop (Lane F night gate). +const LATE_HOUR = 22; // World-space corners of a lot's rotated footprint. Convention matches selfcheck + Lane B: // a lot faces its street via `ry`; corners are [±w/2, ±d/2] rotated by ry about the lot centre. @@ -79,6 +83,7 @@ export function generatePlan(citySeed) { if (sr() < 0.3) open = clampHour(open + (sr() < 0.5 ? -1 : 1)); if (sr() < 0.3) close = clampHour(close + (sr() < 0.5 ? -1 : 1)); if (close <= open) close = clampHour(open + 2); + close = Math.min(close, LATE_HOUR - 1); // regular shops close by 21:00 (only the openLate landmark is later) // (the town's one guaranteed late-night shop is chosen deterministically after the whole // shop set is final — see the `openLate` pass at the end of generatePlan.) const { name, sign } = shopName(seed, type); @@ -356,16 +361,18 @@ export function generatePlan(citySeed) { // The brief calls for one weirdo trading late. Make it a deterministic, explicitly-flagged // landmark (not a per-shop dice, which gave 0..many and often none past 22:00) so Lane B can // light it after dark and Lane F can gate on a known field instead of a magic hours threshold. - // Prefer a naturally-late type (video rental / milk bar); market stalls (morning-only) are never it. - // Runs AFTER corner de-confliction so the chosen shop can't then be spliced out. + // The video rental is the archetypal 90s late-night shop, so pick a video first; fall back to a + // milk bar, then any non-stall, only if a town somehow has none. Market stalls (morning-only) + // are never it. Runs AFTER corner de-confliction so the chosen shop can't then be spliced out. if (shops.length) { - const lateCandidates = shops.filter(s => s.type === 'video' || s.type === 'milkbar'); - const pool = lateCandidates.length ? lateCandidates : shops.filter(s => s.type !== 'stall'); + const videos = shops.filter(s => s.type === 'video'); + const milkbars = shops.filter(s => s.type === 'milkbar'); + const pool = videos.length ? videos : (milkbars.length ? milkbars : shops.filter(s => s.type !== 'stall')); if (pool.length) { const lr = rng(citySeed, 'openlate', 0); const chosen = pick(lr, pool); chosen.openLate = true; - chosen.hours = [chosen.hours[0], 22 + ((lr() * 2) | 0)]; // closes 22:00 or 23:00 + chosen.hours = [chosen.hours[0], LATE_HOUR + ((lr() * 2) | 0)]; // 22:00 or 23:00 — the town's only shop ≥ LATE_HOUR } } diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index 8e076bf..bef22d6 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -40,7 +40,7 @@ for (const s of SEEDS) { // ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ──── // (If you intentionally change generator output, run selfcheck, copy the printed hash here.) section('golden fingerprint (output-drift guard)'); -const GOLDEN = { seed: 20261990, hash: 0x098eec2b }; +const GOLDEN = { seed: 20261990, hash: 0x3fa36874 }; { const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0; ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`); @@ -183,10 +183,15 @@ for (const s of SEEDS) { const milkbars = plan.shops.filter(sh => sh.type === 'milkbar').length; ok(milkbars >= 2 && milkbars <= 4, `seed ${s}: 2–4 corner milk bars (${milkbars})`); - // exactly one deterministic late-night landmark (Lane F's night gate keys off `openLate`, not a threshold) + // exactly one deterministic late-night landmark, and the `openLate` field ⟺ `hours[1] >= 22` + // (Lane F's night gate may key off EITHER — they identify the same single shop). const late = plan.shops.filter(sh => sh.openLate); + const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22); ok(late.length === 1, `seed ${s}: exactly one open-late shop (${late.length})`); - ok(late.every(sh => sh.hours[1] >= 22 && sh.type !== 'stall'), `seed ${s}: open-late shop closes ≥22:00 and isn't a market stall`); + ok(ge22.length === 1, `seed ${s}: exactly one shop closes ≥22:00 (${ge22.length})`); + ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0], `seed ${s}: openLate field ⟺ hours[1]≥22 (same shop)`); + ok(late.length === 1 && late[0].hours[1] >= 22 && late[0].type !== 'stall', `seed ${s}: open-late shop closes ≥22:00 and isn't a market stall`); + ok(late.length === 1 && (late[0].type === 'video' || !plan.shops.some(x => x.type === 'video')), `seed ${s}: the open-late shop is the video rental`); for (const sh of plan.shops) facadeSet.add(sh.facadeSkin); }