Lane C: keeper stand pose on the counter contract (Lane D/F integration)
Answers Lane F §8 (the counter is kind:'counter' in `places`) and closes the
keeper-spawn gap: buildInterior now returns room.counter = { mesh, pose, stand }
and tags the counter interactable with userData.keeperStand. `stand` = {x,z,ry}
where a shopkeeper stands (service side, facing the customer), in room-local
space, rig-front = local -Z per keepers.js. Deterministic; purely additive.
Verified in-browser: 36-build type sweep + 50-room soak — stand in-bounds,
faces customer (dot>0 all), carried on `places` (deep-equal), deterministic;
no change to existing placement, path, leaks, or perf (avg 3.6ms / worst 7.7ms,
geo/tex leak 0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0e9d3fb0f2
commit
42350607a0
@ -1,6 +1,7 @@
|
|||||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
# 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)*
|
*Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)*
|
||||||
|
|
||||||
## TL;DR
|
## TL;DR
|
||||||
@ -124,3 +125,37 @@ required infra — now added to the table).
|
|||||||
facing/cross-block-overlap/edge-coverage/finiteness/fingerprint assertions). Seed 20261990 →
|
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
|
"Boolarra Heads": 27 nodes, 22 edges, 35 blocks, 681 lots, 493 shops, 174 chunks. All 5 seed
|
||||||
screenshots re-captured. Golden fingerprint `0xb5d5cc13`.
|
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<close≤23`. | `plan.js`, `CITY_SPEC.md` |
|
||||||
|
| R2 | **Skin-name double-prefix trap** — registry stores full filenames, `skins.js` expected bare keys → `facade-facade-…jpg.jpg` 404s (Lane F §7); **"pick one and say so"** on field types (Lane B) | Added a **Field-type contract** to CITY_SPEC: all ids + id-refs are **integers**; `facadeSkin`/registry `facades` entries are **full filenames** under `web/assets/gen/`, loaded verbatim (extended to E's ground/awning/sky/wall/interior skins). Lane A is the source of truth; consumer fixtures conform to it. | `CITY_SPEC.md` |
|
||||||
|
| R3 | **`chunkIndex` furniture-drop** — verge props at a perpendicular offset bucketed into a chunk that omitted their edge (Lane B) | Rewrote edge bucketing from a 3-rail supercover to a **gap-free corridor rasterisation**: each edge is split into ≤64m pieces and every cell of each piece's `±width/2` corridor-rect AABB is bucketed. Now *provably* covers the full road+verge band. Self-check samples 9 offsets across the band (incl. between-rail) to prove it. | `plan.js`, `selfcheck.js` |
|
||||||
|
| R4 | **`stall` pool has only 1 facade** (Lane E validator wants ≥2) | Added `facade-corrugated.jpg` (market-shed backdrop) to the `stall` pool — matches Lane E's manifest facade→type map, which already tags corrugated for stall. Every type now has ≥2 facades. | `registry.js`, `CITY_SPEC.md` |
|
||||||
|
|
||||||
|
**New self-check assertions (regression guards):** exactly one `openLate` shop per seed; the late
|
||||||
|
shop closes ≥22:00 and isn't a stall; `chunkIndex` covers the full road corridor sampled densely
|
||||||
|
across the verge band (not just the centreline).
|
||||||
|
|
||||||
|
**Empirical result** (5 seeds): every town gets one late-night video store — e.g. seed 20261990 →
|
||||||
|
"Yass VIDEO" open till 23:00, seed 42 → "MOVIES" till 23:00, seed 1 → "RENTALS" till 22:00. Both
|
||||||
|
stall facades now appear in the market square.
|
||||||
|
|
||||||
|
### Notes for Fable / other lanes
|
||||||
|
- **Lane B can drop its private furniture-rasterisation workaround** if it likes — `chunkIndex.edges`
|
||||||
|
now covers the whole corridor at the source, so any consumer (incl. Lane D footpath NPCs) can trust it.
|
||||||
|
- **Orientation:** CITY_SPEC (§Units/axes + Layer-1 schema) already specifies GLB front = **−Z** at
|
||||||
|
`ry=0`, world facing `(−sin ry, −cos ry)` toward `frontEdge`; the self-check enforces it and B renders
|
||||||
|
it correctly. Lane B's note prose says "+Z" informally — flagging only so the **−Z** spec stays the
|
||||||
|
single reference for Lane C/E when modelling facades. No code change; it works.
|
||||||
|
- **Still-open design questions from round 1** (retail density ~493/core vs Lane B's draw budget; true
|
||||||
|
closed residential ring) remain parked for your call — both are one-constant tunes, not rework.
|
||||||
|
|||||||
@ -71,20 +71,36 @@ Schema v1 (Lane A owns it; extend, don't break):
|
|||||||
// ry: Y-rotation (radians) so a GLB modelled facing −Z ends up with its facade's OUTWARD normal
|
// ry: Y-rotation (radians) so a GLB modelled facing −Z ends up with its facade's OUTWARD normal
|
||||||
// pointing at frontEdge. World facing = (−sin ry, −cos ry). Lane B rotates the shell by ry.
|
// pointing at frontEdge. World facing = (−sin ry, −cos ry). Lane B rotates the shell by ry.
|
||||||
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
|
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
|
||||||
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] } ]
|
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ]
|
||||||
// type: see SHOP TYPES below. hours: [open, close] with 0 ≤ open < close ≤ 23.
|
// type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close ≤ 23.
|
||||||
|
// openLate: present and `true` on EXACTLY ONE shop per town — the designated late-night landmark
|
||||||
|
// (closes ≥ 22:00; a naturally-late type, video rental or milk bar, never a market stall). Absent
|
||||||
|
// on every other shop. Lane B lights it after dark; Lane F's night gate keys off this field
|
||||||
|
// (`plan.shops.find(s => 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
|
> **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
|
> 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
|
> `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`
|
> building lots (`shop`/`anchor`/`house`/`stall`) overlap, within OR across blocks**; **exactly one shop
|
||||||
> covers every lot and lists every edge in every chunk its road crosses; strictly JSON round-trippable
|
> carries `openLate`** (the late-night landmark); `chunkIndex` covers every lot and lists every edge in
|
||||||
> (all finite numbers, no stray keys).
|
> 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
|
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)
|
### 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,
|
> `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
|
> 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/`.)*
|
> 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 |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `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)
|
## NPC contract (Lane D)
|
||||||
|
|
||||||
|
|||||||
@ -78,8 +78,9 @@ export function generatePlan(citySeed) {
|
|||||||
let open = reg.hours.open, close = reg.hours.close;
|
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) open = clampHour(open + (sr() < 0.5 ? -1 : 1));
|
||||||
if (sr() < 0.3) close = clampHour(close + (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);
|
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);
|
const { name, sign } = shopName(seed, type);
|
||||||
shops.push({ id, lot: lotId, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] });
|
shops.push({ id, lot: lotId, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] });
|
||||||
return id;
|
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 {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
citySeed,
|
citySeed,
|
||||||
@ -395,35 +413,28 @@ export function chunkIndex(plan, chunkSize = CHUNK) {
|
|||||||
for (const shop of plan.shops)
|
for (const shop of plan.shops)
|
||||||
for (const k of (lotKeys.get(shop.lot) || [])) bucket(k).shops.push(shop.id);
|
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
|
// edges → every chunk the road CORRIDOR touches (centreline ± width/2 — the road + verge band
|
||||||
// cell a segment passes through with no gaps (point-sampling skipped diagonally-crossed cells —
|
// where Lane B drops verandah posts, trees and street furniture). Each edge is split into
|
||||||
// notably the origin chunk under the spine). Run it on the centreline AND the two kerb rails
|
// ≤chunkSize pieces and every cell of each piece's corridor-rectangle AABB is bucketed. This is
|
||||||
// (centreline ± halfWidth), so a wide road registers in every chunk its surface touches.
|
// 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 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]));
|
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
|
||||||
for (const e of plan.streets.edges) {
|
for (const e of plan.streets.edges) {
|
||||||
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
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 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
|
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw; // perpendicular half-corridor
|
||||||
traverse(e.id, a.x, a.z, b.x, b.z);
|
const pieces = Math.max(1, Math.ceil(len / chunkSize));
|
||||||
if (hw > 0) {
|
for (let s = 0; s < pieces; s++) {
|
||||||
traverse(e.id, a.x + px, a.z + pz, b.x + px, b.z + pz);
|
const x0 = a.x + dx * s / pieces, z0 = a.z + dz * s / pieces;
|
||||||
traverse(e.id, a.x - px, a.z - pz, b.x - px, b.z - pz);
|
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 };
|
return { chunkSize, chunks };
|
||||||
|
|||||||
@ -40,7 +40,7 @@ for (const s of SEEDS) {
|
|||||||
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
|
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
|
||||||
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
|
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
|
||||||
section('golden fingerprint (output-drift guard)');
|
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;
|
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)}`);
|
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`);
|
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);
|
const cell = v => Math.floor(v / CHUNK);
|
||||||
let edgeGap = null;
|
let edgeGap = null;
|
||||||
for (const e of plan.streets.edges) {
|
for (const e of plan.streets.edges) {
|
||||||
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
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
|
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
|
||||||
for (let k = 0; k <= steps && !edgeGap; k++) {
|
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw;
|
||||||
const x = a.x + (b.x - a.x) * k / steps, z = a.z + (b.z - a.z) * k / steps;
|
const steps = Math.max(1, Math.ceil(len / 2)); // 2m dense along-edge samples
|
||||||
const bk = idx.chunks[`${cell(x)},${cell(z)}`];
|
for (const f of [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]) { // across the corridor band
|
||||||
if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, cell(x), cell(z)];
|
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;
|
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)
|
// 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`);
|
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;
|
const milkbars = plan.shops.filter(sh => sh.type === 'milkbar').length;
|
||||||
ok(milkbars >= 2 && milkbars <= 4, `seed ${s}: 2–4 corner milk bars (${milkbars})`);
|
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);
|
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -112,7 +112,7 @@ export const SHOP_TYPES = {
|
|||||||
},
|
},
|
||||||
stall: {
|
stall: {
|
||||||
label: 'Market 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' },
|
sign: { bg: '#e8e2d0', fg: '#5c3b1e', accent: '#c0392b', style: 'handpainted' },
|
||||||
interior: 'open_stall',
|
interior: 'open_stall',
|
||||||
fittings: ['trestle_tables', 'crates'],
|
fittings: ['trestle_tables', 'crates'],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user