Lane A (CityGen): deterministic town generator, registry, names, 2D map, self-check

Layer-1 CityPlan generator per CITY_SPEC schema v1 — pure data, no THREE, all randomness
through core/prng, deterministic + JSON-serializable + <1ms.

- web/js/citygen/plan.js: generatePlan(citySeed) → CityPlan (spine, cross-streets, market
  square + dept anchor, arcade, warehouse fringe, residential collar w/ corner milkbars,
  laneways) + chunkIndex (Amanatides–Woo edge supercover) + corner overlap-resolution pass.
- web/js/core/registry.js: SHOP_TYPES (all 9) + district weights + enums (shared vocabulary).
- web/js/citygen/names.js + wordlists.js: seeded 90s-AU shop/town names, 50+ patterns.
- web/js/citygen/index.js: canonical barrel entry (adopts Lane F's integration shim).
- web/map.html: standalone Canvas-2D plan debugger (pan/zoom/hover, layers, export JSON).
- web/js/citygen/selfcheck.js: node acceptance harness — determinism, golden fingerprint,
  facing, within- AND cross-block no-overlap, chunk+edge coverage, finiteness, assets. ALL GREEN
  (1271/1271) over 6 seeds. web/package.json ({"type":"module"}) lets node run it.
- docs/shots/laneA/: 5 seeds screenshotted, each reads as a town.

Fixes from an adversarial multi-agent review (15 confirmed defects): market facades faced the
wrong way; cross-block corner overlaps; chunkIndex skipped chunks incl. the origin/spine;
stall frontEdge z-band; doubled-possessive names; unparseable map road colour; +others.

CITY_SPEC amendment (treaty, same commit): shop-types table aligned to the registry lanes
import; documented lot.ry/frontEdge, block.kind/district-id, and web/package.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 12:06:41 +10:00
parent a702a69f1e
commit 8b7ca9aae4
16 changed files with 1486 additions and 9 deletions

126
docs/A-progress.md Normal file
View File

@ -0,0 +1,126 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed & self-check all-green.** Awaiting Fable review.*
*Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)*
## TL;DR
`generatePlan(citySeed) → CityPlan` is done: a deterministic, JSON-serializable, <1ms pure-data
town generator matching CITY_SPEC schema v1, plus `chunkIndex`, the shared shop-type registry, a
seeded 90s-AU name generator, a Canvas-2D map debugger, and a node self-check that asserts the
whole acceptance contract. `node web/js/citygen/selfcheck.js` prints **ALL GREEN (1082/1082)**.
Five seeds render as recognisable towns (screenshots in `docs/shots/laneA/`).
## Deliverables (all present)
| # | file | what |
|---|---|---|
| 1 | [web/js/citygen/plan.js](../web/js/citygen/plan.js) | `generatePlan(citySeed)`, `chunkIndex(plan)`, `CHUNK`, `chunkKey` |
| 2 | [web/js/citygen/names.js](../web/js/citygen/names.js) + [wordlists.js](../web/js/citygen/wordlists.js) | seeded shop+town names, 50+ patterns, short signboard forms |
| 3 | [web/js/core/registry.js](../web/js/core/registry.js) | `SHOP_TYPES` (all 9), district weights, enums, pure helpers |
| 4 | [web/map.html](../web/map.html) | standalone Canvas-2D plan viewer (pan/zoom/hover, layer toggles, seed box, regen, export JSON, chunk grid) |
| 5 | [web/js/citygen/selfcheck.js](../web/js/citygen/selfcheck.js) | `node web/js/citygen/selfcheck.js` — full acceptance harness |
| + | [web/package.json](../web/package.json) | `{"type":"module"}` so `node` runs the self-check as ESM (see *Treaty notes*) |
| + | `docs/shots/laneA/seed-*.png` | 5 seeds screenshotted (20261990, 1, 42, 777, 8675309) |
## What the generator produces (matches the CITY_SPEC design brief)
- **Main-street spine** — 7 stations S→N through the origin, x-jitter ±30m, 28m corridor, `kind:'main'`.
Continuous narrow-frontage (69m) retail both sides.
- **Cross streets** at 4 stations; the two central ones are wider "second high streets", the outer
two are grittier `backstreets`. All `kind:'side'`, ±8° jitter.
- **Market square** west of the origin — a tidy 5×8 grid of `stall` lots + the `dept` anchor
fronting the spine.
- **Arcade** — a `kind:'arcade'` (width 5) pedestrian lane cutting east through a mid-spine block,
lined both sides with tiny 35m record-heavy shops.
- **Warehouse fringe** — sparse big lots beyond one spine end (N or S, seeded), mostly `infill`
with the odd pawn shop.
- **Residential collar** — a loose ring road of `house` lots with **24 corner milk bars** embedded.
- **Laneways** (`kind:'lane'`) behind the central blocks, with `yard` lots for back-door flavour.
Sample (seed 20261990 → "Boolarra Heads"): 27 nodes, 22 edges, 35 blocks, 711 lots, 523 shops, 152 chunks.
## Self-check coverage (`node web/js/citygen/selfcheck.js`)
Determinism (two runs byte-identical) · <100ms (actually <1ms) · every edgereal nodes · every blockreal
district · every lot→real block + **valid frontEdge** + positive size · every shop→real lot + known type +
**facade skin exists on disk** + sane hours + named · one-shop-per-lot · **no overlapping lots within a
block** (rotated-rect OBB / SAT test) · **chunkIndex covers every lot** & buckets reference real ids ·
JSON round-trip lossless · design-brief presence (spine, arcade, market, stalls, dept anchor, 24 milkbars) ·
all registry + used facades exist in `web/assets/gen/`. Run over 6 seeds → **1082/1082**.
## House-law compliance
- **Zero `Math.random` and zero THREE imports in `js/citygen/*` and `registry.js`** (grep-clean).
All randomness flows through `web/js/core/prng.js`. (map.html uses `Math.random` only to pick a
*random seed to view* — the generation it then runs is fully seeded; the map is pure Canvas 2D,
no THREE.)
- JSON-serializable, deterministic, <100ms verified.
- Registry is flat data every lane imports; facade pools are real files in `web/assets/gen/`.
## Treaty / cross-lane notes for Fable
1. **`web/package.json` added.** Needed so `node web/js/citygen/selfcheck.js` runs the ES modules
(browsers use `<script type=module>`; node needs `"type":"module"`). It's additive infra —
python `http.server` and browsers ignore it, no deps, no build. Not in the ownership table;
flagging for your blessing. Other lanes can now node-test their pure modules too.
2. **Schema extensions (non-breaking).** Added `kind` to each `block` (its district-kind string,
alongside the spec's `district` **id**) so the map & Lane B can theme a block without a district
lookup. `block.district` is the **district id**; `districts[id].kind` is the kind. Everything
else matches schema v1 exactly.
3. **`ry` convention (Lane B please read):** a lot's `ry` is the Y-rotation so a GLB modelled
facing **Z** ends up with its facade's outward normal pointing at its `frontEdge` street.
Derivation + a worked example are in `plan.js` (`marchStrip`). `frontEdge` is a valid edge id
for every lot (stalls/dept front the nearest spine edge).
4. **`pipeline/gen_names.py` intentionally NOT shipped.** The Lane A brief offered an *optional*
Ollama word-expansion script, but `pipeline/*` is **Lane E's** owned dir per the CITY_SPEC
ownership table. Wordlists are hand-authored and checked in; runtime does zero network calls.
If you want the Ollama expansion, it should live in Lane E's pipeline or be re-assigned.
## How to eyeball it
```
cd web && python3 -m http.server 8130
# http://localhost:8130/map.html (default seed)
# http://localhost:8130/map.html?seed=42 (any uint32 seed; ?seed= drives the screenshot harness)
node web/js/citygen/selfcheck.js # acceptance harness, prints ALL GREEN
```
## Open questions for Fable
- Retail density: a core seed yields ~500 shops (continuous frontage, as briefed). Happy with that,
or dial down for Lane B's draw-call budget? (It's one constant per band; trivial to tune.)
- Want the residential collar to be a true closed ring vs the current 4-edge loop? (Current reads
fine on the map; a fuller street graph is a later enhancement.)
## Adversarial review + fixes (round 1)
Ran a 6-dimension multi-agent review of the whole lane (27 agents: determinism · schema/spec ·
geometry · chunk/JSON/perf · cross-lane contract · code quality), each finding independently
verified by a skeptic agent that ran the real generator to confirm or refute. **21 findings raised,
15 confirmed real, 6 refuted.** All confirmed defects are now **fixed and re-verified**; the
self-check grew new assertions so none of these classes can regress silently.
| # | defect (confirmed) | sev | fix |
|---|---|---|---|
| D1 | market stalls + dept anchor faced **backwards** (`ryEast` sign inverted → facades pointed west, away from the spine they front) | HIGH | `ryEast = atan2(-1,0)`; self-check now asserts every lot faces its `frontEdge` |
| D2 | **cross-block lot overlaps** at spine×cross-street corners (≈45 building-lot pairs/seed, ≤8.7m) — self-check only tested *within* a block | HIGH | reserve corner (rung near-spine inset 14→34m) + a deterministic overlap-resolution pass (demote later lot to `infill`); self-check now runs **global** cross-block OBB/SAT over building lots |
| D3 | `chunkIndex` **skipped chunks** (32m point-sampling on a 64m grid); the origin chunk under the spine was omitted; road width ignored | HIGH | AmanatidesWoo grid supercover + kerb rails (centreline ± half-width); self-check now asserts edge coverage vs a dense supercover |
| D4 | market stalls' `frontEdge` pointed at the wrong spine segment (z-band mismatch, up to 122m off) | MED | stalls → `spineEdges[2]`, dept → `spineEdges[3]` |
| D5 | name generator emitted **doubled possessives** (`Maccass`) — `{First}*s` fired both the `*` and the literal `s` | HIGH | dropped the redundant `*`; simplified `fill()`; self-check rejects unresolved tokens |
| D6 | map.html drew the main road with `var(--roadmain)`, which canvas can't parse → the spine rendered near-invisible | MED | pass the bare custom-property name to `getcss()` (now a proper brown spine) |
| D7 | registry facades/fittings drifted from the CITY_SPEC table | LOW | **amended CITY_SPEC's shop-types table** (in this commit, flagged) to match the registry lanes import |
| + | `_sbl` Map cache was memoized onto the plan → polluted exported JSON with `"_sbl":{}` | LOW | moved the cache to a module-level var, reset per regen |
| + | `cornerBoost` could push single-storey types (video/milkbar) above their registry max | LOW | gated to types with registry max ≥ 2 |
| + | no golden-fingerprint / cross-revision drift guard; lot coords never checked finite | — | added a committed golden hash for seed 20261990 + finiteness assertions |
| + | undocumented `block.kind` / `block.district`-is-id; dead stall guard | — | documented the schema in CITY_SPEC; removed dead code |
**Refuted (6)** — investigated and dismissed with reasons, e.g. "the determinism test is a
tautology" (true that it only catches in-process nondeterminism — so I *added* the golden fingerprint
for real drift detection) and "`package.json` isn't in the ownership table" (correct, but it's
required infra — now added to the table).
**Post-fix self-check:** `✓ ALL GREEN — 1271/1271` (was 1082; +189 checks are the new
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`.

View File

@ -63,14 +63,26 @@ Schema v1 (Lane A owns it; extend, don't break):
nodes: [ { id, x, z } ], nodes: [ { id, x, z } ],
edges: [ { id, a, b, width, kind } ] // kind: 'main' | 'side' | 'lane' | 'arcade' edges: [ { id, a, b, width, kind } ] // kind: 'main' | 'side' | 'lane' | 'arcade'
}, },
blocks: [ { id, district, poly: [[x,z],…] } ], blocks: [ { id, district, kind, poly: [[x,z],…] } ],
// district = district ID (index into districts[]); kind = that district's kind string, denormalized
// onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking).
lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ], lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ],
// use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill' // use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill'
// 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.
// 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] } ]
// type: see SHOP TYPES below // type: see SHOP TYPES below. hours: [open, close] with 0 ≤ open < close 23.
} }
``` ```
> **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).
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.
@ -97,16 +109,23 @@ night in v1; interior-mapping shader is a stretch goal).
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read) ## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for
> the exact facade pools, sign hints, interior archetype, fittings, storeys, hours and district
> weights. This table mirrors it. *(Lane A amendment 2026-07-14: added a variety facade to
> `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/`.)*
| type | facade pool | interior archetype | fittings mix | | type | facade pool | interior archetype | fittings mix |
|---|---|---|---| |---|---|---|---|
| `record` | timber-teal, arcade-tile, djsim-record | crates + bins | record bins, crates, counter, listening corner | | `record` | timber-teal, arcade-tile, djsim-record | crates + bins | record bins, crates, counter, listening corner |
| `opshop` | weatherboard, fibro-blue, djsim-opshop | racks + shelves | clothes racks, bric-a-brac shelving, book wall | | `opshop` | weatherboard, fibro-blue, djsim-opshop | racks + shelves | clothes racks, bric-a-brac shelving, book wall, counter |
| `toy` | stucco-pink, deco-pastel | shelves + glass case | cube shelves, display tables, glass case | | `toy` | stucco-pink, deco-pastel, boutique | shelves + glass case | cube shelves, display tables, glass case, counter |
| `book` | federation, sandstone | halls of shelves | bookshelves, spinner racks, armchair | | `book` | federation, sandstone, redbrick | halls of shelves | bookshelves, spinner racks, armchair, counter |
| `video` | stripmall, djsim-video | aisle shelves | VHS shelving, returns slot, poster wall | | `video` | stripmall, djsim-video | aisle shelves | VHS shelving, returns slot, poster wall, counter |
| `pawn` | besser, grimy, djsim-pawn | counter-forward | glass cabinets, wall hooks, barred window | | `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 | | `milkbar` | djsim-milkbar, brickveneer | counter + freezer | counter, fridge, magazine rack, freezer |
| `dept` (anchor) | terrazzo, djsim-dept | grand hall | mixed sections, escalator prop | | `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) | facade-market | open stall | trestle tables, crates |
## NPC contract (Lane D) ## NPC contract (Lane D)
@ -144,11 +163,18 @@ night in v1; interior-mapping shader is a stretch goal).
| `web/js/interiors/*`, `web/interior_test.html` | Lane C | | `web/js/interiors/*`, `web/interior_test.html` | Lane C |
| `web/js/citizens/*`, `web/citizens_test.html`, `web/models/*` | Lane D | | `web/js/citizens/*`, `web/citizens_test.html`, `web/models/*` | Lane D |
| `pipeline/*`, `web/assets/*` + `web/assets/manifest.json` | Lane E | | `pipeline/*`, `web/assets/*` + `web/assets/manifest.json` | Lane E |
| `web/package.json` | scaffold/shared — `{"type":"module"}` only, added by Lane A |
| `docs/*` | everyone, additively | | `docs/*` | everyone, additively |
Each lane also ships its own standalone test page so it can be verified without the others. Each lane also ships its own standalone test page so it can be verified without the others.
**Never edit another lane's files.** Integration (Lane F) happens after AE land. **Never edit another lane's files.** Integration (Lane F) happens after AE land.
> `web/package.json` (added by Lane A) contains only `{"type":"module"}` so `node` runs the pure
> `web/js/**` ES modules directly (e.g. `node web/js/citygen/selfcheck.js`, an acceptance criterion).
> Browsers and `python3 -m http.server` ignore it; no deps, no build step. It changes Node's module
> interpretation for every lane's `web/js/**` — which is what we want (all lanes can node-test their
> pure modules) — so it's flagged here as shared scope, not silently added.
## Infrastructure map ## Infrastructure map
- **This mac** — dev box, Blender + Unreal installed, Apple Silicon (no CUDA — all gen is - **This mac** — dev box, Blender + Unreal installed, Apple Silicon (no CUDA — all gen is

View File

BIN
docs/shots/laneA/seed-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

14
web/js/citygen/index.js Normal file
View File

@ -0,0 +1,14 @@
// PROCITY citygen — canonical Lane A public entry point (barrel).
//
// Consumers import the whole CityGen API from here (not from plan.js directly), e.g. Lane B's shell:
// const citygen = await import('./js/citygen/index.js'); citygen.generatePlan(seed) → CityPlan
// Keep this the stable import surface for the lane; internals may move between files behind it.
//
// (Originated as a Lane F integration shim because the shell imports ./js/citygen/index.js while the
// generator ships as plan.js; adopted here as the real Lane A entry point, per the shim's own note.)
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap } from './plan.js';
export { shopName, townName } from './names.js';
// Convenience default so `citygen.default` also resolves to the generator.
export { generatePlan as default } from './plan.js';

114
web/js/citygen/names.js Normal file
View File

@ -0,0 +1,114 @@
// PROCITY shop & town name generator — seeded, deterministic, 90s-Australian flavour.
// Given a shop's stable seed, produces the same {name, sign} forever. `sign` is the short form
// that fits a signboard (the game renders it big; `name` is the full directory/label string).
//
// Pure: only imports prng (mulberry32) + wordlists. No THREE, no network, no Math.random.
import { mulberry32 } from '../core/prng.js';
import * as W from './wordlists.js';
const pick = (r, arr) => arr[(r() * arr.length) | 0];
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
const initials = (r) => pick(r, ['A', 'B', 'C', 'D', 'E', 'G', 'J', 'K', 'M', 'P', 'R', 'S', 'T', 'V'])
+ ' & ' + pick(r, ['A', 'B', 'C', 'D', 'E', 'G', 'J', 'K', 'M', 'P', 'R', 'S', 'T', 'V']);
// Token → wordlist resolver. Each token draws from the bank with the given stream.
function tok(r, t) {
switch (t) {
case 'Word': return pick(r, W.WORDS);
case 'Adj': return pick(r, W.ADJ);
case 'Noun': return pick(r, W.NOUN);
case 'Suburb': return pick(r, W.SUBURB);
case 'Family': return pick(r, W.FAMILY);
case 'Saint': return pick(r, W.SAINT);
case 'Charity': return pick(r, W.CHARITY);
case 'First': return pick(r, W.FIRSTNAME);
case 'Animal': return pick(r, W.ANIMAL);
case 'Init': return initials(r);
default: return t;
}
}
// Fill "{Adj} {Noun}" style token patterns. Possessives are written with a literal s in the
// pattern itself (e.g. "{First}s Records", "{Saint}s Charity Barn") — one mechanism, no magic.
function fill(r, pattern) {
return pattern.replace(/\{(\w+)\}/g, (_, t) => tok(r, t));
}
// ── name patterns per shop type (≥40 across the board) ─────────────────────────────
const PATTERNS = {
record: [
'{Word} Records', '{Suburb} Sound Exchange', '{Adj} Groove', '{Word} & Wax',
'Rotation', '{First}s Records', 'The {Animal} Sound Co.', '{Suburb} Vinyl',
],
opshop: [
'St {Saint}s Opportunity Shop', '{Charity} Op Shop', 'The {Adj} Op Shop',
'{Suburb} Recycled', '{Saint}s Charity Barn', 'Second Time {Suburb}', 'The Op Shop',
],
toy: [
'{First}s Toys', 'The {Adj} Toy Box', '{Word} Toys & Games', '{Animal} Playthings',
'{Suburb} Toyworld-ish', 'Marbles & Kites', 'The Toy Nook',
],
book: [
'The {Adj} Book Barn', '{Suburb} Books', '{Word} & Sons Booksellers', 'The {Animal} Bookshop',
'Dusty Pages', '{Init} Books', 'Second-Hand Reads', 'The Book Cellar',
],
video: [
'{Suburb} Video', '{Adj} Video', 'Video {Word}', '{First}s Video',
'The Picture Show', 'Late Nite Video', 'Video Barn',
],
pawn: [
'{Suburb} Cash Converters-ish', '{First}s Pawn & Loan', '{Adj} Cash', 'The Cash Corner',
'{Init} Trading Co.', 'Quick Cash Pawn', 'Second Chance Loans',
],
milkbar: [
'{Family} Milk Bar', '{Suburb} Milk Bar', 'The {Adj} Corner Store', '{First}s Milk Bar',
'{Family}s Deli & Milk Bar', 'Corner Store', 'The Milk Bar',
],
dept: [
'{Suburb} Emporium', '{Family} & Co.', 'The Grand {Word}', '{Adj} Department Store',
'{Suburb} Trading Co.', 'Palais {Word}',
],
stall: [
'{First}s Stall', '{Word} Stall', '{Adj} Bits', 'Trash & Treasure', '{Animal} Wares',
'Bric-a-Brac', 'Sunday Stall',
],
};
// ── short signboard forms (what actually fits above the door) ──────────────────────
const SIGN_PATTERNS = {
record: ['{Word}', 'RECORDS', 'VINYL', '{Suburb} SOUND'],
opshop: ['OP SHOP', 'ST {Saint}S', '{Charity}', 'RECYCLED'],
toy: ['TOYS', '{Adj} TOYS', 'TOY BOX', 'GAMES'],
book: ['BOOKS', 'BOOK BARN', '{Suburb} BOOKS', 'READS'],
video: ['VIDEO', '{Suburb} VIDEO', 'MOVIES', 'RENTALS'],
pawn: ['CASH', 'PAWN', 'LOANS', '{Init}'],
milkbar: ['MILK BAR', '{Family}', 'DELI', 'CORNER STORE'],
dept: ['{Suburb}', 'EMPORIUM', '{Family} & CO'],
stall: ['STALL', 'BITS', 'BRIC-A-BRAC', 'WARES'],
};
// Public: name a shop from its stable seed + type. Returns { name, sign }.
export function shopName(seed, type) {
const r = mulberry32((seed ^ 0x9e3779b9) >>> 0); // decorrelate from other seed uses
const pats = PATTERNS[type] || PATTERNS.stall;
const signs = SIGN_PATTERNS[type] || SIGN_PATTERNS.stall;
const name = fill(r, pick(r, pats)).replace(/\s+/g, ' ').trim();
let sign = fill(r, pick(r, signs)).replace(/\s+/g, ' ').trim();
// Signboard can't be too long — fall back to a punchy word from the full name.
if (sign.length > 16) {
const word = name.split(' ').sort((a, b) => b.length - a.length)[0] || sign;
sign = word.slice(0, 16).toUpperCase();
}
return { name, sign };
}
// Public: name the town from the city seed.
export function townName(citySeed) {
const r = mulberry32((citySeed ^ 0x2545f491) >>> 0);
const root = pick(r, W.TOWN_ROOT);
const suffix = pick(r, W.TOWN_SUFFIX);
const usePrefix = r() < 0.35;
const prefix = usePrefix ? pick(r, W.TOWN_PREFIX) + ' ' : '';
return `${prefix}${cap(root)}${suffix ? ' ' + suffix : ''}`.trim();
}

430
web/js/citygen/plan.js Normal file
View File

@ -0,0 +1,430 @@
// PROCITY CityGen — Layer 1. generatePlan(citySeed) → CityPlan (pure JSON-serializable data).
// NO THREE import anywhere in this file (CITY_SPEC law). All randomness flows through core/prng.
// Deterministic: same citySeed ⇒ byte-identical plan, forever, in <100ms.
//
// Coordinate frame (CITY_SPEC): metres, +Y up, ground = XZ. Origin (0,0) = centre of the town.
// +x = east, -x = west, +z = north, -z = south. The main-street spine runs roughly NS.
//
// A lot's `ry` is the Y-rotation that makes its facade's outward normal point at its street edge
// (GLB convention: model faces -Z at ry=0). Lane B rotates the building shell by ry.
import { rng, seedFor, mulberry32, pick, frange, irange, shuffle } from '../core/prng.js';
import { SHOP_TYPES, pickWeightedType } from '../core/registry.js';
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));
// 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.
export function lotCorners(l) {
const cs = Math.cos(l.ry), sn = Math.sin(l.ry), hw = l.w / 2, hd = l.d / 2;
return [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]].map(([ox, oz]) => [
l.x + ox * cs + oz * sn, l.z - ox * sn + oz * cs,
]);
}
// Separating-Axis-Theorem overlap of two convex quads, tolerance 5cm (absorbs 2dp rounding at
// touching edges without masking real overlaps, which are metres). Axes are normalised so EPS is metric.
export function obbOverlap(a, b) {
const EPS = 0.05;
for (const quad of [a, b]) {
for (let i = 0; i < quad.length; i++) {
const p1 = quad[i], p2 = quad[(i + 1) % quad.length];
let ax = -(p2[1] - p1[1]), az = p2[0] - p1[0];
const m = Math.hypot(ax, az) || 1; ax /= m; az /= m;
let minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity;
for (const [x, z] of a) { const d = x * ax + z * az; if (d < minA) minA = d; if (d > maxA) maxA = d; }
for (const [x, z] of b) { const d = x * ax + z * az; if (d < minB) minB = d; if (d > maxB) maxB = d; }
if (maxA < minB + EPS || maxB < minA + EPS) return false;
}
}
return true;
}
export function generatePlan(citySeed) {
citySeed = citySeed >>> 0;
// ── plan accumulators + id-stamping factories ───────────────────────────────────
const nodes = [], edges = [], districts = [], blocks = [], lots = [], shops = [];
let nid = 0, eid = 0, did = 0, bid = 0, lid = 0, sid = 0;
const addNode = (x, z) => { const n = { id: nid++, x: r2(x), z: r2(z) }; nodes.push(n); return n; };
const addEdge = (a, b, width, kind) => { const id = eid++; edges.push({ id, a, b, width, kind }); return id; };
const addDistrict = (kind, cx, cz) => { const id = did++; districts.push({ id, kind, cx: r2(cx), cz: r2(cz) }); return id; };
const addBlock = (district, kind, poly) => {
const id = bid++;
blocks.push({ id, district, kind, poly: poly.map(p => [r2(p[0]), r2(p[1])]) });
return id;
};
const addLot = (block, x, z, w, d, ry, frontEdge, use) => {
const id = lid++;
lots.push({ id, block, x: r2(x), z: r2(z), w: r2(w), d: r2(d), ry: r4(ry), frontEdge, use });
return id;
};
// Create a shop record for a lot. `type` from registry; facade/storeys/hours seeded off id.
function createShop(lotId, type, opts = {}) {
const id = sid++;
const seed = seedFor(citySeed, 'shop', id);
const reg = SHOP_TYPES[type] || SHOP_TYPES.opshop;
const sr = mulberry32(seed);
const facadeSkin = pick(sr, reg.facades);
let storeys = reg.storeys[0] + ((sr() * (reg.storeys[1] - reg.storeys[0] + 1)) | 0);
// occasional 3-storey corner anchor — but only for types that already build tall (registry
// max ≥ 2); never make an inherently single-storey type (video/milkbar/stall) taller.
if (opts.cornerBoost && reg.storeys[1] >= 2 && sr() < 0.5) storeys = Math.min(3, storeys + 1);
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);
const { name, sign } = shopName(seed, type);
shops.push({ id, lot: lotId, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] });
return id;
}
// March lots along one side of a street edge A→B. Lots are sequential intervals along the edge
// ⇒ never overlap within their block by construction. Returns { blockId, lots:[{lot,use,cx,cz}] }.
function marchStrip(streamKind, streamId, districtId, districtKind, edgeId, A, B, corridorW, side, band) {
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz);
const out = { blockId: -1, lots: [] };
if (len < 1) return out;
const ux = dx / len, uz = dz / len;
const nx = side > 0 ? -uz : uz; // outward normal: street → lot
const nz = side > 0 ? ux : -ux;
const half = corridorW / 2;
const stripDepth = band.dmax + 2;
const c0 = [A.x + nx * half, A.z + nz * half];
const c1 = [B.x + nx * half, B.z + nz * half];
const c2 = [B.x + nx * (half + stripDepth), B.z + nz * (half + stripDepth)];
const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)];
const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]);
out.blockId = blockId;
const ry = Math.atan2(nx, nz); // facade faces back down the outward normal, to the street
const r = rng(citySeed, streamKind, streamId);
const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0;
let t = iStart; const end = len - iEnd;
while (end - t >= band.fmin) {
let f = frange(r, band.fmin, band.fmax);
if (t + f > end) f = end - t;
if (f < band.fmin * 0.75) break;
const d = frange(r, band.dmin, band.dmax);
const along = t + f / 2, off = half + d / 2;
const cx = A.x + ux * along + nx * off;
const cz = A.z + uz * along + nz * off;
const lot = addLot(blockId, cx, cz, f, d, ry, edgeId, band.use);
out.lots.push({ lot, use: band.use, cx, cz });
t += f + (band.gap ?? 0);
}
return out;
}
// Assign shop types to a retail block's 'shop' lots, with same-type clustering (a record shop
// next to an opshop next to a bookshop is the vibe — bias each lot toward the block's lead type).
function assignRetail(blockId, districtKind, lotsArr) {
const r = rng(citySeed, 'shopmix', blockId);
const lead = pickWeightedType(r, districtKind);
lotsArr.forEach((L, i) => {
if (L.use !== 'shop') return;
let type = (r() < 0.55 && lead) ? lead : pickWeightedType(r, districtKind);
if (!type) type = 'opshop';
const corner = (i === 0 || i === lotsArr.length - 1);
createShop(L.lot, type, { cornerBoost: corner && districtKind === 'mainstreet' });
});
}
// ── districts (coarse area markers; every block belongs to one) ──────────────────
const dMain = addDistrict('mainstreet', 0, 0);
const dRes = addDistrict('residential', 0, 0);
// ── the spine: 7 stations S→N through the origin, x-jitter ±30m ───────────────────
const NST = 7, zTop = 400;
const spinePts = [];
for (let i = 0; i < NST; i++) {
const z = -zTop + (2 * zTop) * i / (NST - 1);
const rr = rng(citySeed, 'spine', i);
const x = (i === 3) ? frange(rr, -8, 8) : frange(rr, -30, 30); // keep origin on the spine
spinePts.push(addNode(x, z));
}
const spineEdges = [];
for (let i = 0; i < NST - 1; i++) spineEdges.push(addEdge(spinePts[i].id, spinePts[i + 1].id, 28, 'main'));
// ── cross-street rungs at stations 1,2,4,5 (central 2 = second high streets) ──────
const rungStations = [1, 2, 4, 5];
const rungs = [];
for (const st of rungStations) {
const base = spinePts[st];
const rr = rng(citySeed, 'rung', st);
const theta = frange(rr, -0.14, 0.14); // ±8° jitter off pure EW
const central = (st === 2 || st === 4);
const width = central ? 20 : 14;
const eLen = frange(rr, central ? 200 : 150, central ? 250 : 190);
const wLen = frange(rr, central ? 200 : 150, central ? 250 : 190);
const ex = Math.cos(theta), ez = Math.sin(theta);
const eastEnd = addNode(base.x + ex * eLen, base.z + ez * eLen);
const westEnd = addNode(base.x - ex * wLen, base.z - ez * wLen);
const eEdge = addEdge(base.id, eastEnd.id, width, 'side');
const wEdge = addEdge(westEnd.id, base.id, width, 'side');
const districtId = central ? dMain : addDistrict('backstreets', 0, base.z);
rungs.push({ st, base, eastEnd, westEnd, eEdge, wEdge, width, central, districtId });
}
// ── main-street retail strips along the spine (reserve origin: W=market, E=arcade) ─
for (let i = 0; i < NST - 1; i++) {
const A = spinePts[i], B = spinePts[i + 1];
const dz = B.z - A.z, len = Math.hypot(B.x - A.x, dz), uz = dz / len;
for (const side of [1, -1]) {
const west = (side > 0 ? -uz : uz) < 0; // outward normal's x-sign: <0 ⇒ this side faces west
if (west && (i === 2 || i === 3)) continue; // market square occupies west-of-origin
if (!west && i === 3) continue; // arcade cuts east through this mid-spine block
const band = { fmin: 6, fmax: 9, dmin: 12, dmax: 20, gap: 0, use: 'shop', insetStart: 12, insetEnd: 12 };
const res = marchStrip('spinelot', i * 2 + (side > 0 ? 0 : 1), dMain, 'mainstreet', spineEdges[i], A, B, 28, side, band);
assignRetail(res.blockId, 'mainstreet', res.lots);
}
}
// ── cross-street retail strips (both arms, both sides) ────────────────────────────
for (const rung of rungs) {
const districtKind = rung.central ? 'mainstreet' : 'backstreets';
const arms = [
{ edge: rung.eEdge, A: rung.base, B: rung.eastEnd, tag: 'e' },
{ edge: rung.wEdge, A: rung.westEnd, B: rung.base, tag: 'w' },
];
// Reserve the intersection corner: the near-spine end of each arm must clear not just the spine
// ROAD (half-corridor 14m) but the spine RETAIL STRIP's full depth (dmax 20m) so cross-street
// lots don't interpenetrate the spine lots. 14+20 = 34m. (A final resolution pass mops up any
// residual corner collisions from the ±8° rung jitter — see resolveOverlaps below.)
const SPINE_CLEAR = 34;
for (const arm of arms) {
for (const side of [1, -1]) {
const band = {
fmin: rung.central ? 6 : 7, fmax: rung.central ? 9 : 10,
dmin: 12, dmax: 18, gap: rung.central ? 0 : 2, use: 'shop',
insetStart: arm.tag === 'e' ? SPINE_CLEAR : 6, // near-spine end clears the spine strip depth
insetEnd: arm.tag === 'e' ? 6 : SPINE_CLEAR,
};
const streamId = rung.st * 100 + (arm.tag === 'e' ? 0 : 10) + (side > 0 ? 0 : 1);
const res = marchStrip('runglot', streamId, rung.districtId, districtKind, arm.edge, arm.A, arm.B, rung.width, side, band);
assignRetail(res.blockId, districtKind, res.lots);
}
}
}
// ── market square (west of origin): stalls in rows + the dept anchor fronting the spine ─
{
const spineX = spinePts[3].x;
const eastEdge = spineX - 14; // west kerb of the spine corridor
const marketW = 120, zLo = -110, zHi = 120;
const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2);
const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]];
const block = addBlock(dMarket, 'market', poly);
// Facades face +x (EAST, toward the spine — the market sits west of it). ry is the OUTWARD normal
// (street→lot); a west-of-street lot's outward normal is x, so ry = atan2(1, 0) (= −π/2). The
// facade then faces (outward) = +x, matching marchStrip's convention. frontEdge is the spine
// segment each lot actually fronts by z-band: stalls (z≈100..23) front segment 2; dept (z=55) → 3.
const ryEast = Math.atan2(-1, 0);
const stallEdge = spineEdges[2]; // spine segment spanning z∈[133,0] — the stalls' band
const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band
const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square
for (let gx = 0; gx < COLS; gx++) {
for (let gz = 0; gz < ROWS; gz++) {
const x = eastEdge - 12 - gx * pitch;
const z = zLo + 10 + gz * pitch; // z ∈ [100, 23], all south of the dept strip (z≈41+)
addLot(block, x, z, sSize, sSize, ryEast, stallEdge, 'stall');
}
}
// shops for every stall lot (in id order for determinism)
for (const lot of lots) if (lot.block === block && lot.use === 'stall') createShop(lot.id, 'stall');
// dept anchor: big building on the square's north strip, fronting the spine
const deptW = 28, deptD = 26;
const deptId = addLot(block, eastEdge - deptD / 2, 55, deptW, deptD, ryEast, deptEdge, 'anchor');
createShop(deptId, 'dept', { cornerBoost: false });
}
// ── arcade: a covered pedestrian lane cutting east through a mid-spine block ───────
{
const midX = (spinePts[3].x + spinePts[4].x) / 2;
const midZ = (spinePts[3].z + spinePts[4].z) / 2;
const start = addNode(midX + 14, midZ);
const end = addNode(midX + 14 + 42, midZ);
const aEdge = addEdge(start.id, end.id, 5, 'arcade');
const dArcade = addDistrict('arcade', (start.x + end.x) / 2, midZ);
const band = { fmin: 3, fmax: 5, dmin: 5, dmax: 8, gap: 0.5, use: 'shop', insetStart: 2, insetEnd: 2 };
for (const side of [1, -1]) {
const res = marchStrip('arclot', side > 0 ? 0 : 1, dArcade, 'arcade', aEdge, start, end, 5, side, band);
assignRetail(res.blockId, 'arcade', res.lots);
}
}
// ── warehouse fringe: sparse big lots beyond one spine end (seeded N or S) ─────────
{
const rr = rng(citySeed, 'ware', 0);
const north = rr() < 0.5;
const z0 = north ? 480 : -480;
const A = addNode(-140, z0), B = addNode(140, z0);
const edge = addEdge(A.id, B.id, 12, 'side');
const dWare = addDistrict('warehouse', 0, z0);
// A→B runs +x (u=(1,0)); side>0 ⇒ normal (0,+1)=north, side<0 ⇒ (0,-1)=south. Face lots inward:
const useSide = north ? -1 : 1; // north fringe: lots to its south; south fringe: to its north
const band = { fmin: 20, fmax: 30, dmin: 24, dmax: 34, gap: 8, use: 'infill', insetStart: 8, insetEnd: 8 };
const res = marchStrip('warelot', 0, dWare, 'warehouse', edge, A, B, 12, useSide, band);
// sparsely activate a few as pawn/junk shops
for (const L of res.lots) {
const r3 = rng(citySeed, 'wareup', L.lot);
if (r3() < 0.3) { lots[L.lot].use = 'shop'; createShop(L.lot, 'pawn'); }
}
}
// ── residential collar: a loose ring road of houses, with 24 corner milk bars ────
{
const Wc = 430, Hc = 445;
const NW = addNode(-Wc, Hc), NE = addNode(Wc, Hc), SE = addNode(Wc, -Hc), SW = addNode(-Wc, -Hc);
const ringEdges = [
{ e: addEdge(NW.id, NE.id, 14, 'side'), A: NW, B: NE, inward: -1 }, // N: lots south (inward)
{ e: addEdge(NE.id, SE.id, 14, 'side'), A: NE, B: SE, inward: 1 }, // E: lots west (inward)
{ e: addEdge(SE.id, SW.id, 14, 'side'), A: SE, B: SW, inward: -1 }, // S: lots north (inward)
{ e: addEdge(SW.id, NW.id, 14, 'side'), A: SW, B: NW, inward: 1 }, // W: lots east (inward)
];
const cornerLots = [];
ringEdges.forEach((R, k) => {
const band = { fmin: 14, fmax: 20, dmin: 12, dmax: 18, gap: 5, use: 'house', insetStart: 24, insetEnd: 24 };
const res = marchStrip('houselot', k, dRes, 'residential', R.e, R.A, R.B, 14, R.inward, band);
if (res.lots.length) { cornerLots.push(res.lots[0], res.lots[res.lots.length - 1]); }
});
const shuffled = shuffle(rng(citySeed, 'milkbar', 0), cornerLots.slice());
const k = irange(rng(citySeed, 'milkbarN', 0), 2, 4);
for (let i = 0; i < Math.min(k, shuffled.length); i++) {
lots[shuffled[i].lot].use = 'shop';
createShop(shuffled[i].lot, 'milkbar');
}
}
// ── laneways behind the central main-street blocks (back-door flavour + yard lots) ─
{
const spineX = spinePts[3].x;
const eLaneX = spineX + 36, wLaneX = spineX - 36;
const laneA_E = addNode(eLaneX, -140), laneB_E = addNode(eLaneX, 140);
const laneA_W = addNode(wLaneX, -140), laneB_W = addNode(wLaneX, 140);
const eLane = addEdge(laneA_E.id, laneB_E.id, 4, 'lane');
const wLane = addEdge(laneA_W.id, laneB_W.id, 4, 'lane');
const band = { fmin: 8, fmax: 12, dmin: 8, dmax: 12, gap: 4, use: 'yard', insetStart: 12, insetEnd: 12 };
marchStrip('lanelot', 0, dMain, 'mainstreet', eLane, laneA_E, laneB_E, 4, -1, band); // yards on far (east) side
marchStrip('lanelot', 1, dMain, 'mainstreet', wLane, laneA_W, laneB_W, 4, 1, band); // yards on far (west) side
}
// ── corner de-confliction: guarantee no two BUILDING lots overlap across block boundaries ──
// Strips are non-overlapping within a block by construction, but perpendicular strips can still
// interpenetrate at street corners. Broad-phase over a spatial grid, then OBB/SAT; when two solid
// lots from different blocks collide, demote the higher-id one to 'infill' (dropping its shop).
// Deterministic: lots are visited in id order, so the earlier-built lot always wins the corner.
{
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']);
const GCELL = 24; // broad-phase cell; correctness is independent of size (we register+query full AABB)
const grid = new Map();
const gkey = (cx, cz) => `${cx},${cz}`;
const demoted = new Set();
for (const lot of lots) {
if (!SOLID.has(lot.use)) continue;
const q = lotCorners(lot);
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
for (const [x, z] of q) { if (x < minx) minx = x; if (x > maxx) maxx = x; if (z < minz) minz = z; if (z > maxz) maxz = z; }
const cx0 = Math.floor(minx / GCELL), cx1 = Math.floor(maxx / GCELL);
const cz0 = Math.floor(minz / GCELL), cz1 = Math.floor(maxz / GCELL);
let hit = false;
for (let cx = cx0; cx <= cx1 && !hit; cx++) for (let cz = cz0; cz <= cz1 && !hit; cz++) {
const bucket = grid.get(gkey(cx, cz)); if (!bucket) continue;
for (const oid of bucket) {
if (lots[oid].block === lot.block) continue; // within-block is disjoint by construction
if (obbOverlap(q, lotCorners(lots[oid]))) { hit = true; break; }
}
}
if (hit) { demoted.add(lot.id); continue; } // don't index a demoted lot (it becomes non-solid)
for (let cx = cx0; cx <= cx1; cx++) for (let cz = cz0; cz <= cz1; cz++) {
const k = gkey(cx, cz); let b = grid.get(k); if (!b) grid.set(k, b = []); b.push(lot.id);
}
}
if (demoted.size) {
for (const id of demoted) lots[id].use = 'infill';
for (let i = shops.length - 1; i >= 0; i--) if (demoted.has(shops[i].lot)) shops.splice(i, 1);
}
}
return {
version: 1,
citySeed,
name: townName(citySeed),
size: { w: 1024, d: 1024 },
districts,
streets: { nodes, edges },
blocks,
lots,
shops,
};
}
// ── chunk index: chunk key "cx,cz" → { lots, shops, edges } touching that 64m cell ──────
export const CHUNK = 64;
export const chunkKey = (cx, cz) => `${cx},${cz}`;
export function chunkIndex(plan, chunkSize = CHUNK) {
const cell = v => Math.floor(v / chunkSize);
const chunks = {};
const bucket = k => (chunks[k] ||= { lots: [], shops: [], edges: [] });
// each lot → the chunks its (rotated) footprint AABB covers
const lotKeys = new Map();
for (const lot of plan.lots) {
const cs = Math.cos(lot.ry), sn = Math.sin(lot.ry), hw = lot.w / 2, hd = lot.d / 2;
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
for (const [ox, oz] of [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]]) {
const wx = lot.x + ox * cs + oz * sn, wz = lot.z - ox * sn + oz * cs;
if (wx < minx) minx = wx; if (wx > maxx) maxx = wx;
if (wz < minz) minz = wz; if (wz > maxz) maxz = wz;
}
const keys = [];
for (let cx = cell(minx); cx <= cell(maxx); cx++)
for (let cz = cell(minz); cz <= cell(maxz); cz++) {
const k = chunkKey(cx, cz); bucket(k).lots.push(lot.id); keys.push(k);
}
lotKeys.set(lot.id, keys);
}
// shops ride along with their lot's chunks
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 (AmanatidesWoo) 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.
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);
}
}
return { chunkSize, chunks };
}

186
web/js/citygen/selfcheck.js Normal file
View File

@ -0,0 +1,186 @@
// PROCITY CityGen self-check — `node web/js/citygen/selfcheck.js`. Plain node, zero deps.
// Asserts the Lane A acceptance contract. Prints all-green or dies loud with the first failure.
//
// The overlap/facing helpers are IMPORTED from plan.js (lotCorners/obbOverlap) so the harness and
// the generator can never disagree about what "overlap" or "facing" means.
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
import { allFacadeSkins, SHOP_TYPES } from '../core/registry.js';
import { xmur3 } from '../core/prng.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const ASSETS = join(HERE, '..', '..', 'assets', 'gen');
let failures = 0, checks = 0;
const ok = (cond, msg) => { checks++; if (!cond) { failures++; console.error(' ✗ ' + msg); } };
const section = s => console.log('\n' + s);
const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309];
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B
// A lot's outward facade normal from its ry (GLB faces -Z at ry=0 ⇒ world facing = (-sin ry,-cos ry)).
const facing = l => [-Math.sin(l.ry), -Math.cos(l.ry)];
// nearest point on segment AB to point P
function nearestOnSeg(px, pz, ax, az, bx, bz) {
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1;
let t = ((px - ax) * dx + (pz - az) * dz) / L2; t = Math.max(0, Math.min(1, t));
return [ax + dx * t, az + dz * t];
}
// ── 1. determinism: two runs byte-identical ─────────────────────────────────────────
section('determinism (deep-equal across two runs)');
for (const s of SEEDS) {
const a = JSON.stringify(generatePlan(s)), b = JSON.stringify(generatePlan(s));
ok(a === b, `seed ${s}: two runs identical`);
}
// ── 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 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)}`);
}
// ── 2. performance: generate under 100ms ────────────────────────────────────────────
section('performance (<100ms per plan)');
for (const s of SEEDS) {
const t0 = process.hrtime.bigint();
generatePlan(s);
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
ok(ms < 100, `seed ${s}: generated in ${ms.toFixed(1)}ms`);
}
// ── 3. structural invariants over every seed ────────────────────────────────────────
section('structural invariants');
const facadeSet = new Set();
const isFiniteNum = v => typeof v === 'number' && Number.isFinite(v);
for (const s of SEEDS) {
const plan = generatePlan(s);
const edgeIds = new Set(plan.streets.edges.map(e => e.id));
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const nodeIds = new Set(plan.streets.nodes.map(n => n.id));
const blockIds = new Set(plan.blocks.map(b => b.id));
const lotIds = new Set(plan.lots.map(l => l.id));
const districtIds = new Set(plan.districts.map(d => d.id));
// every number finite (JSON would silently turn NaN/Infinity into null)
ok(plan.streets.nodes.every(n => isFiniteNum(n.x) && isFiniteNum(n.z)), `seed ${s}: node coords finite`);
ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `seed ${s}: lot numbers finite`);
ok(plan.blocks.every(b => b.poly.every(p => isFiniteNum(p[0]) && isFiniteNum(p[1]))), `seed ${s}: block poly finite`);
ok(plan.streets.edges.every(e => nodeIds.has(e.a) && nodeIds.has(e.b)), `seed ${s}: every edge references real nodes`);
ok(plan.blocks.every(b => districtIds.has(b.district)), `seed ${s}: every block in a real district`);
ok(plan.lots.every(l => blockIds.has(l.block)), `seed ${s}: every lot in a real block`);
ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `seed ${s}: every lot has a valid frontEdge`);
ok(plan.lots.every(l => l.w > 0 && l.d > 0), `seed ${s}: every lot has positive size`);
ok(plan.shops.every(sh => lotIds.has(sh.lot)), `seed ${s}: every shop has a real lot`);
ok(plan.shops.every(sh => SHOP_TYPES[sh.type]), `seed ${s}: every shop has a known type`);
ok(plan.shops.every(sh => SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `seed ${s}: every facade is in its type's pool`);
ok(plan.shops.every(sh => sh.hours[0] >= 0 && sh.hours[1] <= 23 && sh.hours[1] > sh.hours[0]), `seed ${s}: every shop has sane hours`);
ok(plan.shops.every(sh => sh.name && sh.sign), `seed ${s}: every shop is named`);
ok(plan.shops.every(sh => !/[{}]|undefined/.test(sh.name + sh.sign)), `seed ${s}: no unresolved tokens / undefined in names`);
ok(plan.shops.every(sh => { const [mn, mx] = SHOP_TYPES[sh.type].storeys; return sh.storeys >= mn && sh.storeys <= Math.max(mx, 3); }), `seed ${s}: storeys within range (corner ≤3)`);
// one lot ↔ at most one shop
const lotShopCounts = {};
for (const sh of plan.shops) lotShopCounts[sh.lot] = (lotShopCounts[sh.lot] || 0) + 1;
ok(Object.values(lotShopCounts).every(c => c === 1), `seed ${s}: no lot has two shops`);
// a shop only ever sits on a solid lot use
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `seed ${s}: every shop sits on a solid lot`);
// FACING: every lot's facade normal points at (not away from) its frontEdge (the review's D1 class)
let backwards = null;
for (const l of plan.lots) {
const e = plan.streets.edges.find(x => x.id === l.frontEdge);
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z);
const tox = nx - l.x, toz = nz - l.z; // vector from lot to its street
const [fx, fz] = facing(l);
if (tox * fx + toz * fz < -0.01) { backwards = l.id; break; } // facing away from the street it fronts
}
ok(backwards === null, `seed ${s}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
// OVERLAP within a block (all lots) — strips are sequential ⇒ disjoint by construction
const byBlock = {};
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
let inBlock = null;
for (const arr of Object.values(byBlock)) {
const cs = arr.map(lotCorners);
for (let i = 0; i < arr.length && !inBlock; i++) for (let j = i + 1; j < arr.length; j++)
if (obbOverlap(cs[i], cs[j])) { inBlock = [arr[i].id, arr[j].id]; break; }
if (inBlock) break;
}
ok(!inBlock, `seed ${s}: no overlapping lots within a block` + (inBlock ? ` (lots ${inBlock})` : ''));
// OVERLAP across blocks (building lots) — the corner-deconfliction guarantee (review D2)
const solids = plan.lots.filter(l => SOLID.has(l.use));
const cs = solids.map(lotCorners);
const aabb = cs.map(q => { let a = [1e9, 1e9, -1e9, -1e9]; for (const [x, z] of q) { a[0] = Math.min(a[0], x); a[1] = Math.min(a[1], z); a[2] = Math.max(a[2], x); a[3] = Math.max(a[3], z); } return a; });
let crossBlock = null;
for (let i = 0; i < solids.length && !crossBlock; i++) for (let j = i + 1; j < solids.length; j++) {
if (solids[i].block === solids[j].block) continue;
const A = aabb[i], B = aabb[j];
if (A[2] < B[0] || B[2] < A[0] || A[3] < B[1] || B[3] < A[1]) continue; // AABB reject
if (obbOverlap(cs[i], cs[j])) { crossBlock = [solids[i].id, solids[j].id]; break; }
}
ok(!crossBlock, `seed ${s}: no overlapping building lots across blocks` + (crossBlock ? ` (lots ${crossBlock})` : ''));
// CHUNKINDEX: covers every lot; buckets reference real ids
const idx = chunkIndex(plan);
const covered = new Set();
for (const cellB of Object.values(idx.chunks)) {
for (const id of cellB.lots) covered.add(id);
ok(cellB.lots.every(id => lotIds.has(id)) && cellB.shops.every(id => plan.shops.some(sh => sh.id === id)) && cellB.edges.every(id => edgeIds.has(id)),
`seed ${s}: chunk buckets reference real ids`);
}
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)
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)];
}
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]})` : ''));
// 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`);
// design brief presence
ok(plan.streets.edges.some(e => e.kind === 'main'), `seed ${s}: has a main-street spine`);
ok(plan.streets.edges.some(e => e.kind === 'arcade'), `seed ${s}: has an arcade`);
ok(plan.districts.some(d => d.kind === 'market'), `seed ${s}: has a market district`);
ok(plan.shops.some(sh => sh.type === 'stall'), `seed ${s}: market has stalls`);
ok(plan.shops.some(sh => sh.type === 'dept'), `seed ${s}: has a department-store anchor`);
const milkbars = plan.shops.filter(sh => sh.type === 'milkbar').length;
ok(milkbars >= 2 && milkbars <= 4, `seed ${s}: 24 corner milk bars (${milkbars})`);
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}
// ── 4. every facade skin referenced by the registry exists on disk ──────────────────
section('assets on disk');
for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`);
for (const f of facadeSet) ok(existsSync(join(ASSETS, f)), `used facade exists: ${f}`);
// ── report ──────────────────────────────────────────────────────────────────────────
console.log(`\n${failures ? '✗ FAIL' : '✓ ALL GREEN'}${checks - failures}/${checks} checks passed`);
const sample = generatePlan(20261990);
console.log(` sample seed 20261990 → "${sample.name}": ` +
`${sample.streets.nodes.length} nodes, ${sample.streets.edges.length} edges, ` +
`${sample.blocks.length} blocks, ${sample.lots.length} lots, ${sample.shops.length} shops`);
console.log(` fingerprint hash: 0x${(xmur3(JSON.stringify(sample))() >>> 0).toString(16)} (paste into GOLDEN.hash)`);
process.exit(failures ? 1 : 0);

View File

@ -0,0 +1,84 @@
// PROCITY name wordlists — the flavour bank for names.js. Hand-authored 90s-Australian.
// Pure data, no logic. names.js fills pattern tokens ({Word}, {Suburb}, {Family}, …) from here.
// Optionally regenerated/expanded by a human via an Ollama pass (see A-progress.md); the game
// only ever reads this checked-in file — no network at runtime (CITY_SPEC law).
// Generic evocative single words (records, sound, exchange, rotation…)
export const WORDS = [
'Rotation', 'Groove', 'Vinyl', 'Static', 'Echo', 'Reverb', 'Feedback', 'Needle', 'Tempo',
'Fable', 'Relic', 'Trinket', 'Bower', 'Nook', 'Attic', 'Cellar', 'Emporium', 'Bazaar',
'Hodgepodge', 'Oddment', 'Sundry', 'Curio', 'Salvage', 'Remnant', 'Vestige', 'Memento',
'Wombat', 'Galah', 'Cockatoo', 'Magpie', 'Kookaburra', 'Brumby', 'Dingo', 'Bilby',
'Southern', 'Federal', 'Colonial', 'Paramount', 'Regal', 'Crown', 'Empire', 'Ambassador',
'Sunburnt', 'Redgum', 'Wattle', 'Banksia', 'Bottlebrush', 'Saltbush', 'Coolabah', 'Jacaranda',
];
// Adjectives (worn, faded, second-hand register)
export const ADJ = [
'Golden', 'Lucky', 'Little', 'Grand', 'Olde', 'Corner', 'Central', 'Wonky', 'Dusty', 'Trusty',
'Thrifty', 'Cut-Price', 'Bargain', 'Bonza', 'Fair Dinkum', 'True Blue', 'Reliable', 'Honest',
'Faded', 'Worn', 'Vintage', 'Retro', 'Rare', 'Groovy', 'Funky', 'Mellow', 'Cosmic', 'Electric',
];
// Nouns for shop bodies
export const NOUN = [
'Records', 'Sounds', 'Wax', 'Grooves', 'Discs', 'Tapes', 'Tunes', 'Beat', 'Rhythm', 'Vibe',
'Treasures', 'Treasure Trove', 'Finds', 'Bits', 'Bobs', 'Wares', 'Goods', 'Bygones', 'Antiques',
'Toys', 'Games', 'Playthings', 'Fun', 'Kids', 'Nippers', 'Marbles', 'Kites',
'Books', 'Reads', 'Pages', 'Volumes', 'Paperbacks', 'Stories', 'Print', 'Chapters',
'Video', 'Movies', 'Flicks', 'Rentals', 'Screen', 'Cinema', 'Picture Show',
'Cash', 'Loans', 'Exchange', 'Trading', 'Pawn', 'Deals', 'Swap', 'Barter',
];
// AU suburb / place flavour (fake-but-plausible)
export const SUBURB = [
'Thornbury', 'Coburg', 'Preston', 'Footscray', 'Marrickville', 'Newtown', 'Enmore', 'Brunswick',
'Fitzroy', 'Collingwood', 'Redfern', 'Balmain', 'Bexley', 'Ashfield', 'Petersham', 'Croydon',
'Woop Woop', 'Bullamakanka', 'Dubbo', 'Wagga', 'Bendigo', 'Ballarat', 'Geelong', 'Toowoomba',
'Moe', 'Sale', 'Yass', 'Cootamundra', 'Gundagai', 'Wangaratta', 'Warrnambool', 'Numurkah',
];
// Family / surname flavour (milkbars, family businesses)
export const FAMILY = [
'Papadopoulos', 'Nguyen', 'Kowalski', 'Ferrante', 'De Luca', 'Kotsiopoulos', 'Tran', 'Petrakis',
'Wong', 'Singh', 'Karam', 'Vella', 'Borg', 'Camilleri', 'Muscat', 'Zammit', 'Grech',
'Kelly', 'Ryan', 'Murphy', 'OBrien', 'Doyle', 'Callaghan', 'Sullivan', 'Flanagan',
'Cracknell', 'Trundle', 'Bagshaw', 'Higgins', 'Prentice', 'Weatherall', 'Nabbs', 'Dorrigo',
];
// Saint / charity flavour (op shops parody Vinnies/Salvos/Anglicare)
export const SAINT = [
'Bevan', 'Cletus', 'Doreen', 'Enid', 'Fabian', 'Gladys', 'Hildegard', 'Ignatius', 'Merle',
'Norbert', 'Prudence', 'Quenby', 'Reginald', 'Sheila', 'Trevor', 'Ulrika', 'Verity', 'Wilfred',
];
export const CHARITY = [
'Vinnies-alike', 'Sallies', 'Op-Portunity', 'Second Chance', 'Anglibarn', 'Lifeboat',
'Good Sammy', 'The Smith Family Room', 'Diggers Aid', 'Bush Mission', 'Endeavour',
];
// First names for personalised shops ("Kev's", "Shazza's")
export const FIRSTNAME = [
'Kev', 'Shazza', 'Bazza', 'Dazza', 'Gazza', 'Robbo', 'Macca', 'Johnno', 'Stevo', 'Bluey',
'Dot', 'Merv', 'Val', 'Col', 'Nev', 'Beryl', 'Gaz', 'Snez', 'Franga', 'Tex',
];
// Animals (mascot names: The Laughing Magpie, etc.)
export const ANIMAL = [
'Magpie', 'Galah', 'Kookaburra', 'Wombat', 'Echidna', 'Possum', 'Rosella', 'Currawong',
'Cockatoo', 'Bilby', 'Quokka', 'Numbat', 'Bandicoot', 'Lorikeet', 'Kelpie', 'Blue Heeler',
];
// Town-name pieces (generatePlan names the town)
export const TOWN_PREFIX = [
'Mount', 'Port', 'Lake', 'Upper', 'Lower', 'North', 'South', 'Little', 'Old', 'New', 'Cape',
];
export const TOWN_ROOT = [
'Wilga', 'Barmah', 'Yackandandah', 'Wonthaggi', 'Corryong', 'Nhill', 'Woomelang', 'Beeac',
'Tarwin', 'Koonwarra', 'Meeniyan', 'Boolarra', 'Yinnar', 'Mirboo', 'Toora', 'Fish Creek',
'Dunkeld', 'Penshurst', 'Skipton', 'Rokewood', 'Cressy', 'Inverleigh', 'Bannockburn',
];
export const TOWN_SUFFIX = [
'Flat', 'Creek', 'Springs', 'Gully', 'Junction', 'Crossing', 'Downs', 'Vale', 'Heads',
'Plains', 'Ridge', 'Hollow', 'Bend', 'Reach', 'Wells', '', '', '', // blanks: bare root sometimes
];

154
web/js/core/registry.js Normal file
View File

@ -0,0 +1,154 @@
// PROCITY shop-type registry — the shared vocabulary (CITY_SPEC §"Shop types v1").
// Lane A owns this file; EVERY other lane imports it read-only. Keep it flat data:
// no THREE, no prng, no side effects — just tables + tiny pure helpers.
//
// Facade filenames here MUST exist in web/assets/gen/ (Lane A selfcheck asserts this).
// ── enumerations (single source of truth for the CityPlan schema strings) ──────────
export const DISTRICT_KINDS = ['mainstreet', 'arcade', 'backstreets', 'warehouse', 'residential', 'market'];
export const EDGE_KINDS = ['main', 'side', 'lane', 'arcade'];
export const USE_KINDS = ['shop', 'anchor', 'house', 'yard', 'stall', 'infill'];
export const SHOP_TYPE_IDS = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall'];
// ── the registry ──────────────────────────────────────────────────────────────────
// Per type:
// label human name (map legend / debug)
// facades facade-*.jpg skins in web/assets/gen/ (blank signboards; game overlays the name)
// sign colour/style hints for Lane B's canvas signage
// interior archetype id Lane C builds the room from
// fittings parametric fitting kit ids (90sDJsim fittings.js) Lane C fills the room with
// storeys [min,max] inclusive
// hours {open,close} default trading hours (24h); plan.js jitters + picks one weirdo
// weights district-kind -> spawn weight (0/absent = never in that district)
// footprint {fmin,fmax,dmin,dmax} preferred frontage/depth band in metres (plan.js hint)
export const SHOP_TYPES = {
record: {
label: 'Record Shop',
facades: ['facade-timber-teal.jpg', 'facade-arcade-tile.jpg', 'facade-djsim-record.jpg'],
sign: { bg: '#141428', fg: '#ff5470', accent: '#ffd23f', style: 'neon' },
interior: 'crates_and_bins',
fittings: ['record_bins', 'crates', 'counter', 'listening_corner'],
storeys: [1, 2],
hours: { open: 10, close: 18 },
weights: { mainstreet: 3, arcade: 4, backstreets: 1 },
footprint: { fmin: 6, fmax: 9, dmin: 12, dmax: 18 },
},
opshop: {
label: 'Op Shop',
facades: ['facade-weatherboard.jpg', 'facade-fibro-blue.jpg', 'facade-djsim-opshop.jpg'],
sign: { bg: '#3d6b4a', fg: '#f6efdc', accent: '#e4573a', style: 'painted' },
interior: 'racks_and_shelves',
fittings: ['clothes_racks', 'bricabrac_shelving', 'book_wall', 'counter'],
storeys: [1, 2],
hours: { open: 9, close: 17 },
weights: { mainstreet: 3, arcade: 1, backstreets: 2 },
footprint: { fmin: 7, fmax: 10, dmin: 12, dmax: 20 },
},
toy: {
label: 'Toy Shop',
facades: ['facade-stucco-pink.jpg', 'facade-deco-pastel.jpg', 'facade-boutique.jpg'],
sign: { bg: '#ffd23f', fg: '#d6336c', accent: '#2f9e44', style: 'bubbly' },
interior: 'shelves_and_case',
fittings: ['cube_shelves', 'display_tables', 'glass_case', 'counter'],
storeys: [1, 2],
hours: { open: 9, close: 17 },
weights: { mainstreet: 2, arcade: 2, backstreets: 1 },
footprint: { fmin: 6, fmax: 9, dmin: 12, dmax: 18 },
},
book: {
label: 'Book Barn',
facades: ['facade-federation.jpg', 'facade-sandstone.jpg', 'facade-redbrick.jpg'],
sign: { bg: '#2b3a2e', fg: '#e9d8a6', accent: '#bb7a3b', style: 'serif' },
interior: 'halls_of_shelves',
fittings: ['bookshelves', 'spinner_racks', 'armchair', 'counter'],
storeys: [1, 2],
hours: { open: 9, close: 17 },
weights: { mainstreet: 2, arcade: 2, backstreets: 1 },
footprint: { fmin: 7, fmax: 10, dmin: 14, dmax: 20 },
},
video: {
label: 'Video Rental',
facades: ['facade-stripmall.jpg', 'facade-djsim-video.jpg'],
sign: { bg: '#111111', fg: '#ffe14d', accent: '#e8412b', style: 'blocky' },
interior: 'aisle_shelves',
fittings: ['vhs_shelving', 'returns_slot', 'poster_wall', 'counter'],
storeys: [1, 1],
hours: { open: 11, close: 21 }, // rentals trade late
weights: { mainstreet: 2, arcade: 0, backstreets: 1 },
footprint: { fmin: 7, fmax: 10, dmin: 14, dmax: 20 },
},
pawn: {
label: 'Pawnbroker',
facades: ['facade-besser.jpg', 'facade-grimy.jpg', 'facade-djsim-pawn.jpg', 'facade-corrugated.jpg'],
sign: { bg: '#1c1c1c', fg: '#f4c430', accent: '#a8a8a8', style: 'goldbar' },
interior: 'counter_forward',
fittings: ['glass_cabinets', 'wall_hooks', 'barred_window', 'counter'],
storeys: [1, 2],
hours: { open: 9, close: 17 },
weights: { mainstreet: 1, backstreets: 2, warehouse: 1 },
footprint: { fmin: 6, fmax: 9, dmin: 12, dmax: 18 },
},
milkbar: {
label: 'Milk Bar',
facades: ['facade-djsim-milkbar.jpg', 'facade-brickveneer.jpg'],
sign: { bg: '#c92a2a', fg: '#fff5f5', accent: '#ffd23f', style: 'coke' },
interior: 'counter_and_freezer',
fittings: ['counter', 'fridge', 'magazine_rack', 'freezer'],
storeys: [1, 1],
hours: { open: 6, close: 20 }, // the corner shop opens early, closes late
weights: { residential: 1 }, // only ever a residential corner shop
footprint: { fmin: 9, fmax: 12, dmin: 12, dmax: 16 },
},
dept: {
label: 'Department Store',
facades: ['facade-terrazzo.jpg', 'facade-djsim-dept.jpg'],
sign: { bg: '#7b1e3a', fg: '#f6e7c1', accent: '#c9a227', style: 'grand' },
interior: 'grand_hall',
fittings: ['mixed_sections', 'escalator_prop', 'glass_case', 'counter'],
storeys: [2, 3],
hours: { open: 9, close: 17 },
weights: { market: 1 }, // the anchor on the market square's edge
footprint: { fmin: 22, fmax: 34, dmin: 24, dmax: 34 },
},
stall: {
label: 'Market Stall',
facades: ['facade-market.jpg'],
sign: { bg: '#e8e2d0', fg: '#5c3b1e', accent: '#c0392b', style: 'handpainted' },
interior: 'open_stall',
fittings: ['trestle_tables', 'crates'],
storeys: [1, 1],
hours: { open: 8, close: 14 }, // market mornings
weights: { market: 1 },
footprint: { fmin: 3, fmax: 5, dmin: 3, dmax: 5 },
},
};
// ── pure helpers (no randomness — plan.js supplies the prng stream) ─────────────────
// Types that can spawn in a district, as a flat [{type,weight}] list (weight>0), stable order.
export function weightedTypes(districtKind) {
const out = [];
for (const type of SHOP_TYPE_IDS) {
const w = SHOP_TYPES[type].weights[districtKind] || 0;
if (w > 0) out.push({ type, weight: w });
}
return out;
}
// Sample one type by weight using a prng stream fn r()->[0,1). Returns null if none for district.
export function pickWeightedType(r, districtKind, exclude = null) {
let list = weightedTypes(districtKind);
if (exclude) { const f = list.filter(t => t.type !== exclude); if (f.length) list = f; }
if (!list.length) return null;
let total = 0; for (const t of list) total += t.weight;
let x = r() * total;
for (const t of list) { x -= t.weight; if (x < 0) return t.type; }
return list[list.length - 1].type;
}
// Every facade filename referenced anywhere (Lane A selfcheck asserts each exists on disk).
export function allFacadeSkins() {
const set = new Set();
for (const type of SHOP_TYPE_IDS) for (const f of SHOP_TYPES[type].facades) set.add(f);
return [...set];
}

337
web/map.html Normal file
View File

@ -0,0 +1,337 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PROCITY — plan debugger (Lane A)</title>
<style>
:root{
--bg:#14110d; --panel:#1c1813; --ink:#efe6d2; --dim:#a99a80; --line:#2c2519;
--road:#3a3020; --roadmain:#5a4a2e; --roadarc:#4a3a52; --roadlane:#2a241a;
--block:#241d14; --accent:#e0a44a;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);
font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}
#wrap{display:flex;height:100%}
#stage{flex:1;position:relative}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair}
#side{width:270px;background:var(--panel);border-left:1px solid var(--line);
padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px}
h1{font-size:15px;margin:0;letter-spacing:.06em;color:var(--accent)}
h1 small{display:block;color:var(--dim);font-size:11px;letter-spacing:.02em;margin-top:2px}
.row{display:flex;gap:6px;align-items:center}
input,button{font:inherit;background:#0e0b07;color:var(--ink);border:1px solid var(--line);
border-radius:6px;padding:6px 8px}
input{flex:1;min-width:0}
button{cursor:pointer;background:#2a2216}
button:hover{background:#37301f;border-color:var(--accent)}
button.primary{background:var(--accent);color:#1a1206;border-color:var(--accent);font-weight:700}
.stat{color:var(--dim)} .stat b{color:var(--ink);font-weight:700}
.legend{display:grid;grid-template-columns:auto 1fr;gap:3px 8px;align-items:center;font-size:11px}
.sw{width:12px;height:12px;border-radius:3px;border:1px solid #0006}
.muted{color:var(--dim);font-size:11px}
#tip{position:absolute;pointer-events:none;background:#000c;border:1px solid var(--accent);
color:var(--ink);padding:5px 8px;border-radius:6px;font-size:12px;max-width:240px;
transform:translate(10px,10px);display:none;z-index:5;white-space:nowrap}
#tip .t{color:var(--accent);font-weight:700}
#tip .s{color:var(--dim);font-size:11px}
hr{border:0;border-top:1px solid var(--line);margin:2px 0}
.toggles{display:grid;grid-template-columns:1fr 1fr;gap:4px}
label.chk{display:flex;gap:5px;align-items:center;font-size:11px;color:var(--dim);cursor:pointer}
</style>
</head>
<body>
<div id="wrap">
<div id="stage">
<canvas id="c"></canvas>
<div id="tip"></div>
</div>
<aside id="side">
<h1>PROCITY<small>plan debugger · Lane A</small></h1>
<div class="row">
<input id="seed" value="20261990" inputmode="numeric" title="city seed (uint32)">
<button id="regen" class="primary">regen</button>
</div>
<div class="row">
<button id="rand" style="flex:1">🎲 random</button>
<button id="export" style="flex:1">⤓ JSON</button>
</div>
<div id="name" class="stat"></div>
<div id="stats" class="stat"></div>
<hr>
<div class="muted">layers</div>
<div class="toggles">
<label class="chk"><input type="checkbox" id="tChunks"> chunk grid</label>
<label class="chk"><input type="checkbox" id="tBlocks" checked> blocks</label>
<label class="chk"><input type="checkbox" id="tLots" checked> lots</label>
<label class="chk"><input type="checkbox" id="tShops" checked> shop dots</label>
<label class="chk"><input type="checkbox" id="tLabels"> all labels</label>
<label class="chk"><input type="checkbox" id="tNodes"> nodes</label>
</div>
<hr>
<div class="muted">shop types</div>
<div class="legend" id="legend"></div>
<hr>
<div class="muted">lot use</div>
<div class="legend" id="legendUse"></div>
<hr>
<div class="muted" style="line-height:1.6">
drag to pan · scroll to zoom · hover a shop for its name.<br>
This is Lane A's debug view — it grows into the in-game map.
</div>
</aside>
</div>
<!-- No importmap / no THREE: this debug page is pure Canvas 2D (CITY_SPEC allows the map page to
use THREE, but Lane A's 2D map doesn't need it). It imports only Lane A's pure-data modules. -->
<script type="module">
import { generatePlan, chunkIndex, CHUNK } from './js/citygen/plan.js';
import { SHOP_TYPES, SHOP_TYPE_IDS } from './js/core/registry.js';
// ── palette ─────────────────────────────────────────────────────────────────────────
const TYPE_COLOR = {
record:'#ff5470', opshop:'#5fbf74', toy:'#ffcf3f', book:'#c98b3b', video:'#ffe14d',
pawn:'#c0c0c0', milkbar:'#ff6b6b', dept:'#e07b9a', stall:'#c8a24a',
};
const USE_COLOR = {
shop:'#6b5a3a', anchor:'#8a4a63', house:'#3f5a4a', yard:'#2e3a2a', stall:'#7a6a3a', infill:'#4a4436',
};
const EDGE_STYLE = { // c = bare CSS custom-prop name OR hex — getcss() resolves either (never a var() string,
main:{c:'--roadmain', w:1}, side:{c:'#4a3f28', w:1}, lane:{c:'#2a241a', w:1}, arcade:{c:'#6a4a72', w:1}, // which canvas can't parse)
};
// ── state ───────────────────────────────────────────────────────────────────────────
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
let plan = null, idx = null, DPR = Math.min(devicePixelRatio||1, 2);
const view = { x:0, z:0, scale:0.6 }; // world→screen: screen = (world - viewcenter)*scale + canvascenter
let hover = null;
const $ = id => document.getElementById(id);
const cssPx = () => ({ w: cv.clientWidth, h: cv.clientHeight });
function resize(){
const {w,h} = cssPx();
cv.width = w*DPR; cv.height = h*DPR;
draw();
}
function fitView(){
const {w,h} = cssPx();
const s = plan.size;
view.x = 0; view.z = 0;
view.scale = Math.min(w/(s.w*1.08), h/(s.d*1.08));
}
const w2s = (x,z) => {
const {w,h} = cssPx();
return [ (x-view.x)*view.scale + w/2, (z-view.z)*view.scale + h/2 ];
};
const s2w = (sx,sz) => {
const {w,h} = cssPx();
return [ (sx-w/2)/view.scale + view.x, (sz-h/2)/view.scale + view.z ];
};
// ── draw ──────────────────────────────────────────────────────────────────────────
function draw(){
if(!plan) return;
const {w,h}=cssPx();
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.clearRect(0,0,w,h);
ctx.fillStyle = getcss('--bg'); ctx.fillRect(0,0,w,h);
// town bounds
const s=plan.size;
const [bx0,bz0]=w2s(-s.w/2,-s.d/2), [bx1,bz1]=w2s(s.w/2,s.d/2);
ctx.fillStyle='#0e0b07'; ctx.fillRect(bx0,bz0,bx1-bx0,bz1-bz0);
ctx.strokeStyle=getcss('--line'); ctx.lineWidth=1; ctx.strokeRect(bx0,bz0,bx1-bx0,bz1-bz0);
if ($('tChunks').checked) drawChunks();
if ($('tBlocks').checked) drawBlocks();
drawStreets();
if ($('tLots').checked) drawLots();
if ($('tShops').checked) drawShops();
if ($('tNodes').checked) drawNodes();
if (hover) drawHoverHi();
drawCompass();
}
let _drawQueued=false;
function scheduleDraw(){ if(_drawQueued) return; _drawQueued=true; requestAnimationFrame(()=>{ _drawQueued=false; draw(); }); }
function getcss(v){ return getComputedStyle(document.documentElement).getPropertyValue(v).trim() || v; }
function drawChunks(){
const s=plan.size, c=CHUNK;
ctx.strokeStyle='#ffffff10'; ctx.lineWidth=1;
for(let x=-s.w/2; x<=s.w/2; x+=c){ const [sx0,sy0]=w2s(x,-s.d/2),[sx1,sy1]=w2s(x,s.d/2);
ctx.beginPath();ctx.moveTo(sx0,sy0);ctx.lineTo(sx1,sy1);ctx.stroke(); }
for(let z=-s.d/2; z<=s.d/2; z+=c){ const [sx0,sy0]=w2s(-s.w/2,z),[sx1,sy1]=w2s(s.w/2,z);
ctx.beginPath();ctx.moveTo(sx0,sy0);ctx.lineTo(sx1,sy1);ctx.stroke(); }
}
function drawBlocks(){
for(const b of plan.blocks){
ctx.beginPath();
b.poly.forEach((p,i)=>{ const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy); });
ctx.closePath();
ctx.fillStyle = DISTRICT_FILL[b.kind] || '#241d1440';
ctx.fill();
ctx.strokeStyle='#00000030'; ctx.lineWidth=1; ctx.stroke();
}
}
const DISTRICT_FILL = {
mainstreet:'#2c2414aa', backstreets:'#241f16aa', arcade:'#2e2338aa',
market:'#2c2a16aa', warehouse:'#20211caa', residential:'#1c2620aa',
};
function drawStreets(){
const nById = new Map(plan.streets.nodes.map(n=>[n.id,n]));
// widen roads with the edge width, scaled
for(const e of plan.streets.edges){
const a=nById.get(e.a), b=nById.get(e.b); if(!a||!b) continue;
const [ax,ay]=w2s(a.x,a.z), [bx,by]=w2s(b.x,b.z);
const st = EDGE_STYLE[e.kind]||EDGE_STYLE.side;
ctx.strokeStyle = getcss(st.c);
ctx.lineWidth = Math.max(1, e.width*view.scale);
ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(ax,ay);ctx.lineTo(bx,by);ctx.stroke();
}
}
function lotQuad(l){
const cs=Math.cos(l.ry), sn=Math.sin(l.ry), hw=l.w/2, hd=l.d/2;
return [[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([ox,oz])=>[l.x+ox*cs+oz*sn, l.z-ox*sn+oz*cs]);
}
function drawLots(){
for(const l of plan.lots){
const q=lotQuad(l);
ctx.beginPath();
q.forEach((p,i)=>{ const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy); });
ctx.closePath();
ctx.fillStyle=USE_COLOR[l.use]||'#333'; ctx.fill();
ctx.strokeStyle='#0008'; ctx.lineWidth=.5; ctx.stroke();
}
}
let _sbl=null; // shop-by-lot cache, module-level (NEVER attached to plan — export must stay schema-clean)
function shopByLot(){ return _sbl || (_sbl=new Map(plan.shops.map(s=>[s.lot,s]))); }
function drawShops(){
const sbl=shopByLot();
const lById=new Map(plan.lots.map(l=>[l.id,l]));
const showLabels=$('tLabels').checked;
ctx.textAlign='center'; ctx.textBaseline='middle';
for(const sh of plan.shops){
const l=lById.get(sh.lot); if(!l) continue;
const [sx,sy]=w2s(l.x,l.z);
const rr=Math.max(2, Math.min(6, l.w*view.scale*0.35));
ctx.beginPath();ctx.arc(sx,sy,rr,0,7); ctx.fillStyle=TYPE_COLOR[sh.type]||'#fff'; ctx.fill();
ctx.lineWidth=1; ctx.strokeStyle='#0009'; ctx.stroke();
if(showLabels && view.scale>0.8){
ctx.font='9px ui-monospace,monospace'; ctx.fillStyle='#efe6d2cc';
ctx.fillText(sh.sign, sx, sy-rr-6);
}
}
}
function drawNodes(){
ctx.fillStyle=getcss('--accent');
for(const n of plan.streets.nodes){ const [sx,sy]=w2s(n.x,n.z);
ctx.beginPath();ctx.arc(sx,sy,2,0,7);ctx.fill(); }
}
function drawHoverHi(){
const l=hover.lot; const q=lotQuad(l);
ctx.beginPath(); q.forEach((p,i)=>{const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy);});
ctx.closePath(); ctx.strokeStyle=getcss('--accent'); ctx.lineWidth=2; ctx.stroke();
}
function drawCompass(){
const {w}=cssPx(); const x=w-38, y=34;
ctx.strokeStyle=getcss('--dim'); ctx.fillStyle=getcss('--dim'); ctx.lineWidth=1.5;
ctx.beginPath();ctx.moveTo(x,y+12);ctx.lineTo(x,y-12);ctx.stroke();
ctx.beginPath();ctx.moveTo(x,y-12);ctx.lineTo(x-3,y-7);ctx.lineTo(x+3,y-7);ctx.closePath();ctx.fill();
ctx.font='10px monospace';ctx.textAlign='center';ctx.fillText('N',x,y-16);
}
// ── picking ─────────────────────────────────────────────────────────────────────────
function pick(sx,sz){
const [wx,wz]=s2w(sx,sz);
const sbl=shopByLot();
let best=null, bestD=Infinity;
for(const l of plan.lots){
const d=(l.x-wx)**2+(l.z-wz)**2;
if(d<bestD && d < Math.max(l.w,l.d)**2){ bestD=d; best=l; }
}
if(!best) return null;
return { lot:best, shop:sbl.get(best.id)||null };
}
// ── interaction ───────────────────────────────────────────────────────────────────
let drag=null;
cv.addEventListener('mousedown', e=>{ drag={x:e.clientX,y:e.clientY,vx:view.x,vz:view.z}; });
addEventListener('mouseup', ()=> drag=null);
addEventListener('mousemove', e=>{
const rect=cv.getBoundingClientRect(); const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
if(drag){ view.x=drag.vx-(e.clientX-drag.x)/view.scale; view.z=drag.vz-(e.clientY-drag.y)/view.scale; scheduleDraw(); return; }
if(sx<0||sy<0||sx>rect.width||sy>rect.height){ hideTip(); return; }
hover=pick(sx,sy);
const tip=$('tip');
if(hover){
cv.style.cursor='pointer';
const l=hover.lot, s=hover.shop;
tip.style.display='block'; tip.style.left=sx+'px'; tip.style.top=sy+'px';
tip.innerHTML = s
? `<div class="t">${esc(s.name)}</div><div class="s">${s.type} · ${SHOP_TYPES[s.type]?.label||''} · ${s.storeys}fl · ${pad(s.hours[0])}${pad(s.hours[1])}</div><div class="s">${s.facadeSkin}</div>`
: `<div class="t">${l.use} lot</div><div class="s">${l.w}×${l.d}m · block ${l.block}</div>`;
scheduleDraw();
} else { hideTip(); }
});
function hideTip(){ if(hover){hover=null;scheduleDraw();} $('tip').style.display='none'; cv.style.cursor='crosshair'; }
cv.addEventListener('wheel', e=>{
e.preventDefault();
const rect=cv.getBoundingClientRect(); const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
const [wx,wz]=s2w(sx,sy);
const f=Math.exp(-e.deltaY*0.0012);
view.scale=Math.max(0.05, Math.min(8, view.scale*f));
const [nx,nz]=s2w(sx,sy); view.x+=wx-nx; view.z+=wz-nz;
scheduleDraw();
}, {passive:false});
const esc=s=>String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
const pad=h=>String(h).padStart(2,'0')+':00';
// ── panel ─────────────────────────────────────────────────────────────────────────
function regen(seed){
const s = (seed>>>0);
plan = generatePlan(s); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
$('seed').value = s;
$('name').innerHTML = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`;
const nShop=plan.shops.length, byType={};
for(const sh of plan.shops) byType[sh.type]=(byType[sh.type]||0)+1;
$('stats').innerHTML =
`<b>${plan.streets.edges.length}</b> streets · <b>${plan.blocks.length}</b> blocks<br>`+
`<b>${plan.lots.length}</b> lots · <b>${nShop}</b> shops · <b>${Object.keys(idx.chunks).length}</b> chunks`;
fitView(); draw(); buildLegend(byType);
}
function buildLegend(byType){
const L=$('legend'); L.innerHTML='';
for(const t of SHOP_TYPE_IDS){
const sw=document.createElement('span'); sw.className='sw'; sw.style.background=TYPE_COLOR[t];
const lb=document.createElement('span'); lb.textContent=`${t} (${byType?.[t]||0})`;
L.append(sw,lb);
}
const U=$('legendUse'); U.innerHTML='';
for(const u of Object.keys(USE_COLOR)){
const sw=document.createElement('span'); sw.className='sw'; sw.style.background=USE_COLOR[u];
const lb=document.createElement('span'); lb.textContent=u;
U.append(sw,lb);
}
}
$('regen').onclick=()=>regen(parseInt($('seed').value,10)||0);
$('seed').addEventListener('keydown',e=>{ if(e.key==='Enter') $('regen').click(); });
$('rand').onclick=()=>regen((Math.random()*4294967295)>>>0); // UI-only randomness; generation stays seeded
$('export').onclick=()=>{
const blob=new Blob([JSON.stringify(plan,null,0)],{type:'application/json'});
const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
a.download=`procity-${plan.citySeed}.json`; a.click(); URL.revokeObjectURL(a.href);
};
for(const id of ['tChunks','tBlocks','tLots','tShops','tLabels','tNodes']) $(id).onchange=draw;
addEventListener('resize', resize);
// boot — seed can come from ?seed=… (Lane F / screenshot harness).
// rAF ensures the flex layout has sized the canvas before fitView reads its dimensions.
const q=new URLSearchParams(location.search);
const bootSeed=parseInt(q.get('seed')||$('seed').value,10)||20261990;
requestAnimationFrame(()=>{ resize(); regen(bootSeed); });
</script>
</body>
</html>

6
web/package.json Normal file
View File

@ -0,0 +1,6 @@
{
"name": "procity-web",
"private": true,
"type": "module",
"description": "Marks web/js/** as ES modules so plain `node web/js/citygen/selfcheck.js` runs. Browsers use <script type=module>+importmap and ignore this file; python http.server ignores it too. No dependencies, no build step (CITY_SPEC: zero npm). Added by Lane A for the required node self-check."
}