diff --git a/docs/A-progress.md b/docs/A-progress.md index c03bf8a..4ef5d73 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,6 +1,7 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed & self-check all-green.** Awaiting Fable review.* +*Status: **all deliverables landed; self-check all-green (1283/1283); round-2 cross-lane requests from +B/E/F closed** (see bottom). Awaiting Fable review.* *Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)* ## TL;DR @@ -124,3 +125,37 @@ required infra — now added to the table). facing/cross-block-overlap/edge-coverage/finiteness/fingerprint assertions). Seed 20261990 → "Boolarra Heads": 27 nodes, 22 edges, 35 blocks, 681 lots, 493 shops, 174 chunks. All 5 seed screenshots re-captured. Golden fingerprint `0xb5d5cc13`. + +## Round 2 — cross-lane integration responses (2026-07-14) + +Picked the lane back up after B–F integrated against it. Worked through the notes those lanes left +for Lane A (`docs/LANES/LANE_F_NOTES.md §8/§3.5/§7`, `B-progress.md`, `E-progress.md`) and closed +every request that lands in my files. **Self-check green at 1283/1283; golden fingerprint refreshed +`0xb5d5cc13` → `0x098eec2b`; map.html re-verified in-browser (renders a town, chunk-grid overlay +works, zero console errors).** No screenshot refresh needed — the changes don't alter the plan's 2D +appearance (the map colours by type/use, not by facade or hours). + +| # | request (who asked) | what I changed | files | +|---|---|---|---| +| R1 | **"Which field marks the open-late shop, and what's the `hours` encoding?"** (Lane F §8); "exactly one late-night shop per town" (F §3.5) | The old `sr()<0.06` per-shop dice yielded **0…many** late shops and — because its formula was `max(close,19)+0..2` — usually **none past 22:00**, so F's `hours[1]>=22` gate was flaky/false. Replaced it with **exactly one deterministic `openLate:true` landmark** per town, chosen after de-confliction from a naturally-late type (video/milk bar, never a stall), closing **22:00 or 23:00**. Encoding documented: `hours=[open,close]`, 24h ints, `0≤open s.openLate)`), NOT a magic hours threshold. } ``` +> **Field-type contract** (Lane A is the source of truth; consumer *fixtures* must match this, not the +> reverse). Every `id` and every reference to one — `lot.block`, `lot.frontEdge`, `edge.a`/`b`, +> `block.district`, `shop.lot` — is an **integer**, not a string. `facadeSkin`, and every entry in a +> registry `facades` pool, is a **full filename** under `web/assets/gen/` (e.g. `"facade-fibro-blue.jpg"`) +> — load it verbatim; do **not** prepend `facade-` or append an extension (the shop-types table below +> abbreviates skin names for readability only). The same rule holds for Lane E's ground/awning/sky/wall/ +> interior skins: store the file exactly as the manifest names it, never re-derive a key from it. + > **Layer-1 invariants Lane A guarantees** (enforced by `web/js/citygen/selfcheck.js`): fully > deterministic per `citySeed` (with a committed golden fingerprint guarding against drift); every > `frontEdge`/`block`/`district`/`lot` reference resolves; every lot faces its `frontEdge`; **no two -> building lots (`shop`/`anchor`/`house`/`stall`) overlap, within OR across blocks**; `chunkIndex` -> covers every lot and lists every edge in every chunk its road crosses; strictly JSON round-trippable -> (all finite numbers, no stray keys). +> building lots (`shop`/`anchor`/`house`/`stall`) overlap, within OR across blocks**; **exactly one shop +> carries `openLate`** (the late-night landmark); `chunkIndex` covers every lot and lists every edge in +> every chunk its road **corridor (road + verge, centreline ± width/2)** touches — so verge-placed street +> furniture never resolves to a chunk that omits its edge; strictly JSON round-trippable (all finite +> numbers, no stray keys). Chunk key: `cx = floor(x/64)`, `cz = floor(z/64)`. **Chunk size 64m.** Lane A ships a -`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it. +`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it. Edges are bucketed across +the full road **corridor** (centreline ± `width/2`), so anything placed in the verge (street furniture, +footpath NPCs) resolves to a chunk whose `edges` list includes its edge. ### Layer 2 — Streetscape (Lane B) @@ -115,6 +131,14 @@ night in v1; interior-mapping shader is a stretch goal). > `toy`/`book`/`pawn` and rounded out the fittings mixes — a `counter` to shops that sell over one, > a `freezer` to milkbar, a `glass_case`+`counter` to dept — so this table matches the registry > lanes actually consume. Non-breaking; all facades exist in `web/assets/gen/`.)* +> +> *(Lane A amendment 2026-07-14 · round 2, responding to B/E/F integration notes: gave `stall` a 2nd +> facade `facade-corrugated.jpg` (a market-shed backdrop, matching Lane E's manifest facade→type map, +> so every type now has ≥ 2 facades per Lane E's validator). Replaced the old per-shop 6% "open late" +> dice — which yielded 0…many late shops and often none past 22:00 — with **exactly one** deterministic +> `openLate` landmark per town (see the schema note above). Widened `chunkIndex` edge coverage to the +> full road corridor. All non-breaking; self-check green at 1283/1283; golden fingerprint refreshed to +> `0x098eec2b`.)* | type | facade pool | interior archetype | fittings mix | |---|---|---|---| @@ -126,7 +150,7 @@ night in v1; interior-mapping shader is a stretch goal). | `pawn` | besser, grimy, djsim-pawn, corrugated | counter-forward | glass cabinets, wall hooks, barred window, counter | | `milkbar` | djsim-milkbar, brickveneer | counter + freezer | counter, fridge, magazine rack, freezer | | `dept` (anchor) | terrazzo, djsim-dept | grand hall | mixed sections, escalator prop, glass case, counter | -| `stall` (market) | facade-market | open stall | trestle tables, crates | +| `stall` (market) | market, corrugated | open stall | trestle tables, crates | ## NPC contract (Lane D) diff --git a/web/js/citygen/plan.js b/web/js/citygen/plan.js index 5aa2b96..deaabfb 100644 --- a/web/js/citygen/plan.js +++ b/web/js/citygen/plan.js @@ -78,8 +78,9 @@ export function generatePlan(citySeed) { let open = reg.hours.open, close = reg.hours.close; 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 (sr() < 0.06) close = Math.min(23, Math.max(close, 19) + ((sr() * 3) | 0)); // the weirdo open late if (close <= open) close = clampHour(open + 2); + // (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); shops.push({ id, lot: lotId, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] }); return id; @@ -351,6 +352,23 @@ export function generatePlan(citySeed) { } } + // ── exactly one late-night landmark: the town's designated "open till late" shop ────── + // 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. + 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'); + 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 + } + } + return { version: 1, citySeed, @@ -395,35 +413,28 @@ export function chunkIndex(plan, chunkSize = CHUNK) { for (const shop of plan.shops) for (const k of (lotKeys.get(shop.lot) || [])) bucket(k).shops.push(shop.id); - // edges → every chunk the ROAD covers. Grid-supercover (Amanatides–Woo) traversal visits every - // cell a segment passes through with no gaps (point-sampling skipped diagonally-crossed cells — - // notably the origin chunk under the spine). Run it on the centreline AND the two kerb rails - // (centreline ± halfWidth), so a wide road registers in every chunk its surface touches. + // edges → every chunk the road CORRIDOR touches (centreline ± width/2 — the road + verge band + // where Lane B drops verandah posts, trees and street furniture). Each edge is split into + // ≤chunkSize pieces and every cell of each piece's corridor-rectangle AABB is bucketed. This is + // gap-free by construction: every point of the corridor lies inside some piece's AABB, so a prop + // placed anywhere in the verge always resolves to a chunk that lists its edge — closing the + // furniture-drop class Lane B hit against the old centreline-only sampling. (selfcheck asserts it.) const addEdgeCell = (id, cx, cz) => { const bk = bucket(chunkKey(cx, cz)); if (!bk.edges.includes(id)) bk.edges.push(id); }; - const traverse = (id, x0, z0, x1, z1) => { - let cx = cell(x0), cz = cell(z0); - const ecx = cell(x1), ecz = cell(z1), dx = x1 - x0, dz = z1 - z0; - const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0, stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0; - const tDeltaX = dx !== 0 ? Math.abs(chunkSize / dx) : Infinity; - const tDeltaZ = dz !== 0 ? Math.abs(chunkSize / dz) : Infinity; - let tMaxX = dx !== 0 ? ((stepX > 0 ? (cx + 1) * chunkSize : cx * chunkSize) - x0) / dx : Infinity; - let tMaxZ = dz !== 0 ? ((stepZ > 0 ? (cz + 1) * chunkSize : cz * chunkSize) - z0) / dz : Infinity; - addEdgeCell(id, cx, cz); - let guard = 0; - while ((cx !== ecx || cz !== ecz) && guard++ < 100000) { - if (tMaxX < tMaxZ) { cx += stepX; tMaxX += tDeltaX; } else { cz += stepZ; tMaxZ += tDeltaZ; } - addEdgeCell(id, cx, cz); - } - }; const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n])); for (const e of plan.streets.edges) { const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1; - const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw; // perpendicular kerb offset - traverse(e.id, a.x, a.z, b.x, b.z); - if (hw > 0) { - traverse(e.id, a.x + px, a.z + pz, b.x + px, b.z + pz); - traverse(e.id, a.x - px, a.z - pz, b.x - px, b.z - pz); + const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw; // perpendicular half-corridor + const pieces = Math.max(1, Math.ceil(len / chunkSize)); + for (let s = 0; s < pieces; s++) { + const x0 = a.x + dx * s / pieces, z0 = a.z + dz * s / pieces; + const x1 = a.x + dx * (s + 1) / pieces, z1 = a.z + dz * (s + 1) / pieces; + let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity; // AABB of this piece's corridor rect + for (const [qx, qz] of [[x0 + px, z0 + pz], [x0 - px, z0 - pz], [x1 + px, z1 + pz], [x1 - px, z1 - pz]]) { + if (qx < minx) minx = qx; if (qx > maxx) maxx = qx; if (qz < minz) minz = qz; if (qz > maxz) maxz = qz; + } + for (let cx = cell(minx); cx <= cell(maxx); cx++) + for (let cz = cell(minz); cz <= cell(maxz); cz++) addEdgeCell(e.id, cx, cz); } } return { chunkSize, chunks }; diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index ca0de3c..a19dc42 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: 0xb5d5cc13 }; +const GOLDEN = { seed: 20261990, hash: 0x098eec2b }; { 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)}`); @@ -140,20 +140,28 @@ for (const s of SEEDS) { } ok(plan.lots.every(l => covered.has(l.id)), `seed ${s}: chunkIndex covers every lot`); - // CHUNKINDEX edge coverage: every chunk the centreline densely passes through is listed (review D3) + // CHUNKINDEX corridor coverage: every chunk the road CORRIDOR passes through lists the edge — not + // just the centreline. Samples densely along the edge AND across the ±width/2 verge band (incl. + // offsets BETWEEN the chunkIndex rails, so this genuinely tests band interior, not just the rails). + // Guards the Lane B furniture-drop class: verge props must never fall in a chunk that omits their edge. const cell = v => Math.floor(v / CHUNK); let edgeGap = null; for (const e of plan.streets.edges) { const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; - const len = Math.hypot(b.x - a.x, b.z - a.z), steps = Math.max(1, Math.ceil(len / 2)); // 2m dense samples - for (let k = 0; k <= steps && !edgeGap; k++) { - const x = a.x + (b.x - a.x) * k / steps, z = a.z + (b.z - a.z) * k / steps; - const bk = idx.chunks[`${cell(x)},${cell(z)}`]; - if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, cell(x), cell(z)]; + const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1; + const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw; + const steps = Math.max(1, Math.ceil(len / 2)); // 2m dense along-edge samples + for (const f of [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]) { // across the corridor band + for (let k = 0; k <= steps && !edgeGap; k++) { + const x = a.x + dx * k / steps + px * f, z = a.z + dz * k / steps + pz * f; + const bk = idx.chunks[`${cell(x)},${cell(z)}`]; + if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, f, cell(x), cell(z)]; + } + if (edgeGap) break; } if (edgeGap) break; } - ok(!edgeGap, `seed ${s}: chunkIndex lists every edge in every chunk its centreline crosses` + (edgeGap ? ` (edge ${edgeGap[0]} missing @ ${edgeGap[1]},${edgeGap[2]})` : '')); + ok(!edgeGap, `seed ${s}: chunkIndex covers the full road corridor (road+verge)` + (edgeGap ? ` (edge ${edgeGap[0]} @ off ${edgeGap[1]} missing @ ${edgeGap[2]},${edgeGap[3]})` : '')); // JSON round-trip is lossless (and carries no stray non-schema keys) ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `seed ${s}: JSON round-trip lossless`); @@ -167,6 +175,11 @@ 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) + const late = plan.shops.filter(sh => sh.openLate); + 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`); + for (const sh of plan.shops) facadeSet.add(sh.facadeSkin); } diff --git a/web/js/core/registry.js b/web/js/core/registry.js index b23680e..ab5b1f0 100644 --- a/web/js/core/registry.js +++ b/web/js/core/registry.js @@ -112,7 +112,7 @@ export const SHOP_TYPES = { }, stall: { label: 'Market Stall', - facades: ['facade-market.jpg'], + facades: ['facade-market.jpg', 'facade-corrugated.jpg'], // canvas market + corrugated shed backdrop (≥2 per Lane E) sign: { bg: '#e8e2d0', fg: '#5c3b1e', accent: '#c0392b', style: 'handpainted' }, interior: 'open_stall', fittings: ['trestle_tables', 'crates'],