AndresCarreon commited on
Commit
d72231c
·
verified ·
1 Parent(s): 3985559

NEMOCITY v0 — mock backend, gradio 6.16.0 (pre-SSR)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
ARCHITECTURE.md ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NEMOCITY — Architecture Contract (v1, June 12 2026, ~7pm)
2
+
3
+ > **This file is the single source of truth for the parallel build.** Every builder agent reads
4
+ > this FIRST and writes ONLY the files it owns (see File Ownership). When in doubt, follow the
5
+ > schemas here exactly — the integrator fixes drift, but don't create it.
6
+
7
+ ## One-liner
8
+
9
+ Ask the city for a building — a 9B Nemotron city hall reads your petition, the deterministic
10
+ engine zones it, and you (and every other visitor on Earth) watch it rise, floor by floor, in ONE
11
+ shared persistent miniature city. Cars commute, rush hour jams the bridge, and the AI City
12
+ Engineer reads real traffic telemetry and fixes it while you watch.
13
+
14
+ - **Name:** NEMOCITY — "the tiny city the internet builds." (nano = Nemotron Nano + the
15
+ hackathon's small-model thesis; -polis = city.)
16
+ - **Tagline:** "Ask for a building and watch it rise — one tiny persistent city the whole
17
+ internet builds, with a 9B Nemotron running city hall."
18
+ - Track: 🍄 Thousand Token Wood. Hackathon: Build Small (Gradio + HF). Deadline June 15 morning.
19
+ - Awards targeted: NVIDIA team-judged RTX 5080 (Nemotron load-bearing — the model's raw JSON
20
+ plan is shown in the UI on every action), TTW podium, Community Choice (shared persistent
21
+ city), Best Demo (timelapse + FPV walk footage), Judges' Wildcard, badges: offbrand, sharing,
22
+ fieldnotes (+ offgrid/llama stretch).
23
+
24
+ ## Non-negotiable design rules
25
+
26
+ 1. **The "her" pattern** (org-celebrated judge taste): the deterministic engine owns ALL city
27
+ state, placement, geometry, and traffic facts. The LLM only interprets petitions into a tiny
28
+ JSON (no coordinates, EVER) and narrates. The engine clamps/repairs everything; a visitor
29
+ action ALWAYS yields visible construction.
30
+ 2. **One petition at a time** — single serialized queue, visitors see their position.
31
+ 3. **The landing page must be gorgeous with ZERO GPU.** The whole city renders client-side from
32
+ the event JSON; cars/citizens/day-night simulate deterministically in the browser. Judges
33
+ evaluate while GPUs sleep.
34
+ 4. **Determinism end-to-end.** All motion is a pure function of (event list, simTime). NO
35
+ `Math.random()`, NO `Date.now()` inside sim/render logic (the shared clock anchor in main.js
36
+ is the single allowed wall-clock read). NO `position += v*dt` accumulation — always evaluate
37
+ `position(t)` absolutely (the godseed life.js idiom). This makes replay, timelapse, shared
38
+ sync, and headless screenshots free.
39
+ 5. **ONE `@spaces.GPU` call per user action** (one-shot JSON generation, model resident on cuda,
40
+ duration=240). The multi-call loop crashes ZeroGPU — June 12 lesson, never relitigate.
41
+ 6. **Moderation before execution** (charset/length + folded wordlist; LLM judge OFF by default).
42
+ LLM-proposed building NAMES also pass the wordlist. One slur on a storefront during judging
43
+ week ends every award.
44
+ 7. **≤32B params total, ONE model:** `nvidia/NVIDIA-Nemotron-Nano-9B-v2`.
45
+ 8. **Copy/naming:** never use "wish", "god", "remember/remembers", "Book of ___" in USER-FACING
46
+ copy (rival differentiation + GODSEED separation). The user-facing words: **petition**,
47
+ **city hall**, **City Engineer**, **ledger**. (Wire format & Python internals KEEP godseed's
48
+ `wish_id`/`wish_*` names verbatim — zero-risk code reuse; UI copy is the only rename layer.)
49
+ 9. Frame: a FLAT city grid (true SimCity feel). NOT a planet, NOT an island. Plus a first-person
50
+ walk mode ("Walk the streets") — verified unclaimed across all 385 rival submissions.
51
+
52
+ ## Repo layout & FILE OWNERSHIP (do not touch files you don't own)
53
+
54
+ ```
55
+ nemocity/
56
+ ARCHITECTURE.md (this file — read-only for everyone)
57
+ app.py ← AGENT-SERVER (adapt from godseed; already copied here)
58
+ requirements.txt ← AGENT-SERVER
59
+ README.md ← AGENT-SERVER (HF Space card w/ full YAML tags — see Deploy)
60
+ server/ ← AGENT-SERVER (sse.py, persistence.py, schemas.py, ratelimit.py — copied, adapt envs/fields)
61
+ engine/ ← AGENT-ENGINE
62
+ constants.py (canonical: grid, river, building table, road classes, traffic constants, palette-relevant hues)
63
+ genesis.py (the exact genesis event list — see Genesis)
64
+ world.py (copied from godseed — keep the Feature/load/apply/state_dict machinery; adapt _observation + validation hook)
65
+ tools.py (NEW: kind-synonym map, clamp tables, event validation — engine-side "tool" specs for the 4 event types)
66
+ city.py (derived state from events: occupancy grid, road graph + street names, population, growth_radius)
67
+ placement.py (the never-fails placement scorer + connector BFS)
68
+ traffic.py (static assignment: trip gen, A*, demand map, Traffic Index, fix candidates + predicted index)
69
+ summary.py (≤600-char city summary for the LLM prompt)
70
+ moderation.py (copied — adapt env names GODSEED_→CITY_)
71
+ queue_worker.py (copied — adapt: one-shot planner drive, fix-kind jobs, plan SSE event)
72
+ mind/ ← AGENT-MIND
73
+ prompts.py (build + fix one-shot prompts; persona; tool docs from engine tables)
74
+ backends.py (copied — adapt: env names, ONESHOT only, model id, /no_think + <think> strip stays)
75
+ validate.py (lenient parse for the BUILD and FIX JSON schemas; balanced-brace extraction + salvage)
76
+ planner.py (one-shot grant(): prompt → generate → parse → clamp → engine acts; fix(): stats → generate → choice)
77
+ mock_scripts.py (keyword→BUILD-JSON scripts exercising every kind; fix script; drives local dev + demos)
78
+ moderation_judge.py (copied; off by default via CITY_JUDGE)
79
+ web/
80
+ index.html, style.css ← AGENT-UI (HUD per Visual Bible; element ids are the contract)
81
+ main.js ← AGENT-UI (copied — adapt SSE applier, petition form, HUD, mode buttons, mock boot, window.__app harness)
82
+ mock/world.json ← generated by tools/gen_web.py (AGENT-ENGINE writes the generator; integrator runs it)
83
+ mock/feed.js ← AGENT-UI (scripted petition feed for ?mock=1 demos)
84
+ lib/ (vendored three r160 + addons + PointerLockControls — already in place; read-only)
85
+ city/
86
+ constants.js ← generated by tools/gen_web.py from engine/constants.py (AGENT-RFOUND hand-seeds it from THIS contract; gen overwrites later — values must match this doc)
87
+ rng.js (copied verbatim — mulberry32; read-only)
88
+ util.js ← AGENT-RFOUND (cellToWorld/worldToCell/key/clamp/lerp/eases/cityCenter)
89
+ scene.js ← AGENT-RFOUND (copied — adapt: fog, camera start, tilt-shift+grade passes, MSAA target, rig-swap hook, daylight wiring)
90
+ ground.js ← AGENT-RFOUND (ground chunks, river water, vertex-color lawn, contact-shadow quads API, occupancy mirror + isWalkable)
91
+ world.js ← AGENT-RFOUND skeleton; INTEGRATOR owns final wiring (event dispatch → subsystems)
92
+ replay.js (copied verbatim; read-only until integrate)
93
+ buildings.js ← AGENT-RKIT (the 19-kind procedural kit, global window InstancedMesh, hue/variant jitter)
94
+ construction.js ← AGENT-RKIT (dirt lot → scaffold → crane → floor-by-floor rise → finish; progress = f(ev.t, simTime))
95
+ roads.js ← AGENT-RTRAFFIC (road/bridge/sidewalk/crosswalk meshes from road cells; canvas atlas; lamp posts)
96
+ traffic.js ← AGENT-RTRAFFIC (road graph, A* + path cache, trip gen, 10Hz car CA, congestion EMA, Traffic Index, heatmap overlay)
97
+ vehicles.js ← AGENT-RTRAFFIC (instanced car kit rendering of traffic.js car states; head/taillights; blob shadows)
98
+ citizens.js ← AGENT-RTRAFFIC (instanced pedestrians on sidewalk loops)
99
+ sky.js ← AGENT-RCAM (sun arc, hemisphere, fog colors, gradient sky dome, stars; exported pure daylight(simTime))
100
+ camera.js ← AGENT-RCAM (orbit/pan/zoom rig, gaze(point,{hold}), landing choreography, bounds)
101
+ fpv.js ← AGENT-RCAM (pointer-lock walk: WASD+shift, occupancy collision w/ axis slide, enter/exit)
102
+ tools/
103
+ shot.mjs (copied — AGENT-UI adapts selectors/harness; the screenshot-and-LOOK loop is mandatory)
104
+ gen_web.py ← AGENT-ENGINE (emits web/city/constants.js + web/mock/world.json from engine constants/genesis)
105
+ tests/ ← test_engine.py + test_traffic.py (ENGINE), test_mind.py (MIND), test_api.py (SERVER)
106
+ traces/ (runtime output — wishes.jsonl; gitkeep only)
107
+ ```
108
+
109
+ Build waves: **Wave 1 (parallel):** ENGINE, MIND, SERVER, RFOUND, UI. **Wave 2 (parallel):**
110
+ RKIT, RTRAFFIC, RCAM. **Wave 3:** INTEGRATOR. **Wave 4:** verify (pytest + headless screenshots).
111
+
112
+ ## The grid
113
+
114
+ - 64×64 cells; cell coords `(cx, cz)` each in `[-32, 31]`. `CELL = 4` world units (= 4 m;
115
+ 1 unit = 1 m — human scale, FPV-ready). City plane = 256×256 u at y=0.
116
+ - World position of cell center: `x = cx*4 + 2`, `z = cz*4 + 2`. +X = east, +Z = south.
117
+ - A cell holds at most ONE of: road | water | building-footprint | park | empty.
118
+ - Buildings occupy `w×d` rectangular footprints, anchor = top-left (min cx, min cz) cell.
119
+ - **The river** (load-bearing: guarantees the traffic bottleneck + a beauty anchor). Water cell
120
+ columns per row, formula (identical in `engine/constants.py` and `web/city/constants.js`):
121
+
122
+ ```
123
+ riverCols(cz) = cz <= -5 ? [7, 8] : cz <= 3 ? [8, 9] : [9, 10]
124
+ ```
125
+
126
+ Water cells are unbuildable and uncrossable except on bridge cells (road cells over water).
127
+ - `growth_radius` = max Chebyshev distance of any developed (non-water) cell from (0,0), min 6.
128
+ Placement penalizes beyond `growth_radius + 2` → the city accretes outward, ring by ring.
129
+
130
+ ## Shared sim clock (every visitor sees the same city at the same moment)
131
+
132
+ - `CITY_EPOCH_S = 1781222400` (June 12 2026 00:00 UTC; constant in engine + web constants).
133
+ - `simTime = serverNow - CITY_EPOCH_S` (seconds). main.js estimates `serverNow` once from
134
+ `server_now` in `/api/state` (and SSE hello), then free-runs on the fixed-step clock, slewing
135
+ ±2% toward the server clock when a heartbeat shows >1.5 s drift. Never snap.
136
+ - ONE in-game day = `DAY_S = 240` seconds. `dayHours = (simTime % 240) / 240 * 24`.
137
+ - Rush hours: bell curves at dayHours 8 and 18 (σ≈1.2h) — judges see a rush within ~2 min.
138
+ - Day/night: `daylight(simTime)` ∈ [0,1], pure function in sky.js — smooth sunrise centered
139
+ dayHours 5.5, sunset centered 19.5 (transitions ~1.5h wide). Night is short and gorgeous.
140
+ - Construction progress (any client, incl. late joiners):
141
+ `progress = clamp((CITY_EPOCH_S + simTime - ev.t) / DURATION(kind), 0, 1)`.
142
+ - `tick(dt)`/`seek(t)` fixed-step core (already in scene.js) MUST keep working: timelapse,
143
+ replay, and shot.mjs depend on it.
144
+
145
+ ## Building table (CANONICAL — engine/constants.py is the source; gen_web.py mirrors to JS)
146
+
147
+ `kind: {w×d, floors min–max, residents, jobs, attract, duration_s}`
148
+
149
+ | kind | w×d | floors | residents | jobs | attract | duration |
150
+ |---|---|---|---|---|---|---|
151
+ | house | 1×1 | 1–2 | 4 | 0 | 0 | 20 |
152
+ | townhouse | 1×1 | 2–3 | 6 | 0 | 0 | 20 |
153
+ | apartments | 2×2 | 4–7 | 16 | 0 | 0 | 45 |
154
+ | cafe | 1×1 | 1 | 0 | 3 | 5 | 20 |
155
+ | shop | 1×1 | 1–2 | 0 | 4 | 4 | 20 |
156
+ | market | 1×2 | 1 | 0 | 8 | 6 | 30 |
157
+ | bank | 2×1 | 2–4 | 0 | 12 | 2 | 30 |
158
+ | office | 1×1 | 3–8 | 0 | 40 | 1 | 30 |
159
+ | tower | 2×2 | 8–16 | 0 | 60 | 2 | 45 |
160
+ | school | 2×2 | 1–2 | 0 | 10 | 3 | 30 |
161
+ | hospital | 3×3 | 3–6 | 0 | 25 | 2 | 45 |
162
+ | fire_station | 2×1 | 2 | 0 | 8 | 1 | 30 |
163
+ | warehouse | 2×2 | 1 | 0 | 10 | 0 | 30 |
164
+ | factory | 3×2 | 1–2 | 0 | 18 | 0 | 45 |
165
+ | park | 2×2 | 0 | 0 | 0 | 8 | 20 |
166
+ | plaza | 1×1 | 0 | 0 | 0 | 6 | 20 |
167
+ | stadium | 4×4 | 1 | 0 | 15 | 9 | 45 |
168
+ | church | 2×1 | 1 | 0 | 2 | 3 | 30 |
169
+ | town_hall | 2×2 | 2 | 0 | 6 | 4 | 45 |
170
+
171
+ - Mixed-use feel comes from variety + placement affinities, not more kinds.
172
+ - **Synonym map** (engine/tools.py — the model can never pick a kind we lack): skyscraper/
173
+ highrise→tower, apartment/apartment building/condo→apartments, coffee shop/diner/restaurant/
174
+ bakery→cafe, store/boutique/pharmacy→shop, mall/grocery/supermarket→market, police station/
175
+ police→fire_station, library/museum/city hall→town_hall, temple/mosque/chapel→church,
176
+ garden/playground→park, fountain/square→plaza, arena/stadium→stadium, clinic→hospital,
177
+ gym→shop, hotel→apartments, factory/plant→factory, anything unknown→**house**.
178
+ - `population = Σ residents × build_progress` (a finishing tower visibly ticks the HUD up).
179
+ - Citizens (pedestrians): `clamp(ceil(population/5), 6, 80)`.
180
+ - Demand-driven infill: after a granted petition, if `jobs > housing_capacity*1.15`, engine
181
+ appends ONE bonus house/apartments event (same wish_id, normal scorer, near the new jobs),
182
+ note blurb "New families are moving in." Max 1 infill per petition.
183
+
184
+ ## Roads & the road graph
185
+
186
+ - Roads are CELLS. Classes: `street` {capacity 2, speed 1.0 cells/s, 1 dash lane marking},
187
+ `avenue` {capacity 6, speed 1.6, wider look, double yellow center}. Road cells over water
188
+ render as BRIDGES (raised deck + railings; same graph semantics).
189
+ - Graph (derived IDENTICALLY in Python `engine/city.py` and JS `traffic.js`): node = road cell;
190
+ edge between 4-adjacent road cells. Intersection = road cell with ≥3 road neighbors.
191
+ - **Street names:** each maximal straight run of road cells laid in one event gets a name from a
192
+ curated list indexed by `crc32(event_id) % len(list)` ("Main St" and "1st Ave" and "Old Bridge"
193
+ fixed in genesis). Names power LLM stats ("Old Bridge carries 3.1× its capacity") + HUD.
194
+ - **Engine-routed roads only — the LLM never outputs geometry:**
195
+ 1. Placing a building with no road frontage auto-routes a CONNECTOR: BFS over empty cells from
196
+ footprint perimeter to nearest road cell (max length 8), Manhattan path, emitted as its own
197
+ `lay_road` event in the same wish.
198
+ 2. Traffic fixes lay engine-routed bypass roads (BFS avoiding jammed cells; water crossable at
199
+ 4× cost — fixes may deliberately build a second bridge).
200
+ - `road_version` = count of road-mutating events; all path caches key on it.
201
+
202
+ ## Traffic model (client-side presentation; Python mirror for facts)
203
+
204
+ - Fixed **10 Hz logic tick** (accumulator inside the existing 60 Hz fixed-step; render
205
+ interpolates). RNG = mulberry32 from event seeds only.
206
+ - **Trip generation** (static OD, gravity; recompute only when buildings change): each completed
207
+ residential building emits `residents` commuters; commuter `i` picks a destination via seeded
208
+ roulette over job/attract buildings, `weight = (jobs + 2*attract) / (1 + manhattanDist^1.5)`,
209
+ `seed = building.seed ^ i`.
210
+ - **Cars:** visible cars `N = clamp(round(population * 0.8 * rushFactor(simTime)), 8, 120)`,
211
+ `rushFactor = 0.35 + 0.65 * (bell(dayH,8,1.2) + bell(dayH,18,1.2))` (bell = gaussian, capped
212
+ at 1). Each car = one commuter loop home→work (dwell 10–20 s seeded)→home, phase-offset seeded.
213
+ - **Pathfinding:** A* over road cells, `cost(cell) = (1/speed(class)) * (1 + 2*demandRatio(cell))`
214
+ — congestion-aware so fixes visibly reroute. Cache per (origin, dest, road_version); on bump,
215
+ re-solve incrementally ≤20 paths/frame.
216
+ - **Movement — queueing cellular automaton** (no physics): car = scalar progress along its path;
217
+ per tick `speed = base * clamp(capacity / max(occ(nextCell), 1), 0.25, 1)`; if
218
+ `occ(nextCell) ≥ 2*capacity` the car HOLDS. occ = integer car count per road cell. Cars
219
+ visibly slow, bunch, queue back from the bridge. Intersection yield = deterministic priority
220
+ (lower car index). U-turn at dead ends.
221
+ - **Congestion:** per road cell `cong = EMA(occ/capacity, τ=5 s)`. **Traffic Index** =
222
+ `round(100 * mean(top-15 cong cells))`, color-coded on the HUD live.
223
+ - **Heatmap overlay** (toggle): one InstancedMesh of road quads, green < 0.5 / yellow < 1.0 /
224
+ red ≥ 1.0, instanceColor updated at 2 Hz. Jammed segments ALSO get an in-world additive red
225
+ underglow strip (#E84F3D) — utility visible from orbit, not just HUD.
226
+ - **Python mirror** (`engine/traffic.py`): same trip gen + A* + demand map from the same event
227
+ list/constants (static assignment, no CA). This feeds the LLM stats + fix candidates, so the
228
+ ENGINE owns the facts; live car queueing is presentation and may drift a car or two, harmless.
229
+ - **ACCEPTANCE (engine agent must verify, tune capacities/CAR rate until true):** genesis city
230
+ + ~3 extra houses west jams the Old Bridge (cong ≥ 1.0 red) at rush hour in mock mode.
231
+
232
+ ## The traffic-fix mechanic (V0-CRITICAL — the prize-winning beat; never let it slip)
233
+
234
+ 1. Client shows live Traffic Index; when any cell holds cong ≥ 1.0 for 10 s → pulsing chip
235
+ "Traffic alert: Old Bridge" + button **[Ask the City Engineer]**.
236
+ 2. `POST /api/fix` → engine gate: if Python static assignment max demandRatio < 0.8 → 409
237
+ "Traffic is flowing smoothly." Else enqueue a fix job (global cooldown 120 s, 2/hr/client).
238
+ 3. Engine computes stats snapshot: top-5 cells by demand/capacity WITH street names, worst
239
+ intersection, traffic_index, and 2–4 pre-validated CANDIDATE FIXES, e.g.
240
+ `{id:"F1", action:"new_road", desc:"second bridge at Oak St, 7 cells", predicted_index:41}`
241
+ (predicted by re-running static assignment with the candidate applied). Actions v0:
242
+ `new_road` (bypass/bridge) and `upgrade_avenue` (repaint a named street's cells to avenue).
243
+ 4. **ONE** @spaces.GPU Nemotron call: input {stats, candidates} → output
244
+ `{"diagnosis":"≤160 chars citing the real numbers","choice":"F1","blurb":"≤120"}`.
245
+ Invalid choice/parse failure → engine applies best predicted candidate + canned diagnosis.
246
+ 5. Engine appends ONE `apply_fix` event (exact cells + diagnosis + metrics_before/predicted);
247
+ SSE `world_delta`. Every client: road_version bumps, paths re-solve, the new road paints in
248
+ over 4 s, queued cars peel onto it, red drains green over ~20 s, HUD animates "78 → 41".
249
+
250
+ ## Placement algorithm (engine/placement.py — deterministic, NEVER fails, Python-only)
251
+
252
+ Input: kind (post-synonym), w×d, floors (clamped), anchor from the "near" hint, resolved fuzzily:
253
+ named building → its centroid; street name → midpoint; "center"/"downtown" → (0,0); "river" →
254
+ nearest water-adjacent developed cell; null/unresolvable → centroid of all buildings.
255
+
256
+ 1. Candidates = empty cells within Chebyshev radius R of anchor; R starts 6, +4 until ≥1 valid.
257
+ 2. VALID = footprint fits on empty non-water cells AND (perimeter touches a road OR connector
258
+ BFS ≤8 exists).
259
+ 3. SCORE: `+30*(1 - dist_to_anchor/R)` `+20*roadFrontage` `+3/developed-8-neighbor (cap +18)`
260
+ + affinities: cafe/shop +12 if house/apartments ≤3 cells; office/bank/tower
261
+ `+10*(1 - dist_to_center/12)` (downtown skyline emerges); house/townhouse/apartments +8 if
262
+ cafe/shop/park ≤4, −6 if factory/warehouse ≤3; factory/warehouse −10 if residential ≤3, +8
263
+ if industrial ≤4 (an industrial quarter self-organizes); park −25 if another park ≤6;
264
+ `−8*max(0, chebyshev_from_center − (growth_radius+2))` (ring growth).
265
+ 4. floors: office/tower `clamp(base + round(4*(1 − dist_to_center/10)), range)` — taller downtown.
266
+ 5. Tie-break: seeded jitter `U(0,1)` from `crc32(wish_id:candidate_index)`.
267
+ 6. Emit `place_building` (exact cells FINAL) + optional connector `lay_road`. Re-derive occupancy
268
+ after EACH building within a multi-building petition (siblings cluster, never overlap).
269
+
270
+ ## Event schema (append-only; byte-compatible with godseed Feature: id, wish_id, tool, args, seed, t)
271
+
272
+ `seed = crc32(f"{wish_id}:{call_index}") & 0x7fffffff`. City-at-T = pure fn of (events t≤T, T).
273
+
274
+ ```json
275
+ {"id":"e_000017","wish_id":"w_000005","tool":"place_building",
276
+ "args":{"kind":"cafe","name":"Cafe Luna","cx":3,"cz":-2,"w":1,"d":1,"floors":1,"hue":35,"variant":2},
277
+ "seed":1830293847,"t":1781230000.0}
278
+
279
+ {"id":"e_000018","wish_id":"w_000005","tool":"lay_road",
280
+ "args":{"cells":[[3,-1],[4,-1],[5,-1]],"klass":"street","name":"Olive St"},"seed":482919,"t":1781230000.5}
281
+
282
+ {"id":"e_000031","wish_id":"w_000009","tool":"apply_fix",
283
+ "args":{"action":"new_road","cells":[[5,-6],[6,-6],[7,-6],[8,-6],[9,-6]],"klass":"avenue",
284
+ "name":"Oak St Bridge","diagnosis":"Old Bridge carries 3.1x capacity at rush — a second crossing cuts the index 78→41.",
285
+ "metrics_before":{"traffic_index":78,"worst":"Old Bridge"},"metrics_predicted":{"traffic_index":41}},
286
+ "seed":99182,"t":1781231100.0}
287
+
288
+ {"id":"e_000032","wish_id":"w_000009","tool":"note","args":{"text":"New families are moving in.","kind":"infill"},"seed":7,"t":1781231101.0}
289
+ ```
290
+
291
+ Derived, never stored: occupancy, road graph + names, road_version, population, demand map,
292
+ growth_radius. WishTrace persists per petition exactly like godseed (text, moderation, RAW LLM
293
+ JSON, event ids, ms_total) → `traces/wishes.jsonl` → HF dataset (Sharing badge + NVIDIA evidence).
294
+
295
+ ## Genesis (epoch 0 — engine/genesis.py is canonical; web/mock/world.json is GENERATED from it)
296
+
297
+ wish_id "genesis", t = CITY_EPOCH_S. Order: roads, then buildings, then a note.
298
+
299
+ Roads:
300
+ - **Main St** — avenue, cx=0, cz −12..12.
301
+ - **1st Ave** — avenue, cz=0, cx −12..16, EXCEPT the two river cells.
302
+ - **Old Bridge** — **street** class (the bottleneck by design), cells (8,0),(9,0) over water.
303
+ - **River Rd** — street, cx=13, cz −6..6.
304
+ - **Elm St** — street, cz=−5, cx −8..0.
305
+ - **2nd St** — street, cx=−5, cz −5..3.
306
+
307
+ Buildings (anchor top-left; every footprint touches a road; engine must assert validity):
308
+ - town_hall 2×2 @ (−2,−3) — faces Main St.
309
+ - office 1×1, 5 floors @ (1,−2); cafe 1×1 @ (1,1); market 1×2 @ (−1,1).
310
+ - park 2×2 @ (−4,1) — beside 2nd St + 1st Ave.
311
+ - houses 1×1 @ (−1,−6), (−4,−6), (−6,−4) — Elm/2nd St cluster.
312
+ - EAST of the river (jobs/homes that force bridge commutes from day one):
313
+ factory 3×2 @ (14,−3); warehouse 2×2 @ (14,1); houses @ (14,3), (14,−5) — all on River Rd.
314
+ - note: {"text":"Nemocity is listening. Ask for a building.","kind":"milestone"}
315
+
316
+ Genesis population ≈ 20, jobs ≈ 95, one street-class bridge carrying every river crossing.
317
+
318
+ ## LLM one-shot schemas (mind/ — small, flat, enum-heavy; a 9B nails these)
319
+
320
+ **BUILD** — input: petition text (≤160 chars) + engine summary (≤600 chars: city name, pop,
321
+ counts by kind, named landmarks/streets, traffic index). Output:
322
+
323
+ ```json
324
+ {"intent":"build","blurb":"≤140 chars, warm city-planner voice",
325
+ "buildings":[{"kind":"cafe","name":"Cafe Luna","near":"the park","floors":4,"hue":35}],
326
+ "decline_reason":null}
327
+ ```
328
+
329
+ - buildings 1..3; kind = table enum (synonyms repaired by engine); name ≤24 chars (wordlist-
330
+ checked, default derived from petition words); near = free text the ENGINE resolves; floors/
331
+ hue optional. intent "decline" (+reason) only for nonsense — moderation already gated abuse.
332
+ - Parse failure after 1 retry → deterministic fallback: one house, name from petition words,
333
+ blurb "The city granted a home." Something ALWAYS builds.
334
+
335
+ **FIX** — input: {stats, candidates} as in the fix mechanic. Output:
336
+ `{"diagnosis":"≤160","choice":"F1","blurb":"≤120"}`. Invalid choice → best predicted candidate.
337
+
338
+ **Prompting** (mind/prompts.py): persona = terse municipal planner AI ("You are city hall of one
339
+ shared miniature city. The engine owns all facts; you only translate petitions into permits.
340
+ Build generously — a district request deserves 2–3 buildings. Never refuse a buildable thing.").
341
+ Tool/kind docs rendered from engine tables (single source). NO multi-example few-shot (GODSEED
342
+ leakage bug) — ONE inline example-shape JSON + "your own values, JSON only". Backend: copied
343
+ ZeroGPUBackend (resident cuda at load, @spaces.GPU(duration=240), /no_think system slot,
344
+ <think> strip, temperature 0.4 for JSON, max_new_tokens 700, lenient parse + 1 retry).
345
+
346
+ ## HTTP API + SSE (server — keep godseed wire names; adapt fields)
347
+
348
+ ```
349
+ GET /api/state → {world:{version,epoch,features:[...]}, queue, wishes_recent, server_now}
350
+ POST /api/wish {text} → {wish_id, position} (petition; 400 poetic reason on moderation reject)
351
+ POST /api/fix → {wish_id, position} (409 if traffic is smooth; cooldowns)
352
+ GET /api/stream → SSE (X-Accel-Buffering: no)
353
+ GET /api/wishes[, /{id}]→ ledger index / full trace (replay)
354
+ GET /api/turn?wish_id= → {token} (cookie-bound; ZeroGPU browser-invoke dance — copied)
355
+ @app.api("grant") → gradio endpoint the petitioner's browser calls (their GPU quota)
356
+ GET / → web/ static (mounted AFTER launch — the route-eviction trick, copied)
357
+ ```
358
+
359
+ SSE events (godseed names + ONE new event):
360
+ ```
361
+ {type:"hello", world_version, epoch, server_now}
362
+ {type:"queue", length, current}
363
+ {type:"wish_started", wish_id, text}
364
+ {type:"plan", wish_id, plan:{...raw validated LLM JSON...}} ← NEW: City Hall paperwork (NVIDIA evidence, shown in UI)
365
+ {type:"tool_call", wish_id, call_index, tool, args}
366
+ {type:"world_delta", feature:{...}}
367
+ {type:"wish_granted", wish_id, epitaph, epoch} (epitaph = the blurb)
368
+ {type:"wish_rejected", wish_id, reason}
369
+ {type:"heartbeat", server_now}
370
+ ```
371
+
372
+ Env vars: `CITY_BACKEND` (mock|zerogpu; llamacpp stretch), `CITY_DATASET`, `CITY_JUDGE`,
373
+ `CITY_INVOKE`, `CITY_MOCK_DELAY`, `CITY_SECRET`, `CITY_HF_MODEL`, `HF_TOKEN`, `PORT`.
374
+ Rate limits: 3 petitions/hr/client (cookie+IP), 1 pending/client, queue cap 50, fix cooldowns.
375
+
376
+ ## Visual Bible — "Golden Hour Toybox" (miniature diorama / tilt-shift)
377
+
378
+ **Thesis: the city should feel like a hand-built miniature model village on a sunlit table —
379
+ small enough to love, alive enough to watch grow.** ("Build Small" made literal.)
380
+
381
+ Palette (hex — `web/city/palette.js` carries these; AGENT-RFOUND owns it. constants.js holds
382
+ only engine-mirrored values since gen_web.py overwrites it):
383
+ - Ground: lawn `#8FC474` (±3% per-cell lightness jitter, vertex colors), park `#6FB257`,
384
+ dirt lot `#CFB78F`. Water `#58AFC7` (darken 12% center). Sidewalk `#D6D2C4` w/ 0.06 u curb lip.
385
+ - Roads: asphalt `#565B66` (warm dark gray, never black), markings warm-white `#F5F1E6`,
386
+ center-line yellow `#EFC75E`.
387
+ - Facades (8, seeded ±6° hue ±5% lightness jitter): cream `#F4E9D0`, terracotta `#E07856`,
388
+ butter `#F2CC8F`, coral `#EC9AA2`, powder blue `#8FB8D8`, dusty teal `#7FB69E`, sage
389
+ `#A9B98A`, brick `#BD6B6E`. Civic white `#F8F4EA` (bank/town_hall/hospital/church).
390
+ - Roofs: clay `#C06B4F` (houses/church), slate `#5C6470` (apartments/school), charcoal
391
+ `#3F454F` (offices/towers), gravel `#D9D5CB` (flat commercial — HVAC boxes read).
392
+ - Sky: day zenith `#7BC1E8` / horizon `#FFE9C2`; night zenith `#141C36` / horizon `#2C3760`
393
+ (indigo, never black). Fog = horizon color (day near 140 far 420; night near 100 far 360).
394
+ - Emissives (night money colors): window glow `#FFC97A`, streetlamp `#FFD9A0`, headlight
395
+ `#FFF6DC`, taillight/signal red `#FF5340`, signal amber `#FFB13D`, signal green `#43D17C`,
396
+ neon teal `#45E0C0`, neon pink `#FF6FA6`, construction orange `#FF8A3D`, congestion red
397
+ underglow `#E84F3D`.
398
+
399
+ Lighting: ONE shadow-casting DirectionalLight (the sun), golden-hour default `#FFCE96` @ 2.4,
400
+ elev 28°, az 235°; arcs with the day cycle (noon `#FFE3BB` @2.8 elev 62°; dusk `#FF9E62` @1.2;
401
+ night = moon `#8FA8D8` @0.15). PCFSoftShadowMap, map 2048 (1024 small), ortho frustum fit to
402
+ city bounds +10%, texel-snapped, re-fit only on growth; bias −0.00035, normalBias 0.5. Casters:
403
+ building InstancedMeshes + trees ONLY. Cars/citizens/trees use instanced radial-gradient blob
404
+ quads (sells the miniature). Hemisphere fill sky `#BCD7EA`/ground `#C7B18E` @0.6 day →
405
+ `#2B3450`/`#161B28` @0.3 night. NO AmbientLight. RoomEnvironment PMREM, envMapIntensity 0.25
406
+ per-material. Max 4 PointLights citywide (active construction, newest build). Window emissives
407
+ stagger on over ~8 s at dusk (in-shader hash threshold — zero CPU).
408
+
409
+ Postprocessing (in order): RenderPass → UnrealBloomPass (DAY 0.32/0.35/0.85 — pastel facades
410
+ must NOT glow in daylight; NIGHT 0.70/0.45/0.62; lerp by daylight) → tilt-shift H pass →
411
+ tilt-shift V pass (+ grade folded in: saturation +8%, warm shadow lift +0.02, vignette
412
+ 0.25/0.75) → OutputPass. Tilt-shift = classic separable 9-tap Gaussian, weights
413
+ [.051 .0918 .12 .1531 .1633 .1531 .12 .0918 .051], per-fragment radius
414
+ `maxBlur * smoothstep(bandHalf, bandHalf+falloff, abs(vUv.y - focusY))`, focusY tracks the
415
+ orbit target's screen y, bandHalf 0.13, falloff 0.22, maxBlur 2.2 px × pixelRatio. Amount: 1.0
416
+ god view → lerp to 0 between camera heights 60→25 m → **0.0 in FPV (hard requirement — DoF in
417
+ first person is nauseating)**. MSAA: composer target `samples: 4` (2 on small). NO BokehPass,
418
+ NO SSAO — fake AO: baked dark edges in the road atlas + instanced contact-shadow quads under
419
+ every building (footprint ×1.25, opacity 0.22) + tree blobs ×0.5.
420
+
421
+ Roads/ground: ground = one plane per 16×16-cell chunk, vertex-colored per cell. Roads = flat
422
+ quads y=0.02, ONE InstancedMesh, per-instance UV offset into a procedurally-drawn 512² canvas
423
+ atlas (8 tiles: street straight, avenue straight, intersection, crosswalk approach, T-junction,
424
+ parking edge, dirt, plaza paving) — dashes/stop lines/crosswalks PAINTED in the atlas, zero
425
+ extra geometry, 6 px soft dark edge baked. Sidewalk curb strips instanced on road cells adjacent
426
+ to zoned cells. Street furniture: instanced lamp posts every 3rd road cell (emissive head +
427
+ fake light-pool quad at night), hydrants/mailbox dots at corners, 10% density.
428
+
429
+ Buildings (19 kinds): assembled from a shared low-poly part kit (wall box, gabled/flat roof,
430
+ awning, AC boxes, water tower, chimney, steeple, smokestack, drive-thru-style signage on shop/
431
+ cafe, fire-escape strip on apartments) — ONE InstancedMesh per part archetype with
432
+ instanceColor; whole city ≈ 10–14 draw calls. Windows: ONE GLOBAL InstancedMesh citywide,
433
+ emissive atlas, in-shader night flicker. Signature silhouettes matter more than detail: water
434
+ towers on apartments, AC clusters, awnings + sidewalk tables on cafes (neon at night), sawtooth
435
+ factory roof + smokestack, hospital cross, church steeple, stadium light masts, town_hall dome.
436
+
437
+ Motion: cars = two-box kit (body + cabin, dark skirt fakes wheels), 7 seeded paint colors,
438
+ right-lane offset, ease at intersections, ±2° pitch bobble on accel/brake; night headlight
439
+ emissive dots + 2 short additive cone quads + taillights. Citizens = instanced capsule + head,
440
+ 2-tone seeded clothes, 1.5 Hz walk bob; spawn near homes dawn / shops midday / drain home at
441
+ dusk. Construction (the "watch it being built" payoff): [0,.12] dirt + dust ring → (.12,.30]
442
+ orange scaffold wireframe pops to height (+ barrier cubes + blinking amber) + kit crane (mast,
443
+ counterweighted jib rotating 0.15 rad/s) on ≥4-floor builds → (.30,.90] floors rise in
444
+ quantized easeOutBack steps → (.90,1] scaffold drops, windows light bottom-to-top, sparkle +
445
+ floating name label ("Cafe Luna — built for visitor #12"). Roads: a roller prop sweeps the path
446
+ painting asphalt over 4 s; bridges add a deck-drop. Idle cranes over a skyline = a city mid-boom.
447
+
448
+ Landing choreography (first paint <1 s, ZERO model calls, no spinner ever): T+0 frame one IS
449
+ the screenshot — golden hour, long shadows, camera LOW (~10 m) behind a moving car on Main St,
450
+ tilt-shift full. T0–3.5 s slow dolly forward along the avenue. T3.5–7 s single eased crane up +
451
+ pull back to god view (38° pitch, downtown rule-of-thirds left, skyline against warm horizon).
452
+ T7–10 s gentle 0.5°/s drift; a guaranteed construction event plays in view (if the live queue
453
+ is empty the client keeps one scripted "welcome build" so a crane is ALWAYS rising when a judge
454
+ arrives); ticker types its first headline; petition input pulses once. Any input cancels →
455
+ orbit camera. Walk button drops to FPV (tilt-shift fades 0.4 s).
456
+
457
+ HUD ("museum placards around a diorama" — labels NEXT TO the model, never chrome OVER it):
458
+ fonts = Bricolage Grotesque 600/800 (display), Schibsted Grotesk 400/500 (UI), Spline Sans Mono
459
+ (ticker/queue). Panels: warm frosted glass — `rgba(252,249,242,0.78)`, blur(14px), 1px
460
+ `rgba(60,50,40,0.12)` border, radius 14px, text `#2A2620`; panels stay light at night (lit
461
+ placards in a dark gallery). Layout: top-left city name + stat chips (population odometer,
462
+ buildings, Traffic Index color-coded, day clock, "Built by N visitors"); bottom-center ONE
463
+ rounded petition field + Build button (placeholder cycles: "a ramen shop near the park…", "a
464
+ fire station downtown…"), lifecycle chips above it (queued → city hall is reading (animated
465
+ ellipsis) → under construction → built, each with jump-camera link); bottom edge 28 px
466
+ news-ticker (mono, LLM blurbs as headlines: "MAYOR CUTS RIBBON ON CAFE LUNA — TRAFFIC ON OLD
467
+ BRIDGE UP 12%"); top-right: Walk toggle, sound toggle, PHOTO button (fades ALL UI for clean
468
+ screenshots). Accents: terracotta #E07856 primary, congestion red only for traffic warnings.
469
+ A "City Hall paperwork" drawer shows the raw Nemotron JSON per petition (NVIDIA evidence).
470
+ Chrome <15% of screen; every panel fades 250 ms.
471
+
472
+ Sound (stretch, OFF until toggled): WebAudio-synthesized only — filtered-noise traffic hum,
473
+ bird blips day / cricket ticks night, soft thunk per construction floor, marimba chime on
474
+ grant, single honk when a jam forms.
475
+
476
+ ## FPV walk mode (web/city/fpv.js)
477
+
478
+ - Vendored `lib/addons/controls/PointerLockControls.js` (r160) for look; fpv.js adds WASD
479
+ (target 5 m/s, Shift 9 m/s, exponential accel/decel) integrated in the FIXED-STEP tick.
480
+ - Eye height 1.65 m; camera near 0.1 far 1500 (fog covers the horizon).
481
+ - Collision: occupancy-grid only — player circle r=0.35 m; test X and Z displacement
482
+ independently against building cells, cancel only the blocked axis (axis-slide). Roads,
483
+ sidewalks, parks, plazas walkable; water blocks (except bridge cells); clamp to city plot
484
+ −2-cell margin with soft vignette hint.
485
+ - Enter: "Walk" button / F key → requestPointerLock, 300 ms fade, spawn at nearest road cell to
486
+ camera look-point. Exit: pointerlockchange (NOT keydown ESC) → restore orbit rig over player.
487
+ - ONE camera, one scene — FPV is a controller swap via scene.js's rig-swap hook. Tilt-shift
488
+ amount = 0 in FPV. Mobile: hide the Walk button on touch (v0); dual-zone joystick is stretch.
489
+
490
+ ## Perf budgets (enforced, not hoped)
491
+
492
+ Targets: 60 fps mid laptop, <300 draw calls, <1.2 M tris, pixelRatio ≤2 (1.7 small).
493
+ Caps: buildings ≤400, cars ≤120, pedestrians ≤80, trees/props ≤1500, road cells ≤600.
494
+ InstancedMesh per part-archetype (NEVER one Group per building — godseed's per-structure
495
+ PointLight habit dies at city scale). Static meshes matrixAutoUpdate=false. Roads/river/ground
496
+ rebuilt only on road events. A* cached per road_version, incremental re-solve ≤20 paths/frame.
497
+ Degrade ladder (fps<45 for 3 s or hardwareConcurrency≤4): halve cars+peds → bloom off ��
498
+ shadows off → pixelRatio 1. LOD: beyond 180 m swap buildings to untextured-box instance set,
499
+ hide pedestrians. All canvas atlases generated once at boot. dispose() discipline on
500
+ construction particles.
501
+
502
+ ## Local dev + verification
503
+
504
+ - Python 3.12 venv via uv: `uv venv -p 3.12 .venv && uv pip install -r requirements.txt`
505
+ (mock backend needs NO ML deps — fastapi/uvicorn/gradio/pydantic/hub only).
506
+ - `CITY_BACKEND=mock PORT=7900 .venv/bin/python app.py` → http://localhost:7900
507
+ - Renderer standalone: `python3 -m http.server 8123 --directory web` + `?mock=1`
508
+ (web/mock/world.json + feed.js; zero backend).
509
+ - **The screenshot-and-LOOK loop is mandatory** (don't ship the first thing that renders):
510
+ `node tools/shot.mjs` variants — god view day, god view night, street FPV, construction
511
+ close-up, heatmap on. window.__app harness (seek/tick/stats) must keep working.
512
+ - pytest: engine (placement determinism, clamps, genesis validity, traffic acceptance), mind
513
+ (parse/salvage/fallback), api (SSE flow, rate limits, fix gate, injected fakes).
514
+
515
+ ## Blueprint Update (v1.1 contract — June 12 PM. ⚠️ NOT part of the v0 build wave: v0 agents
516
+ ## and the v0 integrator IGNORE this section entirely. It ships as its own wave after v0 verifies.)
517
+
518
+ User-requested: petitions like "a Korean BBQ restaurant" must produce a building that looks
519
+ UNIQUE — not just the generic cafe model. Mechanism: **bones + LLM-designed decoration**. The
520
+ kit archetype is the bones (walls/floors/windows/roof — unchanged, instanced, fast); Nemotron
521
+ additionally emits a compact STYLE BLUEPRINT the deterministic renderer interprets. No 3D-gen
522
+ model (slow, ugly, non-deterministic, breaks zero-GPU replay); the LLM becomes the *architect*
523
+ — a stronger Nemotron-load-bearing story, and the blueprint JSON shows in the paperwork drawer.
524
+
525
+ Backwards compatible: `place_building.args.style` is OPTIONAL. Absent/partial style → engine
526
+ fills a seeded per-kind default pack, so old events and fallback builds render unchanged-or-richer.
527
+
528
+ ### style schema (LLM-emitted inside each buildings[] entry; engine clamps EVERYTHING)
529
+
530
+ ```json
531
+ "style": {
532
+ "facade_hue": 12, "trim_hue": 350,
533
+ "roof": "flat|gabled|hipped|sawtooth|dome|pagoda",
534
+ "roof_color": "clay|slate|charcoal|gravel|copper",
535
+ "windows": "grid|tall|arched|round|shoji",
536
+ "awning": {"hues": [350, 40], "shape": "flat|scalloped"},
537
+ "sign": {"style": "hanging|vertical|rooftop|window", "neon_hue": 0,
538
+ "icon": "bowl|flame|cup|leaf|star|fish|bread|music|cross|book|gear|heart|moon|paw|cart|none"},
539
+ "props": ["lanterns", "smoke_vent"],
540
+ "vibe": "charcoal smoke and red neon"
541
+ }
542
+ ```
543
+
544
+ - Enum-heavy on purpose (9B-reliable). Engine: unknown enum → dropped/default; hues → 0..360;
545
+ `props` ≤4 from: lanterns, string_lights, plants, umbrellas, barrels, crates, smoke_vent,
546
+ chimney_smoke, banner, menu_board, bike_rack, flag, grill_smoke. `vibe` ≤40 chars is
547
+ TOOLTIP/ticker text only — NEVER rendered as world geometry.
548
+ - The sign renders the building **name** (already wordlist-moderated — the only world text) +
549
+ icon glyph + neon hue. Sign textures live in ONE shared canvas atlas (256×128/slot, grown in
550
+ chunks); sign quads instanced with per-instance UV offset. Icons = ~16 pre-drawn glyphs.
551
+ - Props are instanced prop archetypes (a lantern string = sagging line + emissive spheres;
552
+ smoke = ONE shared particle system with per-emitter offsets). All bounded: ≤4/building.
553
+ - Seeded default packs per kind (engine table): e.g. cafe → menu_board+awning+window sign;
554
+ factory → smoke stack already in kit. Defaults keyed by `seed` so two unstyle'd houses differ.
555
+ - Prompt addition (mind): "Design the building too: pick a roof, windows, awning, a sign icon
556
+ and neon color, and up to 3 props that match the petition's culture/cuisine/mood. A Korean
557
+ BBQ wants flame icon, smoke_vent, grill_smoke, red neon; a ramen shop wants bowl icon,
558
+ lanterns, banner." max_new_tokens 700 → 900.
559
+ - File ownership for this wave: ENGINE (tools.py style clamp tables + default packs +
560
+ constants), MIND (prompts/validate/mock_scripts style), RKIT (buildings.js decor layer:
561
+ sign atlas, prop archetypes, roof/window variants), UI (paperwork drawer shows style;
562
+ tooltip vibe). gen_web.py mirrors the style enums.
563
+
564
+ ## Deploy (June 12 night / 13 morning — see hf-spaces-deploy-pins memory for war stories)
565
+
566
+ 1. README YAML: `sdk: gradio`, `app_file: app.py`, **NO sdk_version** (transformers≥5.4 has
567
+ nemotron_h IN-TREE, pairs with default gradio 6.18/hub≥1.5 — godseed's final shipped recipe).
568
+ `models: [nvidia/NVIDIA-Nemotron-Nano-9B-v2]`, `datasets: [AndresCarreon/nemocity-world]`,
569
+ tags: `build-small-hackathon, track:wood, sponsor:nvidia, achievement:offbrand,
570
+ achievement:sharing, achievement:fieldnotes, thousand-token-wood, nemotron, nvidia,
571
+ simulation, city-builder, world-building, three-js, multiplayer` (+offgrid/llama if the
572
+ llamacpp path ships). README must LINK demo video + social post (placeholders until June 13/14).
573
+ 2. requirements.txt: fastapi>=0.115, uvicorn>=0.30, pydantic>=2.7, huggingface_hub>=0.30,
574
+ transformers>=5.4, accelerate, einops, sentencepiece. **NEVER pin gradio/spaces/torch**
575
+ (image force-installs them). NO mamba-ssm/causal-conv1d. dtype= (not torch_dtype=).
576
+ 3. app.py invariants (already copied): `import spaces` before torch when CITY_BACKEND=zerogpu;
577
+ gr.Server (no gr.Blocks); launch(prevent_thread_lock=True) THEN mount_static() route
578
+ eviction; ensure_worker lazy start; SSE X-Accel-Buffering: no; SameSite=None+Secure cookies.
579
+ 4. Space: `build-small-hackathon/nemocity` + dataset `AndresCarreon/nemocity-world`. Secrets:
580
+ HF_TOKEN; vars: CITY_DATASET, CITY_BACKEND=mock first. `request_space_hardware('zero-a10g')`.
581
+ Never set_space_sleep_time on ZeroGPU. Deploy mock → verify landing/SSE/persistence → flip
582
+ CITY_BACKEND=zerogpu → one real signed-in petition → tail logs.
583
+ 5. If the 9B fails to load: fall back to mock granting, KEEP SERVING the city (copied posture).
README.md CHANGED
@@ -1,13 +1,147 @@
1
  ---
2
- title: Nemocity
3
- emoji: 📚
4
- colorFrom: gray
5
  colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: NEMOCITY
3
+ emoji: 🏙️
4
+ colorFrom: red
5
  colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 6.16.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ short_description: Ask for a building, watch one tiny shared city rise
12
+ models:
13
+ - nvidia/NVIDIA-Nemotron-Nano-9B-v2
14
+ datasets:
15
+ - AndresCarreon/nemocity-world
16
+ tags:
17
+ - build-small-hackathon
18
+ - track:wood
19
+ - sponsor:nvidia
20
+ - achievement:offbrand
21
+ - achievement:sharing
22
+ - achievement:fieldnotes
23
+ - thousand-token-wood
24
+ - nemotron
25
+ - nvidia
26
+ - simulation
27
+ - city-builder
28
+ - world-building
29
+ - three-js
30
+ - multiplayer
31
  ---
32
 
33
+ <!-- ^ Space card config. app.py runs as a Gradio Server (gr.Server): it serves
34
+ the WebGL city at /, the JSON/SSE API under /api, and the gradio endpoint
35
+ that runs the granting call under /gradio_api. sdk_version 6.16.0 is the
36
+ pre-SSR gradio proven live on the org ZeroGPU (pareidolia) with this exact
37
+ launch pattern; pairs with transformers <5 (nemotron_h in-tree from 4.55.4). -->
38
+
39
+ # NEMOCITY
40
+
41
+ **Ask for a building and watch it rise — one tiny persistent city the whole
42
+ internet builds, with a 9B Nemotron running city hall.**
43
+
44
+ Type a petition — *"a ramen shop near the park"* — and a small NVIDIA Nemotron
45
+ reads it at city hall, the deterministic engine zones a lot, and you (and every
46
+ other visitor on Earth) watch it rise floor by floor in ONE shared miniature
47
+ city. There is no level select and no reset button: your cafe stands next to a
48
+ stranger's fire station, forever, on the same 64x64 grid.
49
+
50
+ The city is alive between petitions. Cars commute on a shared deterministic
51
+ clock, rush hour jams the Old Bridge twice a day, windows light at dusk, and a
52
+ news ticker reads the model's blurbs as headlines. Press Walk and you are on the
53
+ sidewalk in first person, at street level, under the buildings the internet
54
+ asked for.
55
+
56
+ The whole city renders client-side from an append-only event log — judges see a
57
+ gorgeous living diorama with zero GPU spend. The GPU wakes exactly once per
58
+ action, for one one-shot JSON generation.
59
+
60
+ ## How city hall works (the "her" pattern)
61
+
62
+ The deterministic engine owns ALL facts: city state, placement, geometry, road
63
+ routing, traffic telemetry. [NVIDIA-Nemotron-Nano-9B-v2](https://huggingface.co/nvidia/NVIDIA-Nemotron-Nano-9B-v2)
64
+ never outputs a coordinate — it translates your petition into one tiny JSON
65
+ permit (kinds, names, a fuzzy "near" hint) and narrates. The engine resolves,
66
+ clamps, and repairs everything, so a visitor action ALWAYS yields visible
67
+ construction. The model's raw JSON is shown in the UI on every action (the
68
+ "City Hall paperwork" drawer).
69
+
70
+ ```
71
+ your petition ("a ramen shop near the park")
72
+ |
73
+ v
74
+ +- CITY HALL - Nemotron-Nano-9B-v2 (ONE one-shot call) -----+
75
+ | {"intent":"build","blurb":"...", |
76
+ | "buildings":[{"kind":"cafe","name":"Ramen Ichi", |
77
+ | "near":"the park"}]} |
78
+ | no coordinates, ever - kinds + names + fuzzy hints only |
79
+ +------------------------------+----------------------------+
80
+ | permit JSON
81
+ v
82
+ +- THE ENGINE - deterministic Python -----------------------+
83
+ | synonym-repairs kinds -> scores placement -> clamps |
84
+ | floors -> routes connector roads -> seeds + appends |
85
+ | events; owns ALL state, the model owns none of it |
86
+ +------------------------------+----------------------------+
87
+ | world_delta (SSE broadcast)
88
+ v
89
+ +- THE CITY - Three.js, client-side, zero GPU ---------------+
90
+ | pure function of (event list, shared simTime): |
91
+ | buildings rise floor by floor, cars commute, jams form |
92
+ +------------------------------------------------------------+
93
+ ```
94
+
95
+ ## The traffic fix (ask the City Engineer)
96
+
97
+ Rush hour is real, simulated telemetry — not theater. When a road segment stays
98
+ jammed, a traffic alert appears with one button: **Ask the City Engineer**. The
99
+ engine snapshots the facts (worst cells by demand/capacity with street names,
100
+ the Traffic Index) and pre-validates candidate fixes with predicted outcomes by
101
+ re-running its static traffic assignment. Nemotron gets `{stats, candidates}`
102
+ and answers with a diagnosis citing the real numbers plus a choice — *"Old
103
+ Bridge carries 3.1x capacity at rush — a second crossing cuts the index
104
+ 78 to 41."* The engine lays the new road, every client's cars re-route live, and
105
+ the red drains green while you watch. Facts from the engine, judgment from the
106
+ model — small models doing what small models are great at.
107
+
108
+ ## Built for Build Small — Thousand Token Wood
109
+
110
+ - One model, 9B params, in-process on ZeroGPU — comfortably under the 32B cap.
111
+ - **Off-Brand** — fully custom WebGL frontend on `gr.Server`; gradio powers the
112
+ API endpoints, not the visible UI.
113
+ - **Sharing is Caring** — every petition's full trace (text, moderation verdict,
114
+ raw model JSON, resulting events) appends live to a public dataset:
115
+ [AndresCarreon/nemocity-world](https://huggingface.co/datasets/AndresCarreon/nemocity-world).
116
+ The dataset also makes the city persistent across Space restarts.
117
+ - **Field Notes** — build log shipping with the submission.
118
+
119
+ ## Submission links
120
+
121
+ <!-- TODO(fill-at-deploy): replace both placeholders before the deadline. -->
122
+
123
+ - **Demo video:** *coming before the deadline — link will land here.*
124
+ - **Social post:** *coming before the deadline — link will land here.*
125
+ - **Trace dataset:** [AndresCarreon/nemocity-world](https://huggingface.co/datasets/AndresCarreon/nemocity-world)
126
+
127
+ ## Run it locally
128
+
129
+ ```bash
130
+ uv venv -p 3.12 .venv && uv pip install -r requirements.txt
131
+ CITY_BACKEND=mock PORT=7900 .venv/bin/python app.py # open http://localhost:7900
132
+ ```
133
+
134
+ Renderer alone (no backend): `python3 -m http.server 8123 --directory web` and
135
+ open `http://localhost:8123/?mock=1`.
136
+
137
+ Env: `CITY_BACKEND` (`mock | zerogpu`), `CITY_DATASET` + `HF_TOKEN` (trace
138
+ publishing and persistence across restarts), `CITY_SECRET` (stable visitor
139
+ identity), `CITY_JUDGE` (optional LLM moderation layer), `CITY_INVOKE`
140
+ (`browser | server` GPU invocation), `CITY_MOCK_DELAY`, `CITY_HF_MODEL`, `PORT`.
141
+
142
+ ## Credits
143
+
144
+ - **LLM:** NVIDIA Nemotron Nano 9B v2 (open weights), via transformers on ZeroGPU.
145
+ - **Rendering:** [three.js](https://threejs.org) (MIT), vendored.
146
+
147
+ *Ask, and the city builds.*
app.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY — Space entrypoint. gr.Server (FastAPI) + SSE + the WebGL city.
2
+
3
+ One tiny persistent city the whole internet builds, with a 9B Nemotron at city hall.
4
+
5
+ Wiring (ARCHITECTURE.md is the contract; wire names keep godseed's wish_*):
6
+
7
+ web/ (Three.js city) <-- GET / static (mounted after launch)
8
+ city snapshot <-- GET /api/state (includes server_now: shared sim clock)
9
+ file a petition <-- POST /api/wish (rate-limited, moderation fast-path)
10
+ ask the City Engineer <-- POST /api/fix (409 when traffic flows; cooldowns)
11
+ live event stream <-- GET /api/stream SSE broadcast (X-Accel-Buffering: no)
12
+ the grant token <-- GET /api/turn (cookie-authenticated; owner only)
13
+ grant the petition (GPU)<-- gradio @app.api "grant" (runs in the visitor's quota)
14
+ ledger index <-- GET /api/wishes
15
+ single trace (replay) <-- GET /api/wishes/{id}
16
+
17
+ Run: python app.py (gr.Server launch on $PORT, default 7860)
18
+ Backends: CITY_BACKEND = mock | llamacpp | zerogpu (default mock; live = zerogpu)
19
+ Sync: HF_TOKEN + CITY_DATASET enable trace persistence to an HF dataset.
20
+
21
+ The deterministic engine owns all city state; the mind only interprets and narrates;
22
+ this file only moves bytes. Engine/mind constructor assumptions are concentrated in
23
+ ``_default_wiring`` below — the single integration point.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import inspect
30
+ import logging
31
+ import os
32
+ import time
33
+
34
+ # ZeroGPU runtime: the `spaces` lib must be imported before torch anywhere in
35
+ # the process (it patches torch to virtualize the GPU). No-op everywhere else.
36
+ if os.environ.get("CITY_BACKEND", "").strip().lower() == "zerogpu":
37
+ try:
38
+ import spaces # noqa: F401
39
+ except ImportError:
40
+ pass
41
+ # No GPU exists at startup, so torch never loads its bundled CUDA runtime —
42
+ # best-effort preload of libcudart so any compiled kernel import resolves.
43
+ # (Harmless no-op on the in-tree nemotron_h path; kept from godseed.)
44
+ try:
45
+ import ctypes
46
+ import glob
47
+
48
+ for _lib in glob.glob(
49
+ "/usr/local/lib/python3*/site-packages/nvidia/cuda_runtime/lib/libcudart.so.12*"
50
+ ):
51
+ try:
52
+ ctypes.CDLL(_lib, mode=ctypes.RTLD_GLOBAL)
53
+ break
54
+ except OSError:
55
+ continue
56
+ except Exception: # noqa: BLE001 — best-effort; worst case the slow path runs
57
+ pass
58
+ from contextlib import asynccontextmanager
59
+ from pathlib import Path
60
+ from typing import Any, Callable, Optional, Protocol
61
+
62
+ from fastapi import FastAPI, HTTPException, Query, Request, Response
63
+ from fastapi.responses import JSONResponse, StreamingResponse
64
+ from fastapi.staticfiles import StaticFiles
65
+
66
+ from server.persistence import PersistenceService
67
+ from server.ratelimit import (
68
+ COOKIE_NAME,
69
+ DEFAULT_IP_LIMIT,
70
+ ClientIdentity,
71
+ RateLimiter,
72
+ client_ip,
73
+ )
74
+ from server.schemas import (
75
+ QueueInfo,
76
+ StateResponse,
77
+ WishAccepted,
78
+ WishRequest,
79
+ WishSummary,
80
+ WorldState,
81
+ )
82
+ from server.sse import SSEHub
83
+
84
+ log = logging.getLogger("nemocity")
85
+
86
+ ROOT = Path(__file__).resolve().parent
87
+ WEB_DIR = ROOT / "web"
88
+ TRACES_DIR = ROOT / "traces"
89
+
90
+ VALID_BACKENDS = ("mock", "llamacpp", "zerogpu")
91
+
92
+ # City-hall voice for the unhappy paths — never a stack trace.
93
+ RATE_LIMIT_REASON = (
94
+ "City hall hears three petitions an hour from one voice. "
95
+ "Walk the streets a while; come back when the lights change."
96
+ )
97
+ QUEUE_FULL_REASON = "City hall is swamped with paperwork; come back shortly."
98
+ NOT_FOUND_REASON = "No such petition is on record."
99
+ FIX_RATE_REASON = (
100
+ "The City Engineer takes two requests an hour from one visitor. "
101
+ "Watch the traffic a while; the jam isn't going anywhere."
102
+ )
103
+ FIX_COOLDOWN_REASON = (
104
+ "The City Engineer is already out on a call. Give the road crews two minutes."
105
+ )
106
+ TRAFFIC_SMOOTH_REASON = "Traffic is flowing smoothly."
107
+ FIX_GLOBAL_KEY = "fix:global"
108
+
109
+ # Fix pacing per the contract: 2 requests/hr/client + ONE engineer citywide (120 s
110
+ # global cooldown). Both expressed as sliding-window limiters so tests can inject
111
+ # clocks.
112
+ FIX_CLIENT_LIMIT = 2
113
+ FIX_CLIENT_WINDOW_S = 3600.0
114
+ FIX_GLOBAL_COOLDOWN_S = 120.0
115
+
116
+
117
+ # --------------------------------------------------------------------------- protocols
118
+ class WorldLike(Protocol):
119
+ """What the server reads from the engine's world (engine owns all writes)."""
120
+
121
+ version: int
122
+ epoch: int
123
+ features: list[dict[str, Any]]
124
+
125
+
126
+ class QueueLike(Protocol):
127
+ """What the server needs from engine.queue_worker. submit_fix is optional
128
+ (hasattr-guarded): it enqueues a City Engineer job and raises TrafficSmooth
129
+ (matched by type name, never imported) when the static assignment gate says
130
+ there is nothing to fix."""
131
+
132
+ async def submit(self, text: str, client_id: str) -> tuple[str, int]: ...
133
+
134
+ def snapshot(self) -> dict[str, Any]: ... # {"length": int, "current": {...}|None}
135
+
136
+
137
+ FastCheck = Callable[[str], Optional[str]] # returns a readable rejection reason or None
138
+
139
+
140
+ # ----------------------------------------------------------------- default (real) wiring
141
+ def _default_wiring(traces_dir: Path) -> dict[str, Any]:
142
+ """Build the real engine + mind stack. ALL cross-component wiring lives here so
143
+ integration fixes touch exactly one function."""
144
+ backend_name = os.environ.get("CITY_BACKEND", "mock").strip().lower() or "mock"
145
+ if backend_name not in VALID_BACKENDS:
146
+ log.warning("unknown CITY_BACKEND=%r; falling back to mock", backend_name)
147
+ backend_name = "mock"
148
+
149
+ from engine.genesis import genesis_features
150
+ from engine.moderation import Moderator
151
+ from engine.queue_worker import QueueWorker
152
+ from engine.world import World
153
+ from mind.backends import make_backend
154
+ from mind.moderation_judge import judge as judge_fn
155
+ from mind.planner import Planner
156
+
157
+ # Boot: restore wishes.jsonl from the dataset if configured, rebuild the ordered
158
+ # feature list (genesis + every trace's features), load the world verbatim.
159
+ persistence = PersistenceService(traces_dir)
160
+ features = persistence.boot(genesis_features())
161
+ epitaphs = [t.get("epitaph") for t in persistence.traces() if t.get("epitaph")]
162
+ world = World.load(features, epitaphs)
163
+
164
+ # The zerogpu backend eager-loads the 9B at construction (ZeroGPU needs the
165
+ # weights resident before the first request). If that fails — OOM, a model
166
+ # download hiccup — DON'T crash-loop the whole Space: the city, the ledger
167
+ # and every zero-GPU view must still serve. Fall back to mock so visitors
168
+ # see the city; granting degrades to scripted until the Space is healthy.
169
+ try:
170
+ backend = make_backend(backend_name)
171
+ except Exception:
172
+ log.exception("backend %r failed to load; serving with mock granting", backend_name)
173
+ backend = make_backend("mock")
174
+ backend_name = "mock"
175
+ planner = Planner(backend)
176
+ # Moderation layers 1-2 (charset/length + a thorough wordlist with
177
+ # confusable/leetspeak/skeleton folding) are the reliable gate and run on
178
+ # EVERY petition. Layer 3 (an LLM yes/no judge through the same model) is
179
+ # opt-in: its constrained verdict can fail to parse and then DEFAULT-DENY
180
+ # every clean petition (godseed, June 12). Off by default; CITY_JUDGE=on
181
+ # re-enables it.
182
+ use_judge = os.environ.get("CITY_JUDGE", "").strip().lower() in ("1", "on", "true")
183
+ judge = (lambda text: judge_fn(text, backend)) if use_judge else None
184
+ moderator = Moderator(judge=judge)
185
+
186
+ def fast_check(text: str) -> Optional[str]:
187
+ verdict = moderator.precheck(text)
188
+ if verdict.allowed:
189
+ return None
190
+ return verdict.poetic_reason or "City hall declines this petition."
191
+
192
+ hub = SSEHub()
193
+
194
+ async def persist(trace: Any) -> None:
195
+ """Worker hands over the WishTrace; embed the wish's engine-built features
196
+ (filtered from the world by wish_id) so boot rebuild is exact."""
197
+ if not isinstance(trace, dict):
198
+ trace = dict(getattr(trace, "__dict__", None) or {})
199
+ wish_id = trace.get("wish_id")
200
+ wish_features = [
201
+ f.to_dict() for f in world.features if f.wish_id == wish_id
202
+ ]
203
+ trace.setdefault("feature_ids", [f["id"] for f in wish_features])
204
+ await persistence.record(trace, wish_features)
205
+
206
+ # ZeroGPU: GPU work must run inside the petitioner's gradio request (their HF
207
+ # auth headers carry the quota), so the worker hands the grant to the browser
208
+ # via your_turn/invoke. Override with CITY_INVOKE=server|browser.
209
+ invoke_mode = os.environ.get("CITY_INVOKE", "").strip().lower()
210
+ browser_invoked = (
211
+ invoke_mode == "browser"
212
+ if invoke_mode in ("browser", "server")
213
+ else backend_name == "zerogpu"
214
+ )
215
+ # Wish numbering must survive zero-feature petitions across restarts: the
216
+ # worker derives its counter from WORLD features, but a petition whose calls
217
+ # all failed leaves no feature — after a restart the same id was reissued
218
+ # (godseed June 12: two w_000004 in the log). Traces are the complete record.
219
+ import re as _re
220
+
221
+ trace_max = 0
222
+ for t in persistence.traces():
223
+ m = _re.match(r"w_(\d{6})$", str(t.get("wish_id") or ""))
224
+ if m:
225
+ trace_max = max(trace_max, int(m.group(1)))
226
+ queue = QueueWorker(
227
+ world=world,
228
+ moderator=moderator,
229
+ planner=planner,
230
+ emit=hub.emit,
231
+ persist=persist,
232
+ browser_invoked=browser_invoked,
233
+ next_wish_index=max(QueueWorker._derive_wish_counter(world), trace_max + 1),
234
+ )
235
+ log.info(
236
+ "nemocity wired: backend=%s features=%d traces=%d dataset=%s",
237
+ backend_name,
238
+ world.version,
239
+ len(persistence.traces()),
240
+ persistence.sync.dataset if persistence.sync.enabled else "off",
241
+ )
242
+ return {
243
+ "world": world,
244
+ "queue": queue,
245
+ "hub": hub,
246
+ "persistence": persistence,
247
+ "fast_check": fast_check,
248
+ }
249
+
250
+
251
+ # --------------------------------------------------------------------------- helpers
252
+ async def _maybe_await(result: Any) -> Any:
253
+ if inspect.isawaitable(result):
254
+ return await result
255
+ return result
256
+
257
+
258
+ def _set_identity_cookie(response: Response, request: Request, value: str) -> None:
259
+ """Spaces serve over https inside an iframe (cross-site -> SameSite=None+Secure);
260
+ local dev is plain http (Lax, not Secure)."""
261
+ scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
262
+ secure = scheme == "https"
263
+ response.set_cookie(
264
+ COOKIE_NAME,
265
+ value,
266
+ max_age=365 * 24 * 3600,
267
+ path="/",
268
+ httponly=True,
269
+ secure=secure,
270
+ samesite="none" if secure else "lax",
271
+ )
272
+
273
+
274
+ def _world_state(world: WorldLike) -> WorldState:
275
+ # engine.world.World exposes state_dict() (features as plain dicts); the model
276
+ # ignores its extra derived keys. Attribute fallback covers simpler worlds.
277
+ if hasattr(world, "state_dict"):
278
+ return WorldState.model_validate(world.state_dict())
279
+ return WorldState(
280
+ version=int(world.version),
281
+ epoch=int(world.epoch),
282
+ features=list(world.features),
283
+ )
284
+
285
+
286
+ def _queue_info(queue: QueueLike) -> QueueInfo:
287
+ try:
288
+ snapshot = queue.snapshot() or {}
289
+ except Exception: # noqa: BLE001 — a queue hiccup must not kill /api/state
290
+ log.exception("queue snapshot failed")
291
+ snapshot = {}
292
+ return QueueInfo.model_validate(
293
+ {"length": snapshot.get("length", 0), "current": snapshot.get("current")}
294
+ )
295
+
296
+
297
+ def _wish_summary(trace: dict[str, Any], epoch: int) -> WishSummary:
298
+ return WishSummary(
299
+ wish_id=str(trace.get("wish_id", "")),
300
+ text=str(trace.get("text", "")),
301
+ epitaph=str(trace.get("epitaph") or ""),
302
+ epoch=epoch,
303
+ ts=float(trace.get("submitted_at") or 0.0),
304
+ )
305
+
306
+
307
+ # --------------------------------------------------------------------------- the app
308
+ def create_app(
309
+ *,
310
+ world: Optional[WorldLike] = None,
311
+ queue: Optional[QueueLike] = None,
312
+ hub: Optional[SSEHub] = None,
313
+ persistence: Optional[PersistenceService] = None,
314
+ fast_check: Optional[FastCheck] = None,
315
+ rate_limiter: Optional[RateLimiter] = None,
316
+ ip_rate_limiter: Optional[RateLimiter] = None,
317
+ fix_rate_limiter: Optional[RateLimiter] = None,
318
+ fix_cooldown: Optional[RateLimiter] = None,
319
+ identity: Optional[ClientIdentity] = None,
320
+ traces_dir: Optional[Path | str] = None,
321
+ mount_web: bool = True,
322
+ mount_panel: bool = True,
323
+ ) -> FastAPI:
324
+ """Build the ASGI app. With no arguments this wires the real engine + mind from
325
+ env config; tests inject fakes for everything engine/mind-shaped."""
326
+ traces_path = Path(traces_dir) if traces_dir is not None else TRACES_DIR
327
+
328
+ if queue is None:
329
+ wired = _default_wiring(traces_path)
330
+ world = wired["world"]
331
+ queue = wired["queue"]
332
+ # The default queue emits into the hub _default_wiring built; an injected hub
333
+ # is only honored together with an injected queue.
334
+ hub = wired["hub"]
335
+ persistence = wired["persistence"]
336
+ fast_check = wired["fast_check"]
337
+ if world is None or hub is None or persistence is None:
338
+ raise ValueError(
339
+ "create_app: when injecting `queue`, also inject `world`, `hub`, and "
340
+ "`persistence`"
341
+ )
342
+
343
+ rate_limiter = rate_limiter or RateLimiter()
344
+ ip_rate_limiter = ip_rate_limiter or RateLimiter(limit=DEFAULT_IP_LIMIT)
345
+ fix_rate_limiter = fix_rate_limiter or RateLimiter(
346
+ limit=FIX_CLIENT_LIMIT, window=FIX_CLIENT_WINDOW_S
347
+ )
348
+ fix_cooldown = fix_cooldown or RateLimiter(limit=1, window=FIX_GLOBAL_COOLDOWN_S)
349
+ identity = identity or ClientIdentity()
350
+
351
+ @asynccontextmanager
352
+ async def lifespan(app: FastAPI):
353
+ queue_task: Optional[asyncio.Task[Any]] = None
354
+ if hasattr(queue, "start"):
355
+ await _maybe_await(queue.start())
356
+ elif hasattr(queue, "run"):
357
+ queue_task = asyncio.create_task(queue.run())
358
+ yield
359
+ if hasattr(queue, "stop"):
360
+ await _maybe_await(queue.stop())
361
+ elif queue_task is not None:
362
+ queue_task.cancel()
363
+ await asyncio.gather(queue_task, return_exceptions=True)
364
+ await persistence.drain()
365
+
366
+ # Gradio Server mode (the "her" idiom): gr.Server IS a FastAPI app with
367
+ # gradio's API engine attached — the sdk:gradio runtime serves it natively,
368
+ # and @app.api endpoints are gradio endpoints the browser calls via
369
+ # @gradio/client, which forwards the HF auth headers ZeroGPU quota needs.
370
+ try:
371
+ import gradio as gr
372
+
373
+ app: FastAPI = gr.Server(title="NEMOCITY")
374
+ app.router.lifespan_context = lifespan
375
+ except Exception: # pragma: no cover — gradio always present in prod
376
+ app = FastAPI(title="NEMOCITY", docs_url=None, redoc_url=None, lifespan=lifespan)
377
+ app.state.world = world
378
+ app.state.queue = queue
379
+ app.state.hub = hub
380
+ app.state.persistence = persistence
381
+
382
+ async def ensure_worker() -> None:
383
+ """Start the queue worker on the SERVING event loop, lazily. gradio's
384
+ launch() runs uvicorn without honoring an injected lifespan (verified
385
+ June 12: the worker never started under Server mode), so the first
386
+ request opens city hall instead. Idempotent and loop-correct everywhere."""
387
+ if (
388
+ hasattr(queue, "start")
389
+ and hasattr(queue, "is_running")
390
+ and not queue.is_running
391
+ ):
392
+ await _maybe_await(queue.start())
393
+
394
+ # GPU grant endpoint: when the queue announces your_turn over SSE, the
395
+ # petitioner's browser calls this named gradio endpoint with the one-time token.
396
+ if hasattr(app, "api") and hasattr(queue, "invoke"):
397
+
398
+ @app.api(name="grant")
399
+ async def grant(wish_id: str = "", token: str = "") -> dict:
400
+ try:
401
+ await ensure_worker()
402
+ return await queue.invoke(wish_id, token)
403
+ except Exception as exc:
404
+ reason = (
405
+ getattr(exc, "reason", None)
406
+ or "the permit fell through; city hall moved on"
407
+ )
408
+ return {"ok": False, "wish_id": wish_id, "reason": str(reason)}
409
+
410
+ # ------------------------------------------------------------------- /api/turn
411
+ # The owner's browser fetches its secret invocation token here. The token is
412
+ # identity-bound (signed cookie) and never broadcast over SSE, so a stranger
413
+ # tailing /api/stream cannot steal another visitor's turn (verify-fleet
414
+ # critical, June 12). A plain FastAPI route DOES see the cookie (unlike the
415
+ # gradio @app.api endpoint), which is why the auth check lives here.
416
+ if hasattr(queue, "turn_token"):
417
+
418
+ @app.get("/api/turn", include_in_schema=False)
419
+ async def turn(wish_id: str, request: Request, response: Response) -> JSONResponse:
420
+ await ensure_worker()
421
+ cid, new_cookie = identity.resolve(request)
422
+ if new_cookie:
423
+ _set_identity_cookie(response, request, new_cookie)
424
+ tok = queue.turn_token(wish_id, cid)
425
+ if tok is None:
426
+ return JSONResponse({"error": "not your turn"}, status_code=403)
427
+ return JSONResponse({"wish_id": wish_id, "token": tok})
428
+
429
+ # ------------------------------------------------------------------- /api/state
430
+ @app.get("/api/state", response_model=StateResponse)
431
+ async def get_state() -> StateResponse:
432
+ await ensure_worker()
433
+ traces = persistence.traces()
434
+ recent = [
435
+ _wish_summary(trace, epoch)
436
+ for epoch, trace in enumerate(traces, start=1)
437
+ ][-10:][::-1]
438
+ return StateResponse(
439
+ world=_world_state(world),
440
+ queue=_queue_info(queue),
441
+ wishes_recent=recent,
442
+ server_now=time.time(),
443
+ )
444
+
445
+ # -------------------------------------------------------------------- /api/wish
446
+ @app.post("/api/wish", response_model=WishAccepted)
447
+ async def post_wish(
448
+ payload: WishRequest, request: Request, response: Response
449
+ ) -> WishAccepted:
450
+ await ensure_worker()
451
+ cid, new_cookie = identity.resolve(request)
452
+ if new_cookie:
453
+ _set_identity_cookie(response, request, new_cookie)
454
+
455
+ ip_ok, ip_retry = ip_rate_limiter.hit(f"ip:{client_ip(request)}")
456
+ allowed, retry_after = (
457
+ rate_limiter.hit(cid) if ip_ok else (False, ip_retry)
458
+ )
459
+ if not allowed:
460
+ raise HTTPException(
461
+ status_code=429,
462
+ detail=RATE_LIMIT_REASON,
463
+ headers={"Retry-After": str(int(retry_after) + 1)},
464
+ )
465
+
466
+ if fast_check is not None:
467
+ reason = fast_check(payload.text)
468
+ if reason:
469
+ raise HTTPException(status_code=400, detail=str(reason))
470
+
471
+ try:
472
+ wish_id, position = await queue.submit(payload.text, cid)
473
+ except HTTPException:
474
+ raise
475
+ except Exception as exc: # queue full / per-client pending cap (engine policy)
476
+ log.info("petition refused by queue: %s", exc)
477
+ # engine.queue_worker raises QueueError subclasses with a readable .reason;
478
+ # AlreadyPending is a client-pacing problem (429), the rest read as 503.
479
+ status = int(getattr(exc, "status_code", 0)) or (
480
+ 429 if type(exc).__name__ == "AlreadyPending" else 503
481
+ )
482
+ detail = str(getattr(exc, "reason", "") or exc) or QUEUE_FULL_REASON
483
+ raise HTTPException(status_code=status, detail=detail) from exc
484
+ return WishAccepted(wish_id=wish_id, position=position)
485
+
486
+ # --------------------------------------------------------------------- /api/fix
487
+ # "Ask the City Engineer": rate-limited per client AND a single global 120 s
488
+ # cooldown (there is one engineer citywide — every visitor shares the result
489
+ # anyway, it lands as a world_delta on the SSE stream). The engine's static
490
+ # assignment gate decides whether there is actually a jam: queue.submit_fix
491
+ # raises TrafficSmooth (matched by name; the server never imports the engine)
492
+ # and the client gets a flat 409.
493
+ if hasattr(queue, "submit_fix"):
494
+
495
+ @app.post("/api/fix", response_model=WishAccepted)
496
+ async def post_fix(request: Request, response: Response) -> WishAccepted:
497
+ await ensure_worker()
498
+ cid, new_cookie = identity.resolve(request)
499
+ if new_cookie:
500
+ _set_identity_cookie(response, request, new_cookie)
501
+
502
+ allowed, retry_after = fix_rate_limiter.hit(f"fix:{cid}")
503
+ if not allowed:
504
+ raise HTTPException(
505
+ status_code=429,
506
+ detail=FIX_RATE_REASON,
507
+ headers={"Retry-After": str(int(retry_after) + 1)},
508
+ )
509
+ # Peek-then-commit: a 409 must NOT engage the citywide cooldown, or one
510
+ # visitor's no-op click blocks everyone's engineer for two minutes.
511
+ if fix_cooldown.remaining(FIX_GLOBAL_KEY) <= 0:
512
+ raise HTTPException(
513
+ status_code=429,
514
+ detail=FIX_COOLDOWN_REASON,
515
+ headers={"Retry-After": str(int(fix_cooldown.window))},
516
+ )
517
+
518
+ try:
519
+ wish_id, position = await queue.submit_fix(cid)
520
+ except HTTPException:
521
+ raise
522
+ except Exception as exc:
523
+ if type(exc).__name__ == "TrafficSmooth":
524
+ raise HTTPException(
525
+ status_code=409, detail=TRAFFIC_SMOOTH_REASON
526
+ ) from exc
527
+ log.info("fix refused by queue: %s", exc)
528
+ status = int(getattr(exc, "status_code", 0)) or (
529
+ 429 if type(exc).__name__ == "AlreadyPending" else 503
530
+ )
531
+ detail = str(getattr(exc, "reason", "") or exc) or QUEUE_FULL_REASON
532
+ raise HTTPException(status_code=status, detail=detail) from exc
533
+ fix_cooldown.hit(FIX_GLOBAL_KEY)
534
+ return WishAccepted(wish_id=wish_id, position=position)
535
+
536
+ # ------------------------------------------------------------------ /api/stream
537
+ @app.get("/api/stream")
538
+ async def stream(
539
+ limit: Optional[int] = Query(
540
+ default=None,
541
+ ge=1,
542
+ le=10_000,
543
+ description="debug/test aid: close the stream after N events",
544
+ ),
545
+ ) -> StreamingResponse:
546
+ initial = [
547
+ {
548
+ "type": "hello",
549
+ "world_version": int(world.version),
550
+ "epoch": int(world.epoch),
551
+ "server_now": time.time(),
552
+ },
553
+ {"type": "queue", **_queue_info(queue).model_dump()},
554
+ ]
555
+ return StreamingResponse(
556
+ hub.event_stream(initial_events=initial, limit=limit),
557
+ media_type="text/event-stream",
558
+ headers={
559
+ "Cache-Control": "no-cache",
560
+ "X-Accel-Buffering": "no", # required on Spaces or the proxy buffers
561
+ "Connection": "keep-alive",
562
+ },
563
+ )
564
+
565
+ # ----------------------------------------------------------- /api/wishes (ledger)
566
+ @app.get("/api/wishes", response_model=list[WishSummary])
567
+ async def wishes_index() -> list[WishSummary]:
568
+ return [
569
+ _wish_summary(trace, epoch)
570
+ for epoch, trace in enumerate(persistence.traces(), start=1)
571
+ ]
572
+
573
+ @app.get("/api/wishes/{wish_id}")
574
+ async def wish_trace(wish_id: str) -> JSONResponse:
575
+ trace = persistence.find(wish_id)
576
+ if trace is None:
577
+ raise HTTPException(status_code=404, detail=NOT_FOUND_REASON)
578
+ return JSONResponse(trace)
579
+
580
+ # ----------------------------------------------------------------------- static
581
+ # (No Blocks panel: in Server mode the whole app IS the gradio app — a
582
+ # Blocks instance would race the runtime's auto-launch for port 7860.)
583
+
584
+ def _mount_static() -> None:
585
+ """Root static mount. A "/" catch-all registered BEFORE gr.Server.launch()
586
+ shadows the /gradio_api routes gradio adds at launch time (verified June
587
+ 12: the launch self-check 404s and the app dies). So the mount is
588
+ deferred: _serve() calls this AFTER launch; the plain-FastAPI/uvicorn
589
+ path mounts immediately below."""
590
+ if WEB_DIR.is_dir():
591
+ # gradio's launch registers its own SPA index at "/" (GET+HEAD) —
592
+ # evict exactly those so the city owns the root; every other
593
+ # gradio route (/config, /gradio_api/*, assets) must survive for
594
+ # @gradio/client connectivity.
595
+ app.router.routes[:] = [
596
+ r for r in app.router.routes if getattr(r, "path", None) != "/"
597
+ ]
598
+ app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")
599
+ else:
600
+ log.warning("web/ not found at %s; serving API only", WEB_DIR)
601
+
602
+ @app.get("/", include_in_schema=False)
603
+ async def root_placeholder() -> JSONResponse:
604
+ return JSONResponse({"ok": True, "hint": "frontend not built"})
605
+
606
+ app.state.mount_static = _mount_static
607
+ if mount_web and not hasattr(app, "launch"):
608
+ _mount_static()
609
+
610
+ return app
611
+
612
+
613
+ # --------------------------------------------------------------------------- entrypoint
614
+ def _serve(application: FastAPI) -> None:
615
+ port = int(os.environ.get("PORT", os.environ.get("GRADIO_SERVER_PORT", 7860)))
616
+ log.info("NEMOCITY listening on http://0.0.0.0:%d", port)
617
+ if hasattr(application, "launch"):
618
+ # Gradio Server mode — launch non-blocking so the web root can mount
619
+ # AFTER gradio registers its /gradio_api routes (order = precedence).
620
+ # sdk_version 6.16.0 (README) is the pre-SSR gradio: a single Python
621
+ # process owns the port, no Node proxy — the pattern pareidolia runs
622
+ # live on the org ZeroGPU. (gradio 6.18 default-SSR tore the process
623
+ # down right after launch on Spaces, June 14.)
624
+ application.launch(
625
+ server_name="0.0.0.0",
626
+ server_port=port,
627
+ show_error=False,
628
+ prevent_thread_lock=True,
629
+ )
630
+ mount_static = getattr(application.state, "mount_static", None)
631
+ if mount_static is not None:
632
+ mount_static()
633
+ import time as _time
634
+
635
+ while True: # keep the process alive; the server runs in gradio's thread
636
+ _time.sleep(3600)
637
+ else: # pragma: no cover — gradio-less dev fallback
638
+ import uvicorn
639
+
640
+ uvicorn.run(application, host="0.0.0.0", port=port)
641
+
642
+
643
+ # HF Spaces (sdk:gradio) executes this file; locally it's `python app.py`.
644
+ # Test imports leave both conditions false and get no side effects.
645
+ if __name__ == "__main__" or os.environ.get("SPACE_ID"):
646
+ logging.basicConfig(
647
+ level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s"
648
+ )
649
+ _serve(create_app())
engine/__init__.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY deterministic city engine.
2
+
3
+ The engine owns ALL facts ("her" pattern): city state, the 4-event surface,
4
+ placement geometry, traffic facts, moderation, the serialized petition queue,
5
+ and the prompt summary. The LLM only interprets petitions into tiny JSON —
6
+ it never owns state and never outputs coordinates.
7
+ """
8
+
9
+ from .city import CityState
10
+ from .genesis import GENESIS_FEATURES, genesis_features, genesis_world
11
+ from .moderation import Moderator, Verdict
12
+ from .queue_worker import (
13
+ AlreadyPending,
14
+ QueueError,
15
+ QueueFull,
16
+ QueueWorker,
17
+ TrafficSmooth,
18
+ )
19
+ from .summary import summarize
20
+ from .tools import TOOL_NAMES, resolve_kind, sanitize_name, validate_call
21
+ from .world import Feature, World
22
+
23
+ __all__ = [
24
+ "CityState",
25
+ "GENESIS_FEATURES",
26
+ "genesis_features",
27
+ "genesis_world",
28
+ "Moderator",
29
+ "Verdict",
30
+ "AlreadyPending",
31
+ "QueueError",
32
+ "QueueFull",
33
+ "QueueWorker",
34
+ "TrafficSmooth",
35
+ "summarize",
36
+ "TOOL_NAMES",
37
+ "resolve_kind",
38
+ "sanitize_name",
39
+ "validate_call",
40
+ "Feature",
41
+ "World",
42
+ ]
engine/city.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY derived city state — a pure function of the event list.
2
+
3
+ CityState is cheap to rebuild (from_events) and supports incremental apply()
4
+ so the queue worker can keep one instance current while a petition lands.
5
+ Nothing here is persisted: occupancy, the road graph, street names, tallies,
6
+ growth radius and road_version are all derived.
7
+
8
+ Determinism notes (the JS renderer mirrors parts of this):
9
+ * street names: lay_road/apply_fix args carry an explicit name when the engine
10
+ composed one; events without a name fall back to the curated pool indexed by
11
+ crc32(event_id);
12
+ * a re-laid road cell takes the LATER event's klass/name (upgrade_avenue
13
+ repaints in place);
14
+ * building "door" = the road cell 4-adjacent to the footprint with the
15
+ smallest (cz, cx) — traffic.js must use the same rule.
16
+
17
+ Pure stdlib.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import zlib
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Iterable, Optional
25
+
26
+ from . import constants as C
27
+
28
+ Cell = tuple[int, int] # (cx, cz)
29
+
30
+ _NEIGHBORS_4: tuple[Cell, ...] = ((0, -1), (1, 0), (0, 1), (-1, 0))
31
+
32
+ WATER_CELLS: frozenset[Cell] = frozenset(
33
+ (cx, cz)
34
+ for cz in range(C.COORD_MIN, C.COORD_MAX + 1)
35
+ for cx in C.river_cols(cz)
36
+ )
37
+
38
+
39
+ def in_bounds(cx: int, cz: int) -> bool:
40
+ return C.COORD_MIN <= cx <= C.COORD_MAX and C.COORD_MIN <= cz <= C.COORD_MAX
41
+
42
+
43
+ def neighbors4(cell: Cell) -> list[Cell]:
44
+ cx, cz = cell
45
+ return [
46
+ (cx + dx, cz + dz)
47
+ for dx, dz in _NEIGHBORS_4
48
+ if in_bounds(cx + dx, cz + dz)
49
+ ]
50
+
51
+
52
+ @dataclass
53
+ class RoadCell:
54
+ klass: str
55
+ name: str
56
+ bridge: bool
57
+
58
+
59
+ @dataclass
60
+ class Building:
61
+ id: str
62
+ kind: str
63
+ name: str
64
+ cx: int
65
+ cz: int
66
+ w: int
67
+ d: int
68
+ floors: int
69
+ seed: int
70
+ t: float
71
+ cells: tuple[Cell, ...] = field(default_factory=tuple)
72
+
73
+ @property
74
+ def centroid(self) -> tuple[float, float]:
75
+ return (self.cx + (self.w - 1) / 2.0, self.cz + (self.d - 1) / 2.0)
76
+
77
+ def progress(self, now_s: float) -> float:
78
+ dur = C.BUILDINGS[self.kind]["duration_s"]
79
+ return min(max((now_s - self.t) / dur, 0.0), 1.0)
80
+
81
+
82
+ def _event_fields(ev: Any) -> tuple[str, str, dict, int, float]:
83
+ if isinstance(ev, dict):
84
+ return (ev["id"], ev["tool"], ev.get("args") or {},
85
+ int(ev.get("seed", 0)), float(ev.get("t", 0)))
86
+ return (ev.id, ev.tool, ev.args or {}, int(ev.seed), float(ev.t))
87
+
88
+
89
+ class CityState:
90
+ """Occupancy + road graph + registries derived from an event list."""
91
+
92
+ def __init__(self) -> None:
93
+ self.roads: dict[Cell, RoadCell] = {}
94
+ self.buildings: list[Building] = []
95
+ self.building_cells: dict[Cell, Building] = {}
96
+ self.road_version: int = 0
97
+ self._adjacency: Optional[dict[Cell, list[Cell]]] = None
98
+ self._street_cells: Optional[dict[str, list[Cell]]] = None
99
+
100
+ @classmethod
101
+ def from_events(cls, events: Iterable[Any]) -> "CityState":
102
+ city = cls()
103
+ for ev in events or ():
104
+ city.apply(ev)
105
+ return city
106
+
107
+ # ------------------------------------------------------------------ apply
108
+
109
+ def apply(self, ev: Any) -> None:
110
+ ev_id, tool, args, seed, t = _event_fields(ev)
111
+ if tool in ("lay_road", "apply_fix"):
112
+ name = args.get("name") or C.STREET_NAMES[
113
+ zlib.crc32(ev_id.encode()) % len(C.STREET_NAMES)
114
+ ]
115
+ klass = args.get("klass", "street")
116
+ for cx, cz in args.get("cells") or ():
117
+ cell = (int(cx), int(cz))
118
+ if not in_bounds(*cell) or cell in self.building_cells:
119
+ continue
120
+ self.roads[cell] = RoadCell(
121
+ klass=klass, name=name, bridge=cell in WATER_CELLS
122
+ )
123
+ self.road_version += 1
124
+ self._adjacency = None
125
+ self._street_cells = None
126
+ elif tool == "place_building":
127
+ b = Building(
128
+ id=ev_id, kind=args["kind"], name=args.get("name", ""),
129
+ cx=int(args["cx"]), cz=int(args["cz"]),
130
+ w=int(args.get("w") or C.BUILDINGS[args["kind"]]["w"]),
131
+ d=int(args.get("d") or C.BUILDINGS[args["kind"]]["d"]),
132
+ floors=int(args.get("floors", 1)), seed=seed, t=t,
133
+ )
134
+ b.cells = tuple(
135
+ (b.cx + dx, b.cz + dz) for dx in range(b.w) for dz in range(b.d)
136
+ )
137
+ self.buildings.append(b)
138
+ for cell in b.cells:
139
+ self.building_cells[cell] = b
140
+ # "note" mutates nothing
141
+
142
+ # ------------------------------------------------------------------ views
143
+
144
+ def is_empty(self, cell: Cell) -> bool:
145
+ return (
146
+ in_bounds(*cell)
147
+ and cell not in WATER_CELLS
148
+ and cell not in self.roads
149
+ and cell not in self.building_cells
150
+ )
151
+
152
+ def occupancy(self, cell: Cell) -> Optional[str]:
153
+ """'road' | 'water' | building kind | None (empty/off-grid)."""
154
+ if cell in self.roads:
155
+ return "road"
156
+ if cell in WATER_CELLS:
157
+ return "water"
158
+ b = self.building_cells.get(cell)
159
+ return b.kind if b else None
160
+
161
+ @property
162
+ def adjacency(self) -> dict[Cell, list[Cell]]:
163
+ """Road graph: node = road cell, edges between 4-adjacent road cells."""
164
+ if self._adjacency is None:
165
+ self._adjacency = {
166
+ cell: [n for n in neighbors4(cell) if n in self.roads]
167
+ for cell in self.roads
168
+ }
169
+ return self._adjacency
170
+
171
+ @property
172
+ def street_cells(self) -> dict[str, list[Cell]]:
173
+ """Street-name registry; cells in insertion order per name."""
174
+ if self._street_cells is None:
175
+ reg: dict[str, list[Cell]] = {}
176
+ for cell, rc in self.roads.items():
177
+ reg.setdefault(rc.name, []).append(cell)
178
+ self._street_cells = reg
179
+ return self._street_cells
180
+
181
+ def street_midpoint(self, name: str) -> Optional[Cell]:
182
+ cells = self.street_cells.get(name)
183
+ if not cells:
184
+ return None
185
+ return cells[len(cells) // 2]
186
+
187
+ def door(self, b: Building) -> Optional[Cell]:
188
+ """Frontage road cell: smallest (cz, cx) among cells adjacent to the
189
+ footprint. Mirrored by traffic.js — keep the ordering rule."""
190
+ adjacent = {
191
+ n for cell in b.cells for n in neighbors4(cell) if n in self.roads
192
+ }
193
+ if not adjacent:
194
+ return None
195
+ return min(adjacent, key=lambda c: (c[1], c[0]))
196
+
197
+ def population(self, now_s: float) -> int:
198
+ return int(sum(
199
+ C.BUILDINGS[b.kind]["residents"] * b.progress(now_s)
200
+ for b in self.buildings
201
+ ))
202
+
203
+ @property
204
+ def housing_capacity(self) -> int:
205
+ return sum(C.BUILDINGS[b.kind]["residents"] for b in self.buildings)
206
+
207
+ @property
208
+ def jobs(self) -> int:
209
+ return sum(C.BUILDINGS[b.kind]["jobs"] for b in self.buildings)
210
+
211
+ @property
212
+ def growth_radius(self) -> int:
213
+ r = C.GROWTH_RADIUS_MIN
214
+ for cell in self.roads:
215
+ r = max(r, abs(cell[0]), abs(cell[1]))
216
+ for cell in self.building_cells:
217
+ r = max(r, abs(cell[0]), abs(cell[1]))
218
+ return r
219
+
220
+ def counts_by_kind(self) -> dict[str, int]:
221
+ counts: dict[str, int] = {}
222
+ for b in self.buildings:
223
+ counts[b.kind] = counts.get(b.kind, 0) + 1
224
+ return counts
225
+
226
+ def find_building(self, text: str) -> Optional[Building]:
227
+ """Most recent building whose name matches `text` (folded substring,
228
+ either direction). Returns None when nothing matches."""
229
+ needle = str(text or "").strip().lower()
230
+ if not needle:
231
+ return None
232
+ for b in reversed(self.buildings):
233
+ name = b.name.lower()
234
+ if name and (name in needle or needle in name):
235
+ return b
236
+ return None
237
+
238
+ def find_street(self, text: str) -> Optional[str]:
239
+ needle = str(text or "").strip().lower()
240
+ if not needle:
241
+ return None
242
+ for name in self.street_cells:
243
+ low = name.lower()
244
+ if low and (low in needle or needle in low):
245
+ return name
246
+ return None
engine/constants.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY canonical constants — the single source of truth for grid, river,
2
+ buildings, roads, traffic, and placement numbers (ARCHITECTURE.md mirrors these;
3
+ tools/gen_web.py emits the JS copy). Pure data, stdlib only.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ GRID = 64
9
+ CELL = 4 # world units per cell (1 unit = 1 m)
10
+ COORD_MIN, COORD_MAX = -32, 31 # cell coords (cx, cz) inclusive
11
+
12
+ CITY_EPOCH_S = 1781222400 # June 12 2026 00:00 UTC
13
+ DAY_S = 240 # one in-game day in real seconds
14
+
15
+
16
+ def river_cols(cz: int) -> tuple[int, int]:
17
+ """Water cell columns for a row. IDENTICAL to riverCols in web constants."""
18
+ if cz <= -5:
19
+ return (7, 8)
20
+ if cz <= 3:
21
+ return (8, 9)
22
+ return (9, 10)
23
+
24
+
25
+ # kind: w, d, floors (min, max), residents, jobs, attract, duration_s
26
+ BUILDINGS: dict[str, dict] = {
27
+ "house": {"w": 1, "d": 1, "floors": (1, 2), "residents": 4, "jobs": 0, "attract": 0, "duration_s": 20},
28
+ "townhouse": {"w": 1, "d": 1, "floors": (2, 3), "residents": 6, "jobs": 0, "attract": 0, "duration_s": 20},
29
+ "apartments": {"w": 2, "d": 2, "floors": (4, 7), "residents": 16, "jobs": 0, "attract": 0, "duration_s": 45},
30
+ "cafe": {"w": 1, "d": 1, "floors": (1, 1), "residents": 0, "jobs": 3, "attract": 5, "duration_s": 20},
31
+ "shop": {"w": 1, "d": 1, "floors": (1, 2), "residents": 0, "jobs": 4, "attract": 4, "duration_s": 20},
32
+ "market": {"w": 1, "d": 2, "floors": (1, 1), "residents": 0, "jobs": 8, "attract": 6, "duration_s": 30},
33
+ "bank": {"w": 2, "d": 1, "floors": (2, 4), "residents": 0, "jobs": 12, "attract": 2, "duration_s": 30},
34
+ "office": {"w": 1, "d": 1, "floors": (3, 8), "residents": 0, "jobs": 40, "attract": 1, "duration_s": 30},
35
+ "tower": {"w": 2, "d": 2, "floors": (8, 16), "residents": 0, "jobs": 60, "attract": 2, "duration_s": 45},
36
+ "school": {"w": 2, "d": 2, "floors": (1, 2), "residents": 0, "jobs": 10, "attract": 3, "duration_s": 30},
37
+ "hospital": {"w": 3, "d": 3, "floors": (3, 6), "residents": 0, "jobs": 25, "attract": 2, "duration_s": 45},
38
+ "fire_station": {"w": 2, "d": 1, "floors": (2, 2), "residents": 0, "jobs": 8, "attract": 1, "duration_s": 30},
39
+ "warehouse": {"w": 2, "d": 2, "floors": (1, 1), "residents": 0, "jobs": 10, "attract": 0, "duration_s": 30},
40
+ "factory": {"w": 3, "d": 2, "floors": (1, 2), "residents": 0, "jobs": 18, "attract": 0, "duration_s": 45},
41
+ "park": {"w": 2, "d": 2, "floors": (0, 0), "residents": 0, "jobs": 0, "attract": 8, "duration_s": 20},
42
+ "plaza": {"w": 1, "d": 1, "floors": (0, 0), "residents": 0, "jobs": 0, "attract": 6, "duration_s": 20},
43
+ "stadium": {"w": 4, "d": 4, "floors": (1, 1), "residents": 0, "jobs": 15, "attract": 9, "duration_s": 45},
44
+ "church": {"w": 2, "d": 1, "floors": (1, 1), "residents": 0, "jobs": 2, "attract": 3, "duration_s": 30},
45
+ "town_hall": {"w": 2, "d": 2, "floors": (2, 2), "residents": 0, "jobs": 6, "attract": 4, "duration_s": 45},
46
+ }
47
+
48
+ RESIDENTIAL_KINDS = ("house", "townhouse", "apartments")
49
+ INDUSTRIAL_KINDS = ("factory", "warehouse")
50
+
51
+ # The model can never pick a kind we lack; keys are space/hyphen-folded to "_".
52
+ SYNONYMS: dict[str, str] = {
53
+ "skyscraper": "tower", "highrise": "tower", "high_rise": "tower",
54
+ "apartment": "apartments", "apartment_building": "apartments", "condo": "apartments",
55
+ "hotel": "apartments",
56
+ "coffee_shop": "cafe", "coffee": "cafe", "diner": "cafe", "restaurant": "cafe",
57
+ "bakery": "cafe",
58
+ "store": "shop", "boutique": "shop", "pharmacy": "shop", "gym": "shop",
59
+ "mall": "market", "grocery": "market", "supermarket": "market",
60
+ "police_station": "fire_station", "police": "fire_station", "firehouse": "fire_station",
61
+ "library": "town_hall", "museum": "town_hall", "city_hall": "town_hall",
62
+ "temple": "church", "mosque": "church", "chapel": "church",
63
+ "garden": "park", "playground": "park",
64
+ "fountain": "plaza", "square": "plaza",
65
+ "arena": "stadium",
66
+ "clinic": "hospital",
67
+ "plant": "factory", "mill": "factory",
68
+ "home": "house", "cottage": "house", "cabin": "house",
69
+ }
70
+
71
+ ROAD_CLASSES: dict[str, dict] = {
72
+ "street": {"capacity": 2, "speed": 1.0},
73
+ "avenue": {"capacity": 6, "speed": 1.6},
74
+ }
75
+
76
+ ROAD_DURATION_S = 4 # roads paint in over 4 s client-side
77
+
78
+ # Curated street-name pool (genesis names — Main St, 1st Ave, Old Bridge,
79
+ # River Rd, Elm St, 2nd St — are fixed in events and deliberately not here).
80
+ STREET_NAMES: tuple[str, ...] = (
81
+ "Oak St", "Maple Ave", "Cedar Ln", "Birch St", "Willow Way", "Juniper St",
82
+ "Laurel Ave", "Magnolia Blvd", "Poplar St", "Chestnut St", "Sycamore Ave",
83
+ "Alder Row", "Hazel St", "Linden Ave", "Aspen Ct", "Rowan St", "Holly Ave",
84
+ "Ivy Ln", "Clover St", "Fern Way", "Meadow Ln", "Orchard St", "Garden Ave",
85
+ "Harbor St", "Mill Rd", "Canal St", "Foundry St", "Station Rd", "Depot Ln",
86
+ "Prospect Ave", "Summit St", "Vista Way", "Sunrise Blvd", "Larkspur St",
87
+ "Primrose Ave", "Bluebell Ln", "Copper St", "Granite Ave", "Beacon St",
88
+ "Quay Rd",
89
+ )
90
+
91
+ # --------------------------------------------------------------------- traffic
92
+ RUSH_HOURS = (8.0, 18.0) # bell-curve centers in dayHours
93
+ RUSH_SIGMA = 1.2
94
+ RUSH_BASE = 0.35 # rushFactor = base + (1-base) * min(1, bell(8) + bell(18))
95
+ RUSH_AMP = 0.65 # = 1 - RUSH_BASE; mirrored to JS for the same formula
96
+ CAR_RATE = 0.8 # visible cars N = clamp(round(pop * CAR_RATE * rushFactor), MIN, MAX)
97
+ CAR_MIN, CAR_MAX = 8, 120
98
+ EMA_TAU_S = 5.0 # client congestion EMA time constant
99
+ CITIZENS_PER_POP = 5 # pedestrians = clamp(ceil(pop / per), MIN, MAX) — client-side
100
+ CITIZENS_MIN, CITIZENS_MAX = 6, 80
101
+
102
+ TRIP_ATTRACT_FACTOR = 2.0 # weight = (jobs + 2*attract) / (1 + manhattanDist^1.5)
103
+ TRIP_DIST_EXP = 1.5
104
+ # Static-assignment demand each commuter adds to every cell of their route, at
105
+ # peak rush (rushFactor 1.0). TUNED (June 12, measured): genesis alone puts 3
106
+ # crossers on the Old Bridge -> ratio 0.75 (under the 0.8 fix gate); +3 west
107
+ # houses -> 5 crossers -> ratio 1.25 (>= 1.0, the acceptance test passes with
108
+ # margin). At 0.8 the whole genesis grid read jammed.
109
+ DEMAND_PER_COMMUTER = 0.5
110
+ CONGESTION_COST_FACTOR = 2.0 # A* cost = (1/speed) * (1 + factor*demandRatio)
111
+ TRAFFIC_TOP_CELLS = 15 # traffic index = round(100 * mean(top-N ratios))
112
+ FIX_GATE_RATIO = 0.8 # below this max ratio, /api/fix gets a 409
113
+ JAM_RATIO = 1.0
114
+ FIX_AVOID_RATIO = 0.9 # bypass search forbids cells at/above this ratio
115
+ FIX_NEW_CELL_COST = 3.0 # bypass Dijkstra: cost per NEW road cell
116
+ FIX_WATER_COST_MULT = 4.0 # water crossable at 4x (fixes may build bridges)
117
+ FIX_MAX_NEW_CELLS = 16
118
+
119
+ # ------------------------------------------------------------------- placement
120
+ GROWTH_RADIUS_MIN = 6
121
+ GROWTH_RING_SLACK = 2 # penalty beyond growth_radius + slack
122
+ SCORE_ANCHOR = 30.0 # +30 * (1 - dist_to_anchor/R)
123
+ SCORE_FRONTAGE = 20.0 # footprint perimeter touches a road
124
+ SCORE_NEIGHBOR = 3.0 # per developed cell in the one-cell ring
125
+ SCORE_NEIGHBOR_CAP = 18.0
126
+ SCORE_RING_PENALTY = 8.0 # -8 * max(0, cheb_from_center - (growth_radius+slack))
127
+ AFF_SHOP_NEAR_HOMES = 12.0 # cafe/shop, residential within 3
128
+ AFF_DOWNTOWN = 10.0 # office/bank/tower: +10*(1 - dist_to_center/12)
129
+ AFF_HOME_NEAR_AMENITY = 8.0 # residential, cafe/shop/park within 4
130
+ AFF_HOME_NEAR_INDUSTRY = -6.0 # residential, factory/warehouse within 3
131
+ AFF_INDUSTRY_NEAR_HOMES = -10.0
132
+ AFF_INDUSTRY_CLUSTER = 8.0 # factory/warehouse, industrial within 4
133
+ AFF_PARK_SPACING = -25.0 # park, another park within 6
134
+ PLACE_R_START = 6
135
+ PLACE_R_STEP = 4
136
+ CONNECTOR_MAX = 8 # connector BFS length cap (cells of new road)
137
+
138
+ # -------------------------------------------------------------------- petitions
139
+ NAME_MAX_LEN = 24
140
+ INFILL_RATIO = 1.15 # jobs > housing*1.15 -> one bonus home
141
+ INFILL_APARTMENTS_DEFICIT = 16 # jobs-housing gap that upgrades the infill to apartments
engine/genesis.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY genesis — epoch 0, exactly as specified in ARCHITECTURE.md.
2
+
3
+ Roads, then buildings, then a note; all at t = CITY_EPOCH_S, wish_id
4
+ "genesis", seeds crc32-derived. These values are a cross-component contract
5
+ (web/mock/world.json is generated from this list); do NOT edit them.
6
+
7
+ Validity is asserted at import: every footprint on empty non-water in-bounds
8
+ cells, every building touching a road, bridge cells on water and nothing else.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ import zlib
15
+
16
+ from . import constants as C
17
+
18
+
19
+ def _seed(i: int) -> int:
20
+ return zlib.crc32(f"genesis:{i}".encode()) & 0x7FFFFFFF
21
+
22
+
23
+ def _ev(i: int, tool: str, args: dict) -> dict:
24
+ return {
25
+ "id": f"e_{i:06d}", "wish_id": "genesis", "tool": tool,
26
+ "args": args, "seed": _seed(i), "t": float(C.CITY_EPOCH_S),
27
+ }
28
+
29
+
30
+ def _col(cx: int, cz0: int, cz1: int) -> list[list[int]]:
31
+ return [[cx, cz] for cz in range(cz0, cz1 + 1)]
32
+
33
+
34
+ def _row(cz: int, cx0: int, cx1: int, skip: tuple[int, ...] = ()) -> list[list[int]]:
35
+ return [[cx, cz] for cx in range(cx0, cx1 + 1) if cx not in skip]
36
+
37
+
38
+ def _road(i: int, name: str, klass: str, cells: list[list[int]]) -> dict:
39
+ return _ev(i, "lay_road", {"cells": cells, "klass": klass, "name": name})
40
+
41
+
42
+ def _bld(i: int, kind: str, name: str, cx: int, cz: int, floors: int,
43
+ hue: int, variant: int) -> dict:
44
+ spec = C.BUILDINGS[kind]
45
+ return _ev(i, "place_building", {
46
+ "kind": kind, "name": name, "cx": cx, "cz": cz,
47
+ "w": spec["w"], "d": spec["d"], "floors": floors,
48
+ "hue": hue, "variant": variant,
49
+ })
50
+
51
+
52
+ GENESIS_FEATURES: list[dict] = [
53
+ # roads (1st Ave skips the river cells; the Old Bridge — street class, the
54
+ # bottleneck by design — carries the only crossing)
55
+ _road(0, "Main St", "avenue", _col(0, -12, 12)),
56
+ _road(1, "1st Ave", "avenue", _row(0, -12, 16, skip=(8, 9))),
57
+ _road(2, "Old Bridge", "street", [[8, 0], [9, 0]]),
58
+ _road(3, "River Rd", "street", _col(13, -6, 6)),
59
+ _road(4, "Elm St", "street", _row(-5, -8, 0)),
60
+ _road(5, "2nd St", "street", _col(-5, -5, 3)),
61
+ # downtown west of the river
62
+ _bld(6, "town_hall", "City Hall", -2, -3, 2, 44, 0),
63
+ _bld(7, "office", "Meridian Office", 1, -2, 5, 210, 3),
64
+ _bld(8, "cafe", "Corner Cafe", 1, 1, 1, 18, 1),
65
+ _bld(9, "market", "Main St Market", -1, 1, 1, 35, 2),
66
+ _bld(10, "park", "Founders Park", -4, 1, 0, 110, 0),
67
+ # Elm / 2nd St housing cluster
68
+ _bld(11, "house", "Elm Cottage", -1, -6, 2, 28, 4),
69
+ _bld(12, "house", "Rose Cottage", -4, -6, 1, 350, 5),
70
+ _bld(13, "house", "Sage House", -6, -4, 1, 95, 6),
71
+ # east of the river — jobs/homes that force bridge commutes from day one
72
+ _bld(14, "factory", "Riverside Works", 14, -3, 1, 24, 0),
73
+ _bld(15, "warehouse", "East Depot", 14, 1, 1, 210, 1),
74
+ _bld(16, "house", "Ferry House", 14, 3, 1, 205, 7),
75
+ _bld(17, "house", "River Cottage", 14, -5, 2, 16, 2),
76
+ _ev(18, "note", {"text": "Nemocity is listening. Ask for a building.",
77
+ "kind": "milestone"}),
78
+ ]
79
+
80
+
81
+ def genesis_features() -> list[dict]:
82
+ """Deep copy of the genesis event list (safe to mutate / load)."""
83
+ return copy.deepcopy(GENESIS_FEATURES)
84
+
85
+
86
+ def genesis_world():
87
+ """A fresh World seeded with the genesis events."""
88
+ from .world import World
89
+
90
+ return World.load(genesis_features())
91
+
92
+
93
+ def _validate() -> None:
94
+ water = {
95
+ (cx, cz)
96
+ for cz in range(C.COORD_MIN, C.COORD_MAX + 1)
97
+ for cx in C.river_cols(cz)
98
+ }
99
+ road: set[tuple[int, int]] = set()
100
+ for ev in GENESIS_FEATURES:
101
+ if ev["tool"] != "lay_road":
102
+ continue
103
+ name = ev["args"]["name"]
104
+ for cx, cz in ev["args"]["cells"]:
105
+ assert C.COORD_MIN <= cx <= C.COORD_MAX and C.COORD_MIN <= cz <= C.COORD_MAX, \
106
+ f"genesis: road cell off-grid in {name}"
107
+ on_water = (cx, cz) in water
108
+ assert on_water == (name == "Old Bridge"), \
109
+ f"genesis: {name} cell ({cx},{cz}) water mismatch"
110
+ road.add((cx, cz))
111
+ occupied: set[tuple[int, int]] = set(road)
112
+ for ev in GENESIS_FEATURES:
113
+ if ev["tool"] != "place_building":
114
+ continue
115
+ a = ev["args"]
116
+ cells = [
117
+ (a["cx"] + dx, a["cz"] + dz)
118
+ for dx in range(a["w"]) for dz in range(a["d"])
119
+ ]
120
+ for c in cells:
121
+ assert C.COORD_MIN <= c[0] <= C.COORD_MAX and C.COORD_MIN <= c[1] <= C.COORD_MAX, \
122
+ f"genesis: {a['name']} off-grid"
123
+ assert c not in water, f"genesis: {a['name']} on water at {c}"
124
+ assert c not in occupied, f"genesis: {a['name']} overlaps at {c}"
125
+ touches = any(
126
+ (cx + dx, cz + dz) in road
127
+ for cx, cz in cells
128
+ for dx, dz in ((0, -1), (1, 0), (0, 1), (-1, 0))
129
+ )
130
+ assert touches, f"genesis: {a['name']} touches no road"
131
+ occupied.update(cells)
132
+
133
+
134
+ _validate()
engine/moderation.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layered wish moderation. Moderation runs BEFORE execution, always.
2
+
3
+ Layers (each one a hard gate):
4
+ 1. charset/length — ≤160 printable chars (newlines/tabs are folded to
5
+ spaces first; true control / zero-width chars are denied).
6
+ 2. wordlist/regex — slurs, sexual content, hate symbols & terms. Robust to
7
+ leetspeak (n1gg3r), unicode confusables (Cyrillic/Greek lookalikes via an
8
+ explicit map on top of NFKC), diacritics (NFKD strip) and separator
9
+ padding (skeleton substring pass).
10
+ 3. optional async LLM judge — an injected callable `judge(text) ->
11
+ {"allowed": bool, "category": str|None}` (dict, JSON string, or Verdict).
12
+ DEFAULT-DENY on any judge exception, timeout, or parse failure.
13
+
14
+ The blocklists below exist solely to keep this content OFF the shared world.
15
+ Rejections are poetic but final.
16
+
17
+ Pure stdlib. Only `check()` is async (the judge hook may be a coroutine).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import inspect
24
+ import json
25
+ import re
26
+ import unicodedata
27
+ from dataclasses import dataclass
28
+ from typing import Any, Callable, Optional
29
+
30
+ # Petitions are <=160 chars (the BUILD prompt budget); internal name keeps
31
+ # godseed's wish_* wiring.
32
+ MAX_WISH_LEN = 160
33
+
34
+ POETIC = {
35
+ "empty": "city hall received a blank form; write a petition",
36
+ "length": "city hall reads short petitions only — fewer than 160 characters",
37
+ "charset": "city hall cannot read these characters",
38
+ "hate": "city hall will not zone for hate; petition DENIED",
39
+ "sexual": "city hall stamps this petition DENIED",
40
+ "violence": "this city builds; it does not burn — petition DENIED",
41
+ "uncertain": "city hall weighed this petition and set it aside",
42
+ "judged": "city hall declines this petition",
43
+ }
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Verdict:
48
+ allowed: bool
49
+ category: Optional[str]
50
+ poetic_reason: Optional[str]
51
+
52
+
53
+ def _deny(category: str) -> Verdict:
54
+ return Verdict(False, category, POETIC.get(category, POETIC["judged"]))
55
+
56
+
57
+ # --------------------------------------------------------------- normalization
58
+
59
+ # Unicode lookalikes NFKC does NOT fold (Cyrillic / Greek / phonetic Latin).
60
+ _CONFUSABLES = {
61
+ # Cyrillic
62
+ "а": "a", "в": "b", "е": "e", "ё": "e", "є": "e", "і": "i", "ї": "i",
63
+ "ј": "j", "к": "k", "м": "m", "н": "h", "о": "o", "р": "p", "с": "c",
64
+ "т": "t", "у": "y", "х": "x", "г": "r", "ѕ": "s", "ԁ": "d", "ԛ": "q",
65
+ "ԝ": "w", "ь": "b",
66
+ # Greek
67
+ "α": "a", "β": "b", "γ": "y", "ε": "e", "ζ": "z", "η": "n", "ι": "i",
68
+ "κ": "k", "ν": "v", "ο": "o", "ρ": "p", "ς": "s", "σ": "s", "τ": "t",
69
+ "υ": "u", "χ": "x", "ω": "w",
70
+ # Latin extras
71
+ "ɡ": "g", "ɑ": "a", "ı": "i", "ø": "o", "đ": "d", "ƒ": "f",
72
+ }
73
+
74
+ _LEET = str.maketrans({
75
+ "0": "o", "1": "i", "2": "z", "3": "e", "4": "a", "5": "s", "6": "g",
76
+ "7": "t", "8": "b", "9": "g", "@": "a", "$": "s", "!": "i", "+": "t",
77
+ "|": "l", "€": "e", "£": "l",
78
+ })
79
+
80
+ # Phonetic Cyrillic→Latin (catches transliterated terms, e.g. "хитлер";
81
+ # distinct from _CONFUSABLES, which folds visual lookalikes only).
82
+ _CYRILLIC_TRANSLIT = {
83
+ "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e",
84
+ "ж": "zh", "з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m",
85
+ "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
86
+ "ф": "f", "х": "h", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "sh",
87
+ "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya",
88
+ "і": "i", "ї": "i", "є": "e", "ѕ": "s", "ј": "j",
89
+ }
90
+
91
+
92
+ def _base_fold(text: str) -> str:
93
+ """NFKC + casefold + diacritic strip (shared by all matching variants)."""
94
+ s = unicodedata.normalize("NFKC", text).casefold()
95
+ s = unicodedata.normalize("NFKD", s)
96
+ return "".join(ch for ch in s if not unicodedata.combining(ch))
97
+
98
+
99
+ def _normalize(text: str) -> str:
100
+ return "".join(_CONFUSABLES.get(ch, ch) for ch in _base_fold(text))
101
+
102
+
103
+ def _translit(text: str) -> str:
104
+ return "".join(_CYRILLIC_TRANSLIT.get(ch, ch) for ch in _base_fold(text))
105
+
106
+
107
+ def _leetfold(text: str) -> str:
108
+ return text.translate(_LEET)
109
+
110
+
111
+ def _skeleton(text: str) -> str:
112
+ """Letters only — defeats spacing/punctuation padding (n.i.g.g.e.r)."""
113
+ return "".join(ch for ch in text if ch.isalpha())
114
+
115
+
116
+ # --------------------------------------------------------------- blocklists
117
+
118
+ # Token-bounded terms (word-boundary match; safe against raccoon/sussex/etc).
119
+ _TERMS = {
120
+ "hate": (
121
+ "nigger", "niggers", "nigga", "niggas", "niglet", "niglets",
122
+ "faggot", "faggots", "fag", "fags", "dyke", "dykes", "kike", "kikes",
123
+ "spic", "spics", "chink", "chinks", "gook", "gooks",
124
+ "wetback", "wetbacks", "beaner", "beaners", "coon", "coons",
125
+ "jigaboo", "jigaboos", "golliwog", "golliwogs",
126
+ "tranny", "trannies", "shemale", "shemales", "paki", "pakis",
127
+ "raghead", "ragheads", "towelhead", "towelheads", "injun", "injuns",
128
+ "retard", "retards", "retarded",
129
+ "hitler", "hitlers", "nazi", "nazis", "neonazi", "neonazis",
130
+ "swastika", "swastikas", "hakenkreuz", "kkk", "klansman", "klansmen",
131
+ "fuhrer", "goebbels", "himmler", "holocaust", "reich",
132
+ "gitler", "svastika", # common Cyrillic transliterations
133
+ "genocide", "lynch", "lynching", "lynchings",
134
+ ),
135
+ "sexual": (
136
+ "porn", "porno", "pornography", "pornographic",
137
+ "blowjob", "blowjobs", "handjob", "handjobs", "rimjob", "rimjobs",
138
+ "cum", "cumshot", "cumshots", "jizz", "semen",
139
+ "dick", "dicks", "cock", "cocks", "pussy", "pussies",
140
+ "tits", "titties", "boobs", "penis", "penises", "vagina", "vaginas",
141
+ "cunt", "cunts", "clit", "clitoris",
142
+ "whore", "whores", "slut", "sluts", "hentai", "milf", "milfs",
143
+ "dildo", "dildos", "anal", "anus", "orgasm", "orgasms", "orgy",
144
+ "orgies", "fellatio", "cunnilingus",
145
+ "masturbate", "masturbates", "masturbation",
146
+ "bukkake", "deepthroat", "gangbang", "creampie",
147
+ "futa", "futanari", "loli", "lolicon", "shota", "shotacon",
148
+ "rape", "rapes", "raped", "raping", "rapist", "rapists",
149
+ "molest", "molests", "molested", "molester", "molestation",
150
+ "pedophile", "pedophiles", "pedo", "pedos",
151
+ "paedophile", "paedophiles", "incest", "bestiality", "necrophilia",
152
+ "sex", "sexy", "sexual", "nudes", "boner", "boners",
153
+ ),
154
+ }
155
+
156
+ # Multi-word / pattern matches (checked on normalized AND leet-folded text).
157
+ _PHRASES = (
158
+ (r"heil\s+hitler", "hate"),
159
+ (r"sieg\s+heil", "hate"),
160
+ (r"white\s+power", "hate"),
161
+ (r"white\s+supremacy", "hate"),
162
+ (r"blood\s+and\s+soil", "hate"),
163
+ (r"great\s+replacement", "hate"),
164
+ (r"ethnic\s+cleansing", "hate"),
165
+ (r"gas\s+the\s+(?:jews|blacks|gays|muslims|immigrants)", "hate"),
166
+ (r"ku\s+klux", "hate"),
167
+ (r"\b14\s*/?\s*88\b", "hate"),
168
+ (r"kill\s+(?:all|every)\b", "violence"),
169
+ (r"school\s+shoot(?:ing|ings|er|ers)?", "violence"),
170
+ (r"mass\s+shooting", "violence"),
171
+ (r"child\s+porn\w*", "sexual"),
172
+ (r"kiddie\s+porn\w*", "sexual"),
173
+ )
174
+
175
+ # High-severity substrings checked on the letters-only skeleton (defeats
176
+ # s p a c e d and dotted spellings; terms chosen for near-zero false positives).
177
+ _SKELETON_TERMS = (
178
+ ("nigger", "hate"), ("nigga", "hate"), ("faggot", "hate"),
179
+ ("swastika", "hate"), ("svastika", "hate"),
180
+ ("hakenkreuz", "hate"), ("heilhitler", "hate"),
181
+ ("siegheil", "hate"), ("kukluxklan", "hate"), ("whitepower", "hate"),
182
+ ("jigaboo", "hate"), ("porchmonkey", "hate"), ("towelhead", "hate"),
183
+ ("raghead", "hate"), ("wetback", "hate"),
184
+ ("blowjob", "sexual"), ("deepthroat", "sexual"), ("childporn", "sexual"),
185
+ ("bukkake", "sexual"), ("gangbang", "sexual"), ("lolicon", "sexual"),
186
+ ("cumshot", "sexual"),
187
+ )
188
+
189
+ # Hate symbols checked on the raw text before any folding.
190
+ _SYMBOLS = ("卐", "卍", "ᛋᛋ")
191
+
192
+
193
+ def _compile_tokens(terms: tuple[str, ...]) -> re.Pattern:
194
+ joined = "|".join(sorted(map(re.escape, terms), key=len, reverse=True))
195
+ return re.compile(rf"\b(?:{joined})\b")
196
+
197
+ _TOKEN_RX = {category: _compile_tokens(terms) for category, terms in _TERMS.items()}
198
+ _PHRASE_RX = tuple((re.compile(pattern), category) for pattern, category in _PHRASES)
199
+
200
+
201
+ # --------------------------------------------------------------- moderator
202
+
203
+ class Moderator:
204
+ """`precheck` = sync layers 1-2 (the API fast path); `check` adds the judge."""
205
+
206
+ def __init__(
207
+ self,
208
+ judge: Optional[Callable[[str], Any]] = None,
209
+ judge_timeout: Optional[float] = 30.0,
210
+ ) -> None:
211
+ self._judge = judge
212
+ self._judge_timeout = judge_timeout
213
+
214
+ # -- layers 1 + 2 (sync, cheap, deterministic) --
215
+
216
+ def precheck(self, text: Any) -> Verdict:
217
+ """Full gate for USER wish input: length + charset bounds + content."""
218
+ if not isinstance(text, str):
219
+ return _deny("empty")
220
+ # fold benign line breaks/tabs to spaces before the printable gate
221
+ raw = re.sub(r"\s+", " ", text).strip()
222
+ if not raw:
223
+ return _deny("empty")
224
+ if len(raw) > MAX_WISH_LEN:
225
+ return _deny("length")
226
+ if any(not ch.isprintable() for ch in raw):
227
+ return _deny("charset")
228
+ return self.check_content(raw)
229
+
230
+ def check_content(self, text: Any) -> Verdict:
231
+ """Content-only check (slurs/hate/sexual/etc.) WITHOUT the wish-input
232
+ length/charset bounds. Use this to re-moderate MODEL-composed text — the
233
+ god's reading is intentionally long (~700 chars); applying the 140-char
234
+ wish limit here silenced every reading (June 12 regression)."""
235
+ if not isinstance(text, str):
236
+ return _deny("empty")
237
+ raw = re.sub(r"\s+", " ", text).strip()
238
+ if not raw:
239
+ return _deny("empty")
240
+
241
+ for symbol in _SYMBOLS:
242
+ if symbol in raw:
243
+ return _deny("hate")
244
+
245
+ norm = _normalize(raw)
246
+ folded = _leetfold(norm)
247
+ translit = _translit(raw)
248
+ variants = (norm, folded, translit) if translit != norm else (norm, folded)
249
+
250
+ for rx, category in _PHRASE_RX:
251
+ if any(rx.search(v) for v in variants):
252
+ return _deny(category)
253
+ for category, rx in _TOKEN_RX.items():
254
+ if any(rx.search(v) for v in variants):
255
+ return _deny(category)
256
+
257
+ skeletons = {_skeleton(folded), _skeleton(translit)}
258
+ for term, category in _SKELETON_TERMS:
259
+ if any(term in s for s in skeletons):
260
+ return _deny(category)
261
+
262
+ return Verdict(True, None, None)
263
+
264
+ # -- layer 3 (async LLM judge, default-deny on uncertainty) --
265
+
266
+ async def check(self, text: Any) -> Verdict:
267
+ verdict = self.precheck(text)
268
+ if not verdict.allowed or self._judge is None:
269
+ return verdict
270
+ try:
271
+ result = self._judge(text)
272
+ if inspect.isawaitable(result):
273
+ if self._judge_timeout:
274
+ result = await asyncio.wait_for(result, self._judge_timeout)
275
+ else:
276
+ result = await result
277
+ except asyncio.CancelledError:
278
+ raise
279
+ except Exception:
280
+ return _deny("uncertain")
281
+ return self._parse_judge(result)
282
+
283
+ @staticmethod
284
+ def _parse_judge(raw: Any) -> Verdict:
285
+ """Strict parse of the judge's reply; anything murky → deny."""
286
+ try:
287
+ if isinstance(raw, Verdict):
288
+ return raw
289
+ data = raw
290
+ if isinstance(data, (bytes, bytearray)):
291
+ data = data.decode("utf-8", "replace")
292
+ if isinstance(data, str):
293
+ start, end = data.find("{"), data.rfind("}")
294
+ if start < 0 or end <= start:
295
+ return _deny("uncertain")
296
+ data = json.loads(data[start : end + 1])
297
+ if not isinstance(data, dict):
298
+ return _deny("uncertain")
299
+ allowed = data.get("allowed")
300
+ if isinstance(allowed, str):
301
+ lowered = allowed.strip().lower()
302
+ allowed = {"true": True, "false": False}.get(lowered, allowed)
303
+ if allowed is True:
304
+ return Verdict(True, None, None)
305
+ if allowed is False:
306
+ category = data.get("category")
307
+ category = str(category) if category else "judged"
308
+ return Verdict(False, category, POETIC.get(category, POETIC["judged"]))
309
+ return _deny("uncertain")
310
+ except Exception:
311
+ return _deny("uncertain")
engine/placement.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY placement — deterministic, NEVER fails, Python-only.
2
+
3
+ The LLM gives {kind, name, near, floors, hue}; this module turns it into
4
+ exact cells: anchor resolution from the "near" text, a scored candidate
5
+ search over an expanding Chebyshev radius, and an auto-routed road connector
6
+ when the chosen footprint has no frontage. Output is partial event dicts
7
+ ({"tool", "args"}) — world.apply assigns id/seed/t.
8
+
9
+ All randomness is crc32-seeded from (wish_id, indices); no wall clock here.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import zlib
16
+ from collections import deque
17
+ from typing import Any, Optional
18
+
19
+ from . import constants as C
20
+ from .city import WATER_CELLS, Building, Cell, CityState, in_bounds, neighbors4
21
+ from .tools import street_name_for
22
+
23
+ _CENTER_WORDS = frozenset(("center", "centre", "downtown", "middle", "heart"))
24
+ _RIVER_WORDS = frozenset(("river", "water", "waterfront", "riverside", "shore"))
25
+ _STREET_SUFFIXES = frozenset(("st", "ave", "rd", "ln", "blvd", "way", "ct", "row"))
26
+ _STOP = frozenset(("the", "a", "an", "of", "near", "by", "next", "to", "at", "in", "on"))
27
+
28
+ _R_MAX = 2 * C.GRID # covers the whole grid from any anchor
29
+
30
+
31
+ def _u01(key: str) -> float:
32
+ return (zlib.crc32(key.encode()) & 0xFFFFFFFF) / 0xFFFFFFFF
33
+
34
+
35
+ def _tokens(text: str) -> list[str]:
36
+ return [
37
+ t for t in re.findall(r"[a-z0-9']+", str(text or "").lower())
38
+ if t not in _STOP
39
+ ]
40
+
41
+
42
+ def _round_cell(x: float, z: float) -> Cell:
43
+ cx = min(max(int(round(x)), C.COORD_MIN), C.COORD_MAX)
44
+ cz = min(max(int(round(z)), C.COORD_MIN), C.COORD_MAX)
45
+ return (cx, cz)
46
+
47
+
48
+ def resolve_anchor(city: CityState, near: Any) -> Cell:
49
+ """Fuzzy anchor: named building > street > center > river > kind word >
50
+ centroid of all buildings > (0,0)."""
51
+ text = str(near or "").strip().lower()
52
+ toks = _tokens(text)
53
+ if toks:
54
+ for b in reversed(city.buildings):
55
+ name_toks = set(_tokens(b.name))
56
+ if name_toks and (set(toks) & name_toks):
57
+ return _round_cell(*b.centroid)
58
+ for name, cells in city.street_cells.items():
59
+ low = name.lower()
60
+ name_toks = set(_tokens(low)) - _STREET_SUFFIXES
61
+ if low in text or (name_toks and set(toks) & name_toks):
62
+ return cells[len(cells) // 2]
63
+ if set(toks) & _CENTER_WORDS:
64
+ return (0, 0)
65
+ if set(toks) & _RIVER_WORDS:
66
+ developed = set(city.roads) | set(city.building_cells)
67
+ banks = sorted(
68
+ (c for c in developed if any(n in WATER_CELLS for n in neighbors4(c))),
69
+ key=lambda c: (max(abs(c[0]), abs(c[1])), c[1], c[0]),
70
+ )
71
+ if banks:
72
+ return banks[0]
73
+ for t in toks:
74
+ kind = t if t in C.BUILDINGS else C.SYNONYMS.get(t)
75
+ if kind:
76
+ for b in reversed(city.buildings):
77
+ if b.kind == kind:
78
+ return _round_cell(*b.centroid)
79
+ if city.buildings:
80
+ xs = [b.centroid[0] for b in city.buildings]
81
+ zs = [b.centroid[1] for b in city.buildings]
82
+ return _round_cell(sum(xs) / len(xs), sum(zs) / len(zs))
83
+ return (0, 0)
84
+
85
+
86
+ # ----------------------------------------------------------------- connectors
87
+
88
+ def _road_distance_field(city: CityState, max_dist: int) -> dict[Cell, int]:
89
+ """Multi-source BFS over EMPTY cells from the road network. dist=1 means
90
+ 'this empty cell is adjacent to a road' (= a 1-cell connector)."""
91
+ dist: dict[Cell, int] = {}
92
+ queue: deque[Cell] = deque()
93
+ seeds = set()
94
+ for road_cell in city.roads:
95
+ for n in neighbors4(road_cell):
96
+ if city.is_empty(n) and n not in seeds:
97
+ seeds.add(n)
98
+ for cell in sorted(seeds, key=lambda c: (c[1], c[0])):
99
+ dist[cell] = 1
100
+ queue.append(cell)
101
+ while queue:
102
+ cell = queue.popleft()
103
+ d = dist[cell]
104
+ if d >= max_dist:
105
+ continue
106
+ for n in neighbors4(cell):
107
+ if n not in dist and city.is_empty(n):
108
+ dist[n] = d + 1
109
+ queue.append(n)
110
+ return dist
111
+
112
+
113
+ def _connector_path(
114
+ city: CityState, footprint: set[Cell], max_len: int
115
+ ) -> Optional[list[Cell]]:
116
+ """Shortest Manhattan path of NEW road cells from the footprint ring to a
117
+ cell adjacent to the existing road network. Deterministic BFS order."""
118
+ starts = sorted(
119
+ {
120
+ n for cell in footprint for n in neighbors4(cell)
121
+ if city.is_empty(n) and n not in footprint
122
+ },
123
+ key=lambda c: (c[1], c[0]),
124
+ )
125
+ prev: dict[Cell, Optional[Cell]] = {}
126
+ queue: deque[tuple[Cell, int]] = deque()
127
+ for s in starts:
128
+ prev[s] = None
129
+ queue.append((s, 1))
130
+ while queue:
131
+ cell, length = queue.popleft()
132
+ if any(n in city.roads for n in neighbors4(cell)):
133
+ path = [cell]
134
+ while prev[path[-1]] is not None:
135
+ path.append(prev[path[-1]])
136
+ path.reverse()
137
+ return path
138
+ if length >= max_len:
139
+ continue
140
+ for n in neighbors4(cell):
141
+ if n not in prev and city.is_empty(n) and n not in footprint:
142
+ prev[n] = cell
143
+ queue.append((n, length + 1))
144
+ return None
145
+
146
+
147
+ # -------------------------------------------------------------------- scoring
148
+
149
+ def _rect_cheb(b: Building, cx: int, cz: int, w: int, d: int) -> int:
150
+ """Exact min Chebyshev distance between a building and a candidate rect."""
151
+ dx = max(0, b.cx - (cx + w - 1), cx - (b.cx + b.w - 1))
152
+ dz = max(0, b.cz - (cz + d - 1), cz - (b.cz + b.d - 1))
153
+ return max(dx, dz)
154
+
155
+
156
+ def _affinity(city: CityState, kind: str, cx: int, cz: int, w: int, d: int) -> float:
157
+ res = ind = amen = park = 10 ** 6
158
+ for b in city.buildings:
159
+ dist = _rect_cheb(b, cx, cz, w, d)
160
+ if b.kind in C.RESIDENTIAL_KINDS:
161
+ res = min(res, dist)
162
+ if b.kind in C.INDUSTRIAL_KINDS:
163
+ ind = min(ind, dist)
164
+ if b.kind in ("cafe", "shop", "park"):
165
+ amen = min(amen, dist)
166
+ if b.kind == "park":
167
+ park = min(park, dist)
168
+ score = 0.0
169
+ if kind in ("cafe", "shop") and res <= 3:
170
+ score += C.AFF_SHOP_NEAR_HOMES
171
+ if kind in ("office", "bank", "tower"):
172
+ dist_c = max(abs(cx), abs(cz))
173
+ score += C.AFF_DOWNTOWN * (1 - dist_c / 12.0)
174
+ if kind in C.RESIDENTIAL_KINDS:
175
+ if amen <= 4:
176
+ score += C.AFF_HOME_NEAR_AMENITY
177
+ if ind <= 3:
178
+ score += C.AFF_HOME_NEAR_INDUSTRY
179
+ if kind in C.INDUSTRIAL_KINDS:
180
+ if res <= 3:
181
+ score += C.AFF_INDUSTRY_NEAR_HOMES
182
+ if ind <= 4:
183
+ score += C.AFF_INDUSTRY_CLUSTER
184
+ if kind == "park" and park <= 6:
185
+ score += C.AFF_PARK_SPACING
186
+ return score
187
+
188
+
189
+ def _search(
190
+ city: CityState, kind: str, anchor: Cell, wish_id: str, connector_max: int
191
+ ) -> Optional[tuple[Cell, bool]]:
192
+ """Best (top_left, has_frontage) within the smallest radius that holds a
193
+ valid candidate, or None."""
194
+ spec = C.BUILDINGS[kind]
195
+ w, d = spec["w"], spec["d"]
196
+ growth = city.growth_radius
197
+ ring_limit = growth + C.GROWTH_RING_SLACK
198
+ road_dist = _road_distance_field(city, max(connector_max, 1) + 1)
199
+ ax, az = anchor
200
+
201
+ radius = C.PLACE_R_START
202
+ while radius <= _R_MAX:
203
+ best: Optional[tuple[float, Cell, bool]] = None
204
+ index = 0
205
+ for cz in range(az - radius, az + radius + 1):
206
+ for cx in range(ax - radius, ax + radius + 1):
207
+ index += 1
208
+ cells = [(cx + dx, cz + dz) for dx in range(w) for dz in range(d)]
209
+ if not all(city.is_empty(c) for c in cells):
210
+ continue
211
+ footprint = set(cells)
212
+ ring = {
213
+ n for c in cells for n in neighbors4(c) if n not in footprint
214
+ }
215
+ frontage = any(n in city.roads for n in ring)
216
+ if not frontage and not any(
217
+ road_dist.get(n, 10 ** 9) <= connector_max for n in ring
218
+ ):
219
+ continue
220
+ dist_anchor = max(abs(cx - ax), abs(cz - az))
221
+ score = C.SCORE_ANCHOR * (1 - dist_anchor / radius)
222
+ if frontage:
223
+ score += C.SCORE_FRONTAGE
224
+ developed = sum(
225
+ 1 for n in ring if n in city.roads or n in city.building_cells
226
+ )
227
+ score += min(C.SCORE_NEIGHBOR * developed, C.SCORE_NEIGHBOR_CAP)
228
+ score += _affinity(city, kind, cx, cz, w, d)
229
+ cheb_center = max(abs(cx), abs(cz))
230
+ score -= C.SCORE_RING_PENALTY * max(0, cheb_center - ring_limit)
231
+ score += _u01(f"{wish_id}:{index}") * 0.5 # seeded tie-break
232
+ if best is None or score > best[0]:
233
+ best = (score, (cx, cz), frontage)
234
+ if best is not None:
235
+ return best[1], best[2]
236
+ radius += C.PLACE_R_STEP
237
+ return None
238
+
239
+
240
+ # ----------------------------------------------------------------------- place
241
+
242
+ def place(
243
+ city: CityState,
244
+ kind: str,
245
+ name: str,
246
+ floors: Any,
247
+ hue: Any,
248
+ near: Any,
249
+ wish_id: str,
250
+ call_index: int,
251
+ ) -> list[dict]:
252
+ """Resolve a building request into 1-2 events (place_building + optional
253
+ connector lay_road). Never fails: the radius grows, the connector cap
254
+ relaxes, and a footprint that fits nowhere degrades to a house."""
255
+ if kind not in C.BUILDINGS:
256
+ kind = "house"
257
+ anchor = resolve_anchor(city, near)
258
+
259
+ found = _search(city, kind, anchor, wish_id, C.CONNECTOR_MAX)
260
+ if found is None:
261
+ found = _search(city, kind, anchor, wish_id, 2 * C.CONNECTOR_MAX)
262
+ if found is None and kind != "house":
263
+ return place(city, "house", name, 1, hue, near, wish_id, call_index)
264
+ if found is None: # a 64x64 grid with any road at all always has a slot
265
+ found = _search(city, kind, anchor, wish_id, 10 ** 6)
266
+ if found is None:
267
+ raise RuntimeError("placement impossible: no empty cell on the grid")
268
+
269
+ (cx, cz), frontage = found
270
+ spec = C.BUILDINGS[kind]
271
+ lo, hi = spec["floors"]
272
+ base = None
273
+ if isinstance(floors, (int, float)) and not isinstance(floors, bool):
274
+ base = int(round(floors))
275
+ if kind in ("office", "tower"):
276
+ if base is None:
277
+ base = (lo + hi) // 2
278
+ dist_c = max(abs(cx), abs(cz))
279
+ base = base + round(4 * (1 - dist_c / 10.0)) # taller downtown
280
+ elif base is None:
281
+ base = lo + zlib.crc32(f"{wish_id}:{call_index}:floors".encode()) % (hi - lo + 1)
282
+ final_floors = min(max(base, lo), hi)
283
+
284
+ if isinstance(hue, (int, float)) and not isinstance(hue, bool):
285
+ final_hue = int(round(hue)) % 361
286
+ else:
287
+ final_hue = zlib.crc32(f"{wish_id}:{call_index}:hue".encode()) % 360
288
+ variant = zlib.crc32(f"{wish_id}:{call_index}:variant".encode()) % 8
289
+
290
+ events: list[dict] = [{
291
+ "tool": "place_building",
292
+ "args": {
293
+ "kind": kind, "name": str(name or ""), "cx": cx, "cz": cz,
294
+ "w": spec["w"], "d": spec["d"], "floors": final_floors,
295
+ "hue": final_hue, "variant": variant,
296
+ },
297
+ }]
298
+ if not frontage:
299
+ footprint = {
300
+ (cx + dx, cz + dz) for dx in range(spec["w"]) for dz in range(spec["d"])
301
+ }
302
+ path = _connector_path(city, footprint, 2 * C.CONNECTOR_MAX) or \
303
+ _connector_path(city, footprint, 10 ** 6)
304
+ if path:
305
+ events.append({
306
+ "tool": "lay_road",
307
+ "args": {
308
+ "cells": [[c[0], c[1]] for c in path],
309
+ "klass": "street",
310
+ "name": street_name_for(f"{wish_id}:{call_index}:road"),
311
+ },
312
+ })
313
+ return events
engine/queue_worker.py ADDED
@@ -0,0 +1,719 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY petition queue — one city hall, one petition at a time.
2
+
3
+ A single global asyncio worker serializes every job:
4
+
5
+ wish: pop -> moderate -> planner.grant(text, summary, act, emit)
6
+ [act(building_req) runs the engine: synonyms -> sanitize ->
7
+ placement.place -> world.apply -> world_delta per event]
8
+ -> infill rule -> persist trace -> wish_granted
9
+ fix: pop -> traffic snapshot (stats + candidates) -> planner.fix(
10
+ stats, candidates, act_fix, emit) -> ONE apply_fix event ->
11
+ persist -> wish_granted
12
+
13
+ Admission control in `submit`/`submit_fix`: 1 pending job per client, queue
14
+ cap 50. Hourly rate limits + fix cooldowns live in the server, not here.
15
+ `submit_fix` raises TrafficSmooth (-> 409 upstream) when the static
16
+ assignment says there is nothing to fix.
17
+
18
+ The emit passed to the planner is wrapped: any event missing a wish_id gets
19
+ this job's id injected — so the planner can emit {type:"plan", plan:{...}}
20
+ right after its JSON parses without knowing the wish_id.
21
+
22
+ Crash-safe by construction: an exception while granting one job emits a
23
+ readable generic wish_rejected and the worker moves on. SSE emit failures
24
+ are swallowed; persist failures are logged but do not undo a grant.
25
+
26
+ Wire names keep godseed's wish_* verbatim; user-facing copy says petition.
27
+ Stdlib only; this is the only async module in the engine.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import contextlib
34
+ import inspect
35
+ import logging
36
+ import re
37
+ import time
38
+ import uuid
39
+ from dataclasses import dataclass
40
+ from typing import Any, Optional
41
+
42
+ from . import constants as C
43
+ from . import placement, tools, traffic
44
+ from .city import CityState
45
+
46
+ log = logging.getLogger("nemocity.engine.queue")
47
+
48
+ QUEUE_MAX = 50
49
+ MAX_BUILDINGS_PER_WISH = 3 # act() invocations the planner gets per petition
50
+ MAX_CALLS_PER_WISH = 12 # engine backstop on applied events
51
+ TEXT_PREVIEW_LEN = 60
52
+ _SUBMIT_TEXT_CAP = 500 # keep raw text bounded; moderation still sees >160 and denies
53
+
54
+ # Browser-invoked granting (ZeroGPU): GPU work must run inside the PETITIONER'S
55
+ # gradio request (their HF auth headers carry the quota — a server-side
56
+ # background spaces.GPU call fails by design). The worker emits your_turn and
57
+ # waits for the browser to fetch its token (authenticated) then invoke().
58
+ INVOCATION_WINDOW_S = 25.0
59
+ GRANT_DEADLINE_S = 150.0 # one-shot 9B runs well under this; bounds a hung grant
60
+
61
+ QUEUE_FULL_REASON = "city hall is swamped; come back in a few minutes"
62
+ ALREADY_PENDING_REASON = "one petition per visitor at a time; yours is still on the desk"
63
+ WANDERED_REASON = "the petitioner left the counter; city hall moved on"
64
+ INTERRUPTED_REASON = "the review was interrupted; city hall moved on"
65
+ NOT_YOUR_TURN_REASON = "this petition is not at the counter"
66
+ RITE_BEGUN_REASON = "this petition is already being processed"
67
+ CRASH_REASON = "city hall misfiled this petition; please submit it again"
68
+ BUSY_HEAVENS_REASON = "the planning office is busy; offer your petition again in a moment"
69
+ HANDS_FULL_OBSERVATION = "rejected: this petition's permit limit is reached"
70
+ FALLBACK_EPITAPH = "the city granted a home"
71
+ TRAFFIC_SMOOTH_REASON = "Traffic is flowing smoothly."
72
+ FIX_JOB_TEXT = "City Engineer: traffic review"
73
+ INFILL_NOTE = "New families are moving in."
74
+
75
+ _WISH_ID_RE = re.compile(r"^w_(\d+)$")
76
+
77
+
78
+ class QueueError(Exception):
79
+ """Admission failure; `.reason` is readable and safe to show users."""
80
+
81
+ def __init__(self, reason: str) -> None:
82
+ super().__init__(reason)
83
+ self.reason = reason
84
+
85
+
86
+ class QueueFull(QueueError):
87
+ pass
88
+
89
+
90
+ class AlreadyPending(QueueError):
91
+ pass
92
+
93
+
94
+ class TrafficSmooth(QueueError):
95
+ """Nothing to fix — the server matches this by type name -> 409."""
96
+
97
+
98
+ @dataclass
99
+ class _Item:
100
+ wish_id: str
101
+ text: str
102
+ client_id: str
103
+ submitted_at: float
104
+ kind: str = "wish" # "wish" | "fix"
105
+
106
+
107
+ def _trace_get(trace: Any, key: str) -> Any:
108
+ if isinstance(trace, dict):
109
+ return trace.get(key)
110
+ return getattr(trace, key, None)
111
+
112
+
113
+ def _trace_set(trace: Any, key: str, value: Any) -> None:
114
+ try:
115
+ if isinstance(trace, dict):
116
+ trace[key] = value
117
+ else:
118
+ setattr(trace, key, value)
119
+ except Exception: # frozen dataclass etc. — best-effort
120
+ pass
121
+
122
+
123
+ def _epitaph_from_reading(reading: Any) -> Optional[str]:
124
+ """Salvage the blurb from the model's last sentence so the ledger stays
125
+ varied when the model omits a clean one."""
126
+ text = str(reading or "").strip()
127
+ if not text:
128
+ return None
129
+ parts = [p.strip() for p in re.split(r"(?<=[.!?])\s+", text) if p.strip()]
130
+ return parts[-1][:120] if parts else None
131
+
132
+
133
+ def _num(value: Any) -> Optional[float]:
134
+ if isinstance(value, bool):
135
+ return None
136
+ if isinstance(value, (int, float)):
137
+ return float(value)
138
+ try:
139
+ return float(str(value).strip())
140
+ except (ValueError, TypeError):
141
+ return None
142
+
143
+
144
+ class QueueWorker:
145
+ """Single serialized worker; construct once, `await start()` at boot.
146
+
147
+ Injected collaborators (duck-typed, no engine->mind/server imports):
148
+ world engine.world.World (apply/summary/record_epitaph/epoch)
149
+ moderator engine.moderation.Moderator (async check)
150
+ planner object with `async grant(text, summary, act, emit)` and
151
+ (optionally) `async fix(stats, candidates, act_fix, emit)`
152
+ emit async callable broadcasting one SSE event dict
153
+ persist callable (sync or async) receiving the WishTrace; optional
154
+ """
155
+
156
+ def __init__(
157
+ self,
158
+ world: Any,
159
+ moderator: Any,
160
+ planner: Any,
161
+ emit: Any,
162
+ persist: Any = None,
163
+ *,
164
+ max_queue: int = QUEUE_MAX,
165
+ max_calls_per_wish: int = MAX_CALLS_PER_WISH,
166
+ next_wish_index: Optional[int] = None,
167
+ browser_invoked: bool = False,
168
+ invocation_window: float = INVOCATION_WINDOW_S,
169
+ grant_deadline: float = GRANT_DEADLINE_S,
170
+ ) -> None:
171
+ self._world = world
172
+ self._moderator = moderator
173
+ self._planner = planner
174
+ self._emit = emit
175
+ self._persist = persist
176
+ self._max_queue = max_queue
177
+ self._max_calls = max_calls_per_wish
178
+ self._queue: asyncio.Queue[_Item] = asyncio.Queue()
179
+ self._pending: dict[str, str] = {} # client_id -> wish_id
180
+ self._current: Optional[_Item] = None
181
+ self._task: Optional[asyncio.Task] = None
182
+ self._browser_invoked = browser_invoked
183
+ self._invocation_window = invocation_window
184
+ self._grant_deadline = grant_deadline
185
+ self._awaiting: Optional[dict[str, Any]] = None
186
+ self._wish_counter = (
187
+ next_wish_index if next_wish_index is not None else self._derive_wish_counter(world)
188
+ )
189
+
190
+ @staticmethod
191
+ def _derive_wish_counter(world: Any) -> int:
192
+ """Continue numbering after the highest persisted w_NNNNNN."""
193
+ best = 0
194
+ for feature in getattr(world, "features", ()) or ():
195
+ match = _WISH_ID_RE.match(getattr(feature, "wish_id", "") or "")
196
+ if match:
197
+ best = max(best, int(match.group(1)) + 1)
198
+ return best or 1 # first-ever petition is w_000001
199
+
200
+ def _city(self) -> CityState:
201
+ return CityState.from_events(getattr(self._world, "features", ()) or ())
202
+
203
+ # --------------------------------------------------------------- admission
204
+
205
+ def _admit(self, client_id: str) -> None:
206
+ if client_id in self._pending:
207
+ raise AlreadyPending(ALREADY_PENDING_REASON)
208
+ if self._queue.qsize() >= self._max_queue:
209
+ raise QueueFull(QUEUE_FULL_REASON)
210
+
211
+ def _enqueue(self, text: str, client_id: str, kind: str) -> tuple[str, int]:
212
+ wish_id = f"w_{self._wish_counter:06d}"
213
+ self._wish_counter += 1
214
+ item = _Item(
215
+ wish_id=wish_id, text=text, client_id=client_id,
216
+ submitted_at=time.time(), kind=kind,
217
+ )
218
+ self._pending[client_id] = wish_id
219
+ self._queue.put_nowait(item)
220
+ return wish_id, self._queue.qsize() + (1 if self._current else 0)
221
+
222
+ async def submit(self, text: Any, client_id: Any) -> tuple[str, int]:
223
+ """Enqueue a petition. Returns (wish_id, position). Position counts the
224
+ petition itself plus everyone ahead of it (including the one being
225
+ granted), so an idle empty queue yields position 1.
226
+
227
+ Raises AlreadyPending / QueueFull with readable `.reason`.
228
+ """
229
+ text = ("" if text is None else str(text)).strip()[:_SUBMIT_TEXT_CAP]
230
+ client_id = str(client_id)
231
+ self._admit(client_id)
232
+ wish_id, position = self._enqueue(text, client_id, "wish")
233
+ await self._emit_queue()
234
+ return wish_id, position
235
+
236
+ async def submit_fix(self, client_id: Any) -> tuple[str, int]:
237
+ """Enqueue a City Engineer job. The static-assignment gate runs HERE:
238
+ when the worst demand ratio is below the gate there is nothing to fix
239
+ and TrafficSmooth is raised (the server turns it into a 409). Rate
240
+ limits and cooldowns live in the server."""
241
+ client_id = str(client_id)
242
+ self._admit(client_id)
243
+ assignment = traffic.assign(self._city(), time.time())
244
+ if assignment.max_ratio < C.FIX_GATE_RATIO:
245
+ raise TrafficSmooth(TRAFFIC_SMOOTH_REASON)
246
+ wish_id, position = self._enqueue(FIX_JOB_TEXT, client_id, "fix")
247
+ await self._emit_queue()
248
+ return wish_id, position
249
+
250
+ # --------------------------------------------------------------- lifecycle
251
+
252
+ async def start(self) -> None:
253
+ if self._task is None or self._task.done():
254
+ self._task = asyncio.get_running_loop().create_task(
255
+ self._run(), name="nemocity-queue-worker"
256
+ )
257
+
258
+ async def stop(self) -> None:
259
+ if self._task is not None:
260
+ self._task.cancel()
261
+ with contextlib.suppress(asyncio.CancelledError):
262
+ await self._task
263
+ self._task = None
264
+
265
+ @property
266
+ def is_running(self) -> bool:
267
+ return self._task is not None and not self._task.done()
268
+
269
+ @property
270
+ def queue_length(self) -> int:
271
+ return self._queue.qsize()
272
+
273
+ @property
274
+ def current(self) -> Optional[dict]:
275
+ if self._current is None:
276
+ return None
277
+ return {
278
+ "wish_id": self._current.wish_id,
279
+ "text_preview": self._current.text[:TEXT_PREVIEW_LEN],
280
+ }
281
+
282
+ def snapshot(self) -> dict:
283
+ """The server's QueueLike view: waiting count + the job being granted."""
284
+ return {"length": self._queue.qsize(), "current": self.current}
285
+
286
+ # --------------------------------------------------------------- worker
287
+
288
+ async def _run(self) -> None:
289
+ while True:
290
+ item = await self._queue.get()
291
+ self._current = item
292
+ try:
293
+ await self._process(item)
294
+ except asyncio.CancelledError:
295
+ raise
296
+ except Exception:
297
+ # one petition must NEVER kill the worker
298
+ log.exception("wish %s crashed; city hall moves on", item.wish_id)
299
+ await self._safe_emit(
300
+ {"type": "wish_rejected", "wish_id": item.wish_id, "reason": CRASH_REASON}
301
+ )
302
+ finally:
303
+ self._current = None
304
+ self._pending.pop(item.client_id, None)
305
+ self._queue.task_done()
306
+ await self._emit_queue()
307
+
308
+ async def _process(self, item: _Item) -> None:
309
+ if not self._browser_invoked:
310
+ await self.grant_item(item)
311
+ return
312
+
313
+ # Browser-invoked mode: announce the turn, then wait for the petitioner's
314
+ # browser to call invoke() (a gradio API request carrying their ZeroGPU
315
+ # quota). No invocation within the window -> city hall moves on.
316
+ token = uuid.uuid4().hex
317
+ loop = asyncio.get_running_loop()
318
+ started: asyncio.Future[bool] = loop.create_future()
319
+ done: asyncio.Future[bool] = loop.create_future()
320
+ self._awaiting = {
321
+ "wish_id": item.wish_id,
322
+ "token": token,
323
+ "client_id": item.client_id,
324
+ "item": item,
325
+ "started": started,
326
+ "done": done,
327
+ "begun": False,
328
+ }
329
+ try:
330
+ # SECURITY: the token is NOT broadcast. your_turn carries only the
331
+ # wish_id over the public SSE; the owner's browser fetches the secret
332
+ # token from GET /api/turn (cookie-authenticated, identity-bound).
333
+ turn_event = {
334
+ "type": "your_turn",
335
+ "wish_id": item.wish_id,
336
+ "window_s": self._invocation_window,
337
+ }
338
+ # Announce in thirds: re-emits cover SSE reconnects and slow tabs.
339
+ third = max(self._invocation_window / 3.0, 0.01)
340
+ invoked = False
341
+ for _ in range(3):
342
+ await self._safe_emit(turn_event)
343
+ try:
344
+ await asyncio.wait_for(asyncio.shield(started), third)
345
+ invoked = True
346
+ break
347
+ except asyncio.TimeoutError:
348
+ continue
349
+ if not invoked:
350
+ await self._safe_emit(
351
+ {"type": "wish_rejected", "wish_id": item.wish_id, "reason": WANDERED_REASON}
352
+ )
353
+ return
354
+ try:
355
+ await asyncio.wait_for(asyncio.shield(done), self._grant_deadline)
356
+ except asyncio.TimeoutError:
357
+ # The invoked grant is hung past any sane petition length; the
358
+ # queue must not stall behind it.
359
+ log.error("grant for %s exceeded deadline; moving on", item.wish_id)
360
+ await self._safe_emit(
361
+ {"type": "wish_rejected", "wish_id": item.wish_id, "reason": INTERRUPTED_REASON}
362
+ )
363
+ finally:
364
+ self._awaiting = None
365
+
366
+ def turn_token(self, wish_id: Any, client_id: Any) -> Optional[str]:
367
+ """Return the secret invocation token for the job at the counter — but
368
+ ONLY to its owner (identity-bound). Returns None when it isn't this
369
+ caller's turn."""
370
+ awaiting = self._awaiting
371
+ if (
372
+ awaiting is None
373
+ or awaiting["wish_id"] != str(wish_id)
374
+ or awaiting["client_id"] != str(client_id)
375
+ ):
376
+ return None
377
+ return awaiting["token"]
378
+
379
+ async def invoke(self, wish_id: Any, token: Any) -> dict:
380
+ """Run the grant for the job at the counter. Called from the petitioner's
381
+ gradio API request so the GPU work runs in THEIR quota context."""
382
+ awaiting = self._awaiting
383
+ if (
384
+ awaiting is None
385
+ or awaiting["wish_id"] != str(wish_id)
386
+ or awaiting["token"] != str(token)
387
+ ):
388
+ raise QueueError(NOT_YOUR_TURN_REASON)
389
+ if awaiting["begun"]:
390
+ raise QueueError(RITE_BEGUN_REASON)
391
+ awaiting["begun"] = True
392
+ if not awaiting["started"].done():
393
+ awaiting["started"].set_result(True)
394
+ try:
395
+ await self.grant_item(awaiting["item"])
396
+ return {"ok": True, "wish_id": str(wish_id)}
397
+ except Exception:
398
+ log.exception("invoked grant for %s crashed", wish_id)
399
+ await self._safe_emit(
400
+ {"type": "wish_rejected", "wish_id": str(wish_id), "reason": CRASH_REASON}
401
+ )
402
+ return {"ok": False, "wish_id": str(wish_id), "reason": CRASH_REASON}
403
+ finally:
404
+ if not awaiting["done"].done():
405
+ awaiting["done"].set_result(True)
406
+
407
+ # ----------------------------------------------------------------- grants
408
+
409
+ async def grant_item(self, item: _Item) -> None:
410
+ """The whole rite for one job; runs in the worker task (server mode) or
411
+ inside the petitioner's gradio request (browser-invoked mode)."""
412
+ if item.kind == "fix":
413
+ await self._grant_fix(item)
414
+ else:
415
+ await self._grant_wish(item)
416
+
417
+ def _wrap_emit(self, item: _Item):
418
+ """Planner-facing emit: inject this job's wish_id into any event that
419
+ lacks one (the plan event, thought tokens)."""
420
+
421
+ async def emit(event: dict) -> None:
422
+ if isinstance(event, dict) and "wish_id" not in event:
423
+ event = {**event, "wish_id": item.wish_id}
424
+ await self._safe_emit(event)
425
+
426
+ return emit
427
+
428
+ async def _apply_events(
429
+ self, item: _Item, city: CityState, events: list[dict], index: int
430
+ ) -> tuple[int, int, str]:
431
+ """world.apply each event; mirror into the live CityState; emit
432
+ tool_call + world_delta. Returns (next_index, landed, first_obs)."""
433
+ landed = 0
434
+ first_obs = ""
435
+ for call in events:
436
+ feature, observation = self._world.apply(item.wish_id, index, call)
437
+ index += 1
438
+ if not first_obs:
439
+ first_obs = observation
440
+ if feature is None:
441
+ log.warning("engine-composed event rejected: %s", observation)
442
+ continue
443
+ landed += 1
444
+ city.apply(feature)
445
+ await self._safe_emit(
446
+ {
447
+ "type": "tool_call",
448
+ "wish_id": item.wish_id,
449
+ "call_index": index - 1,
450
+ "tool": feature.tool,
451
+ "args": dict(feature.args),
452
+ }
453
+ )
454
+ await self._safe_emit({"type": "world_delta", "feature": feature.to_dict()})
455
+ return index, landed, first_obs
456
+
457
+ async def _grant_wish(self, item: _Item) -> None:
458
+ await self._safe_emit({"type": "wish_started", "wish_id": item.wish_id, "text": item.text})
459
+ await self._emit_queue()
460
+
461
+ verdict = await self._moderator.check(item.text)
462
+ if not verdict.allowed:
463
+ await self._safe_emit(
464
+ {
465
+ "type": "wish_rejected",
466
+ "wish_id": item.wish_id,
467
+ "reason": verdict.poetic_reason or "city hall declines this petition",
468
+ }
469
+ )
470
+ return
471
+
472
+ world_summary = self._world.summary()
473
+ city = self._city()
474
+ state = {"index": 0, "acts": 0, "landed": 0, "jobs_anchor": ""}
475
+
476
+ async def act(building_req: Any) -> str:
477
+ """ONE building entry {kind,name,near,floors,hue} -> engine places
478
+ it (plus an auto-routed connector) and the events land."""
479
+ if not isinstance(building_req, dict):
480
+ return "rejected: malformed building request"
481
+ if state["acts"] >= MAX_BUILDINGS_PER_WISH or state["index"] >= self._max_calls:
482
+ return HANDS_FULL_OBSERVATION
483
+ state["acts"] += 1
484
+ kind = tools.resolve_kind(building_req.get("kind"))
485
+ name = tools.sanitize_name(
486
+ building_req.get("name"),
487
+ default=tools.default_name(kind, item.text, item.wish_id),
488
+ )
489
+ events = placement.place(
490
+ city,
491
+ kind=kind,
492
+ name=name,
493
+ floors=_num(building_req.get("floors")),
494
+ hue=_num(building_req.get("hue")),
495
+ near=str(building_req.get("near") or ""),
496
+ wish_id=item.wish_id,
497
+ call_index=state["index"],
498
+ )
499
+ state["index"], landed, first_obs = await self._apply_events(
500
+ item, city, events, state["index"]
501
+ )
502
+ state["landed"] += landed
503
+ if landed and C.BUILDINGS[kind]["jobs"] > 0:
504
+ state["jobs_anchor"] = name
505
+ return first_obs or "rejected: nothing placed"
506
+
507
+ trace = await self._planner.grant(item.text, world_summary, act, self._wrap_emit(item))
508
+
509
+ # If the petition built NOTHING and the planner never actually planned —
510
+ # every turn an error (GPU/model failure) — reject honestly and DON'T
511
+ # persist an empty trace that pollutes the shared ledger.
512
+ turns = _trace_get(trace, "turns") or []
513
+ all_errored = bool(turns) and all(
514
+ str((t or {}).get("observation") or "").startswith("error:") for t in turns
515
+ )
516
+ if state["landed"] == 0 and (not turns or all_errored):
517
+ await self._safe_emit(
518
+ {"type": "wish_rejected", "wish_id": item.wish_id, "reason": BUSY_HEAVENS_REASON}
519
+ )
520
+ return
521
+
522
+ if state["landed"]:
523
+ await self._apply_infill(item, city, state)
524
+
525
+ # SECURITY: the reading + epitaph are model-composed and persist to the
526
+ # public dataset + ledger. Re-moderate; redact on denial so no unvetted
527
+ # prose survives in the archive.
528
+ reading = _trace_get(trace, "reading")
529
+ if reading and not self._moderator.check_content(reading).allowed:
530
+ _trace_set(trace, "reading", "city hall read this petition in silence.")
531
+ epitaph = _trace_get(trace, "epitaph")
532
+ if not epitaph or not self._moderator.check_content(epitaph).allowed:
533
+ epitaph = _epitaph_from_reading(_trace_get(trace, "reading")) or FALLBACK_EPITAPH
534
+ _trace_set(trace, "epitaph", epitaph)
535
+ self._enrich_trace(trace, item)
536
+ await self._persist_trace(trace, item)
537
+
538
+ self._world.record_epitaph(epitaph)
539
+ await self._safe_emit(
540
+ {
541
+ "type": "wish_granted",
542
+ "wish_id": item.wish_id,
543
+ "epitaph": str(epitaph),
544
+ "epoch": self._world.epoch,
545
+ }
546
+ )
547
+
548
+ async def _apply_infill(self, item: _Item, city: CityState, state: dict) -> None:
549
+ """Demand-driven infill: jobs outpacing homes pulls ONE bonus home in
550
+ near the new jobs (max 1 per petition)."""
551
+ jobs, housing = city.jobs, city.housing_capacity
552
+ if jobs <= housing * C.INFILL_RATIO or state["index"] + 2 > self._max_calls:
553
+ return
554
+ kind = "apartments" if jobs - housing >= C.INFILL_APARTMENTS_DEFICIT else "house"
555
+ events = placement.place(
556
+ city,
557
+ kind=kind,
558
+ name=tools.default_name(kind, item.text, item.wish_id + ":infill"),
559
+ floors=None,
560
+ hue=None,
561
+ near=state["jobs_anchor"],
562
+ wish_id=item.wish_id,
563
+ call_index=state["index"],
564
+ )
565
+ events.append({"tool": "note", "args": {"text": INFILL_NOTE, "kind": "infill"}})
566
+ state["index"], landed, _ = await self._apply_events(
567
+ item, city, events, state["index"]
568
+ )
569
+ state["landed"] += landed
570
+
571
+ async def _grant_fix(self, item: _Item) -> None:
572
+ await self._safe_emit({"type": "wish_started", "wish_id": item.wish_id, "text": item.text})
573
+ await self._emit_queue()
574
+
575
+ city = self._city()
576
+ now_s = time.time()
577
+ stats, candidates = traffic.snapshot(city, now_s)
578
+ if stats["max_ratio"] < C.FIX_GATE_RATIO or not candidates:
579
+ await self._safe_emit(
580
+ {
581
+ "type": "wish_rejected",
582
+ "wish_id": item.wish_id,
583
+ "reason": TRAFFIC_SMOOTH_REASON,
584
+ }
585
+ )
586
+ return
587
+
588
+ by_id = {c["id"]: c for c in candidates}
589
+ best = candidates[0] # sorted best-predicted-first
590
+ state = {"index": 0, "landed": 0, "applied": ""}
591
+
592
+ def canned_diagnosis(cand: dict) -> str:
593
+ return (
594
+ f"{stats['worst']} carries {stats['max_ratio']}x capacity at rush — "
595
+ f"{cand['desc']} cuts the index "
596
+ f"{stats['traffic_index']}->{cand['predicted_index']}."
597
+ )
598
+
599
+ async def act_fix(choice: Any) -> str:
600
+ """The planner's pick {choice, diagnosis, blurb} -> ONE apply_fix
601
+ event. Invalid/missing choice falls back to the best candidate."""
602
+ if state["landed"]:
603
+ return "rejected: the fix is already applied"
604
+ choice = choice if isinstance(choice, dict) else {}
605
+ cand = by_id.get(str(choice.get("choice") or "").strip().upper()) or best
606
+ # model-composed diagnosis goes on the shared world: wordlist-gate it
607
+ raw = str(choice.get("diagnosis") or "").strip()
608
+ diagnosis = raw[:200] if raw and self._moderator.check_content(raw).allowed else ""
609
+ if not diagnosis:
610
+ diagnosis = canned_diagnosis(cand)
611
+ call = {
612
+ "tool": "apply_fix",
613
+ "args": {
614
+ "action": cand["action"],
615
+ "cells": cand["cells"],
616
+ "klass": cand["klass"],
617
+ "name": cand["name"],
618
+ "diagnosis": diagnosis,
619
+ "metrics_before": {
620
+ "traffic_index": stats["traffic_index"],
621
+ "worst": stats["worst"] or "",
622
+ },
623
+ "metrics_predicted": {"traffic_index": cand["predicted_index"]},
624
+ },
625
+ }
626
+ state["index"], landed, obs = await self._apply_events(
627
+ item, city, [call], state["index"]
628
+ )
629
+ state["landed"] += landed
630
+ if landed:
631
+ state["applied"] = cand["id"]
632
+ return obs
633
+
634
+ public = [
635
+ {k: c[k] for k in ("id", "action", "desc", "predicted_index")}
636
+ for c in candidates
637
+ ]
638
+ trace: Any = None
639
+ planner_fix = getattr(self._planner, "fix", None)
640
+ if planner_fix is not None:
641
+ try:
642
+ trace = await planner_fix(stats, public, act_fix, self._wrap_emit(item))
643
+ except Exception:
644
+ log.exception("planner.fix crashed for %s; engine applies best", item.wish_id)
645
+ if state["landed"] == 0:
646
+ # parse failure / no planner: the engine applies the best predicted
647
+ # candidate with the canned diagnosis — a fix ALWAYS lands.
648
+ await act_fix({"choice": best["id"]})
649
+
650
+ epitaph = _trace_get(trace, "epitaph")
651
+ if not epitaph or not self._moderator.check_content(epitaph).allowed:
652
+ applied = by_id.get(state["applied"]) or best
653
+ epitaph = f"The City Engineer opened {applied['name']}."
654
+ if not isinstance(trace, dict):
655
+ trace = {"reading": "", "turns": []}
656
+ trace["epitaph"] = str(epitaph)
657
+ trace.setdefault("kind", "fix")
658
+ trace.setdefault("stats", stats)
659
+ self._enrich_trace(trace, item)
660
+ await self._persist_trace(trace, item)
661
+
662
+ self._world.record_epitaph(epitaph)
663
+ await self._safe_emit(
664
+ {
665
+ "type": "wish_granted",
666
+ "wish_id": item.wish_id,
667
+ "epitaph": str(epitaph),
668
+ "epoch": self._world.epoch,
669
+ }
670
+ )
671
+
672
+ # --------------------------------------------------------------- helpers
673
+
674
+ async def _persist_trace(self, trace: Any, item: _Item) -> None:
675
+ if self._persist is None or trace is None:
676
+ return
677
+ try:
678
+ result = self._persist(trace)
679
+ if inspect.isawaitable(result):
680
+ await result
681
+ except asyncio.CancelledError:
682
+ raise
683
+ except Exception:
684
+ log.exception("persist failed for %s (grant stands)", item.wish_id)
685
+
686
+ def _enrich_trace(self, trace: Any, item: _Item) -> None:
687
+ """Fill trace fields the planner cannot know (wish_id, moderation, ...)."""
688
+ fields = {
689
+ "wish_id": item.wish_id,
690
+ "text": item.text,
691
+ "submitted_at": item.submitted_at,
692
+ }
693
+ try:
694
+ if isinstance(trace, dict):
695
+ for key, value in fields.items():
696
+ trace.setdefault(key, value)
697
+ trace.setdefault("moderation", {"allowed": True, "category": None})
698
+ elif trace is not None:
699
+ for key, value in fields.items():
700
+ if not getattr(trace, key, None):
701
+ setattr(trace, key, value)
702
+ if getattr(trace, "moderation", None) is None:
703
+ setattr(trace, "moderation", {"allowed": True, "category": None})
704
+ except Exception: # frozen dataclass etc. — enrichment is best-effort
705
+ log.debug("trace enrichment skipped for %s", item.wish_id, exc_info=True)
706
+
707
+ async def _emit_queue(self) -> None:
708
+ await self._safe_emit(
709
+ {"type": "queue", "length": self._queue.qsize(), "current": self.current}
710
+ )
711
+
712
+ async def _safe_emit(self, event: dict) -> None:
713
+ """Broadcast one SSE event; emit failures must never break a grant."""
714
+ try:
715
+ await self._emit(event)
716
+ except asyncio.CancelledError:
717
+ raise
718
+ except Exception:
719
+ log.warning("emit failed for event type %s", event.get("type"), exc_info=True)
engine/summary.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compact city description for the LLM prompt (<600 chars, always).
2
+
3
+ Covers: NEMOCITY, population, building counts by kind, named landmarks and
4
+ streets, traffic index + worst street, growth ring. Duck-types over World
5
+ (no engine.world import — no cycles).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any, Optional
12
+
13
+ from . import constants as C
14
+ from . import traffic
15
+ from .city import CityState
16
+
17
+ MAX_LEN = 599 # contract: strictly under 600 chars
18
+
19
+ _MAX_TALLY_KINDS = 6
20
+ _MAX_LANDMARKS = 4
21
+ _MAX_STREETS = 5
22
+
23
+ _A_OR_AN_VOWEL = "aeiou"
24
+
25
+
26
+ def _count_phrase(noun: str, n: int) -> str:
27
+ noun = noun.replace("_", " ")
28
+ if n == 1:
29
+ article = "an" if noun[:1] in _A_OR_AN_VOWEL else "a"
30
+ return f"{article} {noun}"
31
+ plural = noun + ("es" if noun.endswith(("s", "x", "z", "ch", "sh")) else "s")
32
+ return f"{n} {plural}"
33
+
34
+
35
+ def summarize(world: Any, now_s: Optional[float] = None) -> str:
36
+ if now_s is None:
37
+ now_s = time.time()
38
+ city = CityState.from_events(getattr(world, "features", ()) or ())
39
+
40
+ parts: list[str] = [
41
+ f"NEMOCITY, pop {city.population(now_s)}, epoch {getattr(world, 'epoch', 0)}"
42
+ ]
43
+
44
+ counts = city.counts_by_kind()
45
+ if counts:
46
+ bits = [
47
+ _count_phrase(kind, counts[kind])
48
+ for kind in list(counts)[:_MAX_TALLY_KINDS]
49
+ ]
50
+ if len(counts) > _MAX_TALLY_KINDS:
51
+ bits.append("more")
52
+ parts.append("buildings: " + ", ".join(bits))
53
+ parts.append(f"jobs {city.jobs}, homes for {city.housing_capacity}")
54
+
55
+ landmarks = [
56
+ b.name for b in reversed(city.buildings)
57
+ if b.name and C.BUILDINGS[b.kind]["attract"] >= 3
58
+ ][:_MAX_LANDMARKS]
59
+ if landmarks:
60
+ parts.append("landmarks: " + ", ".join(landmarks))
61
+
62
+ streets = list(city.street_cells)
63
+ if streets:
64
+ tail = f" +{len(streets) - _MAX_STREETS}" if len(streets) > _MAX_STREETS else ""
65
+ parts.append("streets: " + ", ".join(streets[:_MAX_STREETS]) + tail)
66
+
67
+ assignment = traffic.assign(city, now_s)
68
+ line = f"traffic index {assignment.traffic_index}"
69
+ if assignment.max_ratio > 0:
70
+ worst = assignment.top_cells(1)
71
+ if worst:
72
+ line += f" (worst: {assignment.net.name(worst[0])} {assignment.ratio(worst[0]):.1f}x)"
73
+ parts.append(line)
74
+ parts.append(f"growth ring {city.growth_radius}")
75
+
76
+ text = " | ".join(parts)
77
+ if len(text) > MAX_LEN:
78
+ text = text[: MAX_LEN - 3] + "..."
79
+ return text
engine/tools.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY event-type specs — validation/clamping for the 4 city event types.
2
+
3
+ Semantics (godseed lineage):
4
+ * out-of-range NUMBERS are CLAMPED, never rejected;
5
+ * unknown kinds are repaired via SYNONYMS (anything unknown -> house);
6
+ * malformed shapes (bad cells, missing geometry) are rejected with a terse
7
+ observation string — but the only module that COMPOSES these events is the
8
+ engine itself (placement/traffic), so rejections mean engine bugs, not model
9
+ noise. The LLM never outputs coordinates.
10
+
11
+ Pure stdlib. world.py validates through `validate_call`; queue_worker uses
12
+ `resolve_kind` / `sanitize_name` / `default_name` on model-supplied fields.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import zlib
19
+ from typing import Any, Optional
20
+
21
+ from . import constants as C
22
+ from .moderation import Moderator
23
+
24
+ _moderator = Moderator() # wordlist layers only; no judge
25
+
26
+ TOOL_NAMES: tuple[str, ...] = ("place_building", "lay_road", "apply_fix", "note")
27
+
28
+ FIX_ACTIONS = ("new_road", "upgrade_avenue")
29
+ NOTE_KINDS = ("milestone", "infill", "fix")
30
+
31
+ _FALLBACK_KIND = "house"
32
+
33
+
34
+ # ----------------------------------------------------------------- kind resolver
35
+
36
+ def resolve_kind(value: Any) -> str:
37
+ """Map model-supplied kind text to a BUILDINGS key. Never fails: synonyms
38
+ repair, substring rescues, anything else becomes a humble house."""
39
+ if not isinstance(value, str):
40
+ return _FALLBACK_KIND
41
+ v = re.sub(r"[\s\-]+", "_", value.strip().lower()).strip("_")
42
+ if v in C.BUILDINGS:
43
+ return v
44
+ if v in C.SYNONYMS:
45
+ return C.SYNONYMS[v]
46
+ if v.endswith("s") and v[:-1] in C.BUILDINGS:
47
+ return v[:-1]
48
+ for token, kind in C.SYNONYMS.items():
49
+ if token in v:
50
+ return kind
51
+ for kind in C.BUILDINGS:
52
+ if kind in v:
53
+ return kind
54
+ return _FALLBACK_KIND
55
+
56
+
57
+ # ------------------------------------------------------------------ name helpers
58
+
59
+ _STOPWORDS = frozenset((
60
+ "a", "an", "the", "and", "or", "of", "for", "with", "near", "by", "at",
61
+ "in", "on", "to", "please", "build", "make", "add", "put", "want", "like",
62
+ "new", "some", "my", "our", "me", "us", "city", "town",
63
+ ))
64
+
65
+
66
+ def sanitize_name(value: Any, default: str = "") -> str:
67
+ """Printable, wordlist-clean, <=24 chars; falls back to `default`."""
68
+ if not isinstance(value, str):
69
+ return default
70
+ name = "".join(ch for ch in value if ch.isprintable())
71
+ name = re.sub(r"\s+", " ", name).strip()[: C.NAME_MAX_LEN].strip()
72
+ if not name or not _moderator.check_content(name).allowed:
73
+ return default
74
+ return name
75
+
76
+
77
+ def default_name(kind: str, petition_text: str, wish_id: str) -> str:
78
+ """Deterministic pleasant default derived from petition words."""
79
+ words = [
80
+ w for w in re.findall(r"[A-Za-z]+", str(petition_text or ""))
81
+ if len(w) > 2 and w.lower() not in _STOPWORDS
82
+ and w.lower() not in C.BUILDINGS and w.lower() not in C.SYNONYMS
83
+ ]
84
+ label = kind.replace("_", " ").title()
85
+ if words:
86
+ pick = words[zlib.crc32(f"{wish_id}:{kind}".encode()) % len(words)].title()
87
+ candidate = f"{pick} {label}"[: C.NAME_MAX_LEN].strip()
88
+ if _moderator.check_content(candidate).allowed:
89
+ return candidate
90
+ return f"The {label}"[: C.NAME_MAX_LEN]
91
+
92
+
93
+ def street_name_for(key: str) -> str:
94
+ """Curated street name from a deterministic key (e.g. 'w_000005:1:road')."""
95
+ return C.STREET_NAMES[zlib.crc32(str(key).encode()) % len(C.STREET_NAMES)]
96
+
97
+
98
+ # ------------------------------------------------------------------- primitives
99
+
100
+ def _err(msg: str) -> tuple[None, str]:
101
+ return None, f"rejected: {msg}"
102
+
103
+
104
+ def _int_in(value: Any, lo: int, hi: int) -> Optional[int]:
105
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
106
+ try:
107
+ value = float(str(value).strip())
108
+ except (ValueError, TypeError):
109
+ return None
110
+ return int(round(min(max(float(value), lo), hi)))
111
+
112
+
113
+ def _coerce_cell(cell: Any) -> Optional[list[int]]:
114
+ if not isinstance(cell, (list, tuple)) or len(cell) < 2:
115
+ return None
116
+ cx = _int_in(cell[0], C.COORD_MIN, C.COORD_MAX)
117
+ cz = _int_in(cell[1], C.COORD_MIN, C.COORD_MAX)
118
+ if cx is None or cz is None:
119
+ return None
120
+ return [cx, cz]
121
+
122
+
123
+ def _coerce_cells(raw: Any, max_cells: int = 256) -> Optional[list[list[int]]]:
124
+ if not isinstance(raw, (list, tuple)) or not raw:
125
+ return None
126
+ out: list[list[int]] = []
127
+ seen: set[tuple[int, int]] = set()
128
+ for c in list(raw)[:max_cells]:
129
+ cell = _coerce_cell(c)
130
+ if cell is None:
131
+ return None
132
+ if (cell[0], cell[1]) in seen:
133
+ continue
134
+ seen.add((cell[0], cell[1]))
135
+ out.append(cell)
136
+ return out
137
+
138
+
139
+ def _text(value: Any, max_len: int) -> str:
140
+ s = "".join(ch for ch in str(value or "") if ch.isprintable())
141
+ return re.sub(r"\s+", " ", s).strip()[:max_len]
142
+
143
+
144
+ # ------------------------------------------------------------------- validators
145
+
146
+ def _validate_place_building(args: dict):
147
+ kind = resolve_kind(args.get("kind"))
148
+ spec = C.BUILDINGS[kind]
149
+ cx = _int_in(args.get("cx"), C.COORD_MIN, C.COORD_MAX - spec["w"] + 1)
150
+ cz = _int_in(args.get("cz"), C.COORD_MIN, C.COORD_MAX - spec["d"] + 1)
151
+ if cx is None or cz is None or args.get("cx") is None or args.get("cz") is None:
152
+ return _err("place_building needs cx, cz")
153
+ lo, hi = spec["floors"]
154
+ floors = _int_in(args.get("floors"), lo, hi)
155
+ if floors is None:
156
+ floors = lo
157
+ hue = _int_in(args.get("hue"), 0, 360)
158
+ if hue is None:
159
+ hue = 35
160
+ variant = _int_in(args.get("variant"), 0, 7)
161
+ if variant is None:
162
+ variant = 0
163
+ name = sanitize_name(args.get("name"), default=f"The {kind.replace('_', ' ').title()}")
164
+ return {
165
+ "kind": kind, "name": name, "cx": cx, "cz": cz,
166
+ "w": spec["w"], "d": spec["d"], "floors": floors,
167
+ "hue": hue, "variant": variant,
168
+ }, None
169
+
170
+
171
+ def _validate_lay_road(args: dict):
172
+ cells = _coerce_cells(args.get("cells"))
173
+ if cells is None:
174
+ return _err("lay_road needs cells [[cx,cz],...]")
175
+ klass = str(args.get("klass") or "street").strip().lower()
176
+ if klass not in C.ROAD_CLASSES:
177
+ klass = "street"
178
+ name = sanitize_name(args.get("name"), default="")
179
+ out = {"cells": cells, "klass": klass}
180
+ if name:
181
+ out["name"] = name
182
+ return out, None
183
+
184
+
185
+ def _coerce_metrics(raw: Any) -> dict:
186
+ out: dict[str, Any] = {}
187
+ if isinstance(raw, dict):
188
+ for k, v in list(raw.items())[:6]:
189
+ key = _text(k, 24)
190
+ if not key:
191
+ continue
192
+ if isinstance(v, bool):
193
+ continue
194
+ if isinstance(v, (int, float)):
195
+ num = round(float(v), 2)
196
+ out[key] = int(num) if num.is_integer() else num
197
+ elif isinstance(v, str):
198
+ out[key] = _text(v, 48)
199
+ return out
200
+
201
+
202
+ def _validate_apply_fix(args: dict):
203
+ action = str(args.get("action") or "").strip().lower()
204
+ if action not in FIX_ACTIONS:
205
+ return _err(f"unknown fix action '{_text(args.get('action'), 24)}'")
206
+ cells = _coerce_cells(args.get("cells"))
207
+ if cells is None:
208
+ return _err("apply_fix needs cells [[cx,cz],...]")
209
+ klass = str(args.get("klass") or "avenue").strip().lower()
210
+ if klass not in C.ROAD_CLASSES:
211
+ klass = "avenue"
212
+ name = sanitize_name(args.get("name"), default=street_name_for(repr(cells[0])))
213
+ diagnosis = _text(args.get("diagnosis"), 200)
214
+ return {
215
+ "action": action, "cells": cells, "klass": klass, "name": name,
216
+ "diagnosis": diagnosis,
217
+ "metrics_before": _coerce_metrics(args.get("metrics_before")),
218
+ "metrics_predicted": _coerce_metrics(args.get("metrics_predicted")),
219
+ }, None
220
+
221
+
222
+ def _validate_note(args: dict):
223
+ text = _text(args.get("text"), 140)
224
+ if not text:
225
+ return _err("note needs text")
226
+ if not _moderator.check_content(text).allowed:
227
+ return _err("those words may not be posted on the city ledger")
228
+ kind = str(args.get("kind") or "milestone").strip().lower()
229
+ if kind not in NOTE_KINDS:
230
+ kind = "milestone"
231
+ return {"text": text, "kind": kind}, None
232
+
233
+
234
+ _VALIDATORS = {
235
+ "place_building": _validate_place_building,
236
+ "lay_road": _validate_lay_road,
237
+ "apply_fix": _validate_apply_fix,
238
+ "note": _validate_note,
239
+ }
240
+
241
+
242
+ def validate_call(tool: Any, args: Any) -> tuple[Optional[dict], Optional[str]]:
243
+ """Validate one event. Returns (canonical_args, None) or (None, rejection)."""
244
+ if not isinstance(tool, str) or tool not in _VALIDATORS:
245
+ return _err(f"unknown tool '{_text(tool, 32)}'")
246
+ if args is None:
247
+ args = {}
248
+ if not isinstance(args, dict):
249
+ return _err("args must be an object")
250
+ return _VALIDATORS[tool](args)
251
+
252
+
253
+ def as_dict() -> dict:
254
+ """Plain-JSON view of the event surface (prompt builders may render this)."""
255
+ return {
256
+ "place_building": {
257
+ "kind": {"type": "enum", "values": list(C.BUILDINGS)},
258
+ "name": {"type": "string", "max_len": C.NAME_MAX_LEN},
259
+ "cx": {"type": "int", "min": C.COORD_MIN, "max": C.COORD_MAX},
260
+ "cz": {"type": "int", "min": C.COORD_MIN, "max": C.COORD_MAX},
261
+ "floors": {"type": "int", "note": "clamped to the kind's range"},
262
+ "hue": {"type": "int", "min": 0, "max": 360},
263
+ },
264
+ "lay_road": {
265
+ "cells": {"type": "cells", "note": "[[cx,cz],...] engine-routed only"},
266
+ "klass": {"type": "enum", "values": list(C.ROAD_CLASSES)},
267
+ "name": {"type": "string", "max_len": C.NAME_MAX_LEN},
268
+ },
269
+ "apply_fix": {
270
+ "action": {"type": "enum", "values": list(FIX_ACTIONS)},
271
+ "cells": {"type": "cells"},
272
+ "klass": {"type": "enum", "values": list(C.ROAD_CLASSES)},
273
+ "diagnosis": {"type": "string", "max_len": 200},
274
+ },
275
+ "note": {
276
+ "text": {"type": "string", "max_len": 140},
277
+ "kind": {"type": "enum", "values": list(NOTE_KINDS)},
278
+ },
279
+ }
engine/traffic.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY traffic — the Python static-assignment mirror.
2
+
3
+ Same trip generation + A* + demand semantics as web/city/traffic.js, from the
4
+ same event list and constants, so the ENGINE owns the facts the LLM sees (the
5
+ live car queueing client-side is presentation and may drift a car or two).
6
+
7
+ Determinism contract with the JS mirror:
8
+ * mulberry32 (bit-exact port of web/city/rng.js); ONE draw per commuter,
9
+ seeded `building.seed ^ i`;
10
+ * trip order = building event order, commuter index ascending;
11
+ * destination roulette over completed jobs/attract buildings in event order,
12
+ weight = (jobs + 2*attract) / (1 + manhattanCentroidDist^1.5);
13
+ * door = adjacent road cell with smallest (cz, cx);
14
+ * incremental loading: each commuter routes by A* with
15
+ cost(cell) = (1/speed) * (1 + 2*demandRatio(cell)) over demand loaded so
16
+ far, then adds DEMAND_PER_COMMUTER to every cell of its path.
17
+
18
+ Stats are evaluated AT PEAK RUSH (rushFactor = 1.0). Pure stdlib.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import heapq
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Optional
27
+
28
+ from . import constants as C
29
+ from .city import WATER_CELLS, Building, Cell, CityState, in_bounds, neighbors4
30
+ from .tools import street_name_for
31
+
32
+ _MIN_CELL_COST = 1.0 / C.ROAD_CLASSES["avenue"]["speed"] # admissible A* heuristic
33
+
34
+
35
+ def mulberry32(seed: int):
36
+ """Bit-exact port of web/city/rng.js mulberry32 — keep byte-stable."""
37
+ a = seed & 0xFFFFFFFF
38
+
39
+ def rng() -> float:
40
+ nonlocal a
41
+ a = (a + 0x6D2B79F5) & 0xFFFFFFFF
42
+ t = a
43
+ t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF
44
+ t = (t ^ ((t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF)) & 0xFFFFFFFF)) & 0xFFFFFFFF
45
+ return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
46
+
47
+ return rng
48
+
49
+
50
+ # -------------------------------------------------------------------- road net
51
+
52
+ @dataclass
53
+ class RoadNet:
54
+ """Road cells + class/name, optionally with a candidate fix applied."""
55
+
56
+ cells: dict[Cell, tuple[str, str]] # cell -> (klass, name)
57
+
58
+ @classmethod
59
+ def from_city(cls, city: CityState, fix: Optional[dict] = None) -> "RoadNet":
60
+ cells = {cell: (rc.klass, rc.name) for cell, rc in city.roads.items()}
61
+ if fix:
62
+ if fix["action"] == "new_road":
63
+ for cx, cz in fix["cells"]:
64
+ cells[(cx, cz)] = (fix["klass"], fix["name"])
65
+ elif fix["action"] == "upgrade_avenue":
66
+ for cx, cz in fix["cells"]:
67
+ cell = (cx, cz)
68
+ if cell in cells:
69
+ cells[cell] = ("avenue", cells[cell][1])
70
+ return cls(cells=cells)
71
+
72
+ def capacity(self, cell: Cell) -> float:
73
+ return C.ROAD_CLASSES[self.cells[cell][0]]["capacity"]
74
+
75
+ def speed(self, cell: Cell) -> float:
76
+ return C.ROAD_CLASSES[self.cells[cell][0]]["speed"]
77
+
78
+ def name(self, cell: Cell) -> str:
79
+ return self.cells[cell][1]
80
+
81
+ def door(self, b: Building) -> Optional[Cell]:
82
+ adjacent = {
83
+ n for cell in b.cells for n in neighbors4(cell) if n in self.cells
84
+ }
85
+ if not adjacent:
86
+ return None
87
+ return min(adjacent, key=lambda c: (c[1], c[0]))
88
+
89
+
90
+ # -------------------------------------------------------------------- trip gen
91
+
92
+ def trip_table(city: CityState, now_s: float) -> list[tuple[Building, Building]]:
93
+ """Static OD pairs (gravity model, seeded roulette). Completed buildings
94
+ only on both ends; recomputed only when buildings change."""
95
+ completed = [b for b in city.buildings if b.progress(now_s) >= 1.0]
96
+ homes = [b for b in completed if C.BUILDINGS[b.kind]["residents"] > 0]
97
+ dests = [
98
+ b for b in completed
99
+ if C.BUILDINGS[b.kind]["jobs"] + C.BUILDINGS[b.kind]["attract"] > 0
100
+ ]
101
+ if not dests:
102
+ return []
103
+ trips: list[tuple[Building, Building]] = []
104
+ for home in homes:
105
+ hx, hz = home.centroid
106
+ weights: list[float] = []
107
+ for dest in dests:
108
+ dx, dz = dest.centroid
109
+ dist = abs(dx - hx) + abs(dz - hz)
110
+ spec = C.BUILDINGS[dest.kind]
111
+ weights.append(
112
+ (spec["jobs"] + C.TRIP_ATTRACT_FACTOR * spec["attract"])
113
+ / (1 + dist ** C.TRIP_DIST_EXP)
114
+ )
115
+ total = sum(weights)
116
+ if total <= 0:
117
+ continue
118
+ for i in range(C.BUILDINGS[home.kind]["residents"]):
119
+ r = mulberry32((home.seed ^ i) & 0xFFFFFFFF)() * total
120
+ acc = 0.0
121
+ chosen = dests[-1]
122
+ for dest, weight in zip(dests, weights):
123
+ acc += weight
124
+ if r < acc:
125
+ chosen = dest
126
+ break
127
+ trips.append((home, chosen))
128
+ return trips
129
+
130
+
131
+ # ------------------------------------------------------------------ assignment
132
+
133
+ @dataclass
134
+ class Assignment:
135
+ net: RoadNet
136
+ demand: dict[Cell, float] = field(default_factory=dict)
137
+ paths: list[tuple[Cell, Cell, tuple[Cell, ...]]] = field(default_factory=list)
138
+ traffic_index: int = 0
139
+
140
+ def ratio(self, cell: Cell) -> float:
141
+ return self.demand.get(cell, 0.0) / self.net.capacity(cell)
142
+
143
+ @property
144
+ def max_ratio(self) -> float:
145
+ if not self.net.cells:
146
+ return 0.0
147
+ return max((self.ratio(c) for c in self.net.cells), default=0.0)
148
+
149
+ def top_cells(self, n: int) -> list[Cell]:
150
+ return sorted(
151
+ self.net.cells,
152
+ key=lambda c: (-self.ratio(c), c[1], c[0]),
153
+ )[:n]
154
+
155
+
156
+ def _astar(net: RoadNet, demand: dict[Cell, float], start: Cell, goal: Cell
157
+ ) -> Optional[list[Cell]]:
158
+ if start == goal:
159
+ return [start]
160
+
161
+ def cell_cost(cell: Cell) -> float:
162
+ ratio = demand.get(cell, 0.0) / net.capacity(cell)
163
+ return (1.0 / net.speed(cell)) * (1 + C.CONGESTION_COST_FACTOR * ratio)
164
+
165
+ def h(cell: Cell) -> float:
166
+ return (abs(cell[0] - goal[0]) + abs(cell[1] - goal[1])) * _MIN_CELL_COST
167
+
168
+ g: dict[Cell, float] = {start: 0.0}
169
+ prev: dict[Cell, Cell] = {}
170
+ heap: list[tuple[float, int, int, Cell]] = [(h(start), start[1], start[0], start)]
171
+ closed: set[Cell] = set()
172
+ while heap:
173
+ _, _, _, cell = heapq.heappop(heap)
174
+ if cell in closed:
175
+ continue
176
+ if cell == goal:
177
+ path = [cell]
178
+ while path[-1] != start:
179
+ path.append(prev[path[-1]])
180
+ path.reverse()
181
+ return path
182
+ closed.add(cell)
183
+ for n in neighbors4(cell):
184
+ if n not in net.cells or n in closed:
185
+ continue
186
+ cand = g[cell] + cell_cost(n)
187
+ if cand < g.get(n, float("inf")):
188
+ g[n] = cand
189
+ prev[n] = cell
190
+ heapq.heappush(heap, (cand + h(n), n[1], n[0], n))
191
+ return None
192
+
193
+
194
+ def assign(city: CityState, now_s: float, fix: Optional[dict] = None) -> Assignment:
195
+ """Incremental static assignment at peak rush. <2s for a genesis city."""
196
+ net = RoadNet.from_city(city, fix)
197
+ out = Assignment(net=net)
198
+ for home, dest in trip_table(city, now_s):
199
+ od, dd = net.door(home), net.door(dest)
200
+ if od is None or dd is None:
201
+ continue
202
+ path = _astar(net, out.demand, od, dd)
203
+ if path is None:
204
+ continue
205
+ for cell in path:
206
+ out.demand[cell] = out.demand.get(cell, 0.0) + C.DEMAND_PER_COMMUTER
207
+ out.paths.append((od, dd, tuple(path)))
208
+ ratios = sorted((out.ratio(c) for c in net.cells), reverse=True)
209
+ top = ratios[: C.TRAFFIC_TOP_CELLS]
210
+ if top:
211
+ # pad: a city with fewer road cells than the window still reads honestly
212
+ mean = sum(top) / C.TRAFFIC_TOP_CELLS
213
+ out.traffic_index = int(round(100 * mean))
214
+ return out
215
+
216
+
217
+ # ----------------------------------------------------------------------- stats
218
+
219
+ def _intersection_name(net: RoadNet, cell: Cell) -> str:
220
+ names = [net.name(cell)]
221
+ for n in neighbors4(cell):
222
+ if n in net.cells and net.name(n) not in names:
223
+ names.append(net.name(n))
224
+ return " & ".join(names[:2])
225
+
226
+
227
+ def stats(city: CityState, assignment: Assignment) -> dict:
228
+ net = assignment.net
229
+ top = [
230
+ c for c in assignment.top_cells(5) if assignment.ratio(c) > 0
231
+ ]
232
+ worst = top[0] if top else None
233
+ intersections = [
234
+ c for c in net.cells
235
+ if sum(1 for n in neighbors4(c) if n in net.cells) >= 3
236
+ ]
237
+ worst_x = max(
238
+ intersections, key=lambda c: (assignment.ratio(c), -c[1], -c[0]), default=None
239
+ )
240
+ return {
241
+ "traffic_index": assignment.traffic_index,
242
+ "max_ratio": round(assignment.max_ratio, 2),
243
+ "worst": net.name(worst) if worst else None,
244
+ "worst_intersection": _intersection_name(net, worst_x) if worst_x else None,
245
+ "top": [
246
+ {
247
+ "street": net.name(c),
248
+ "cell": [c[0], c[1]],
249
+ "ratio": round(assignment.ratio(c), 2),
250
+ "klass": net.cells[c][0],
251
+ }
252
+ for c in top
253
+ ],
254
+ }
255
+
256
+
257
+ # ------------------------------------------------------------------ candidates
258
+
259
+ def _bypass_candidate(
260
+ city: CityState, assignment: Assignment, now_s: float
261
+ ) -> Optional[dict]:
262
+ """Engine-routed relief road around the worst cell: Dijkstra over the grid,
263
+ new cells cost extra, water at 4x (a fix may deliberately bridge), jammed
264
+ cells forbidden."""
265
+ net = assignment.net
266
+ jammed = {
267
+ c for c in net.cells if assignment.ratio(c) >= C.FIX_AVOID_RATIO
268
+ }
269
+ if not jammed:
270
+ return None
271
+ worst = min(jammed, key=lambda c: (-assignment.ratio(c), c[1], c[0]))
272
+ od_counts: dict[tuple[Cell, Cell], int] = {}
273
+ for od, dd, path in assignment.paths:
274
+ if worst in path:
275
+ od_counts[(od, dd)] = od_counts.get((od, dd), 0) + 1
276
+ if not od_counts:
277
+ return None
278
+ (start, goal), _ = min(
279
+ od_counts.items(), key=lambda kv: (-kv[1], kv[0][0][1], kv[0][0][0])
280
+ )
281
+
282
+ def cost(cell: Cell) -> Optional[float]:
283
+ if cell in jammed and cell not in (start, goal):
284
+ return None
285
+ if cell in net.cells:
286
+ return 1.0 / net.speed(cell)
287
+ if cell in city.building_cells:
288
+ return None
289
+ if cell in WATER_CELLS:
290
+ return C.FIX_NEW_CELL_COST * C.FIX_WATER_COST_MULT
291
+ return C.FIX_NEW_CELL_COST
292
+
293
+ dist: dict[Cell, float] = {start: 0.0}
294
+ prev: dict[Cell, Cell] = {}
295
+ heap: list[tuple[float, int, int, Cell]] = [(0.0, start[1], start[0], start)]
296
+ closed: set[Cell] = set()
297
+ while heap:
298
+ d, _, _, cell = heapq.heappop(heap)
299
+ if cell in closed:
300
+ continue
301
+ if cell == goal:
302
+ break
303
+ closed.add(cell)
304
+ for n in neighbors4(cell):
305
+ if n in closed:
306
+ continue
307
+ c = cost(n)
308
+ if c is None:
309
+ continue
310
+ cand = d + c
311
+ if cand < dist.get(n, float("inf")):
312
+ dist[n] = cand
313
+ prev[n] = cell
314
+ heapq.heappush(heap, (cand, n[1], n[0], n))
315
+ if goal not in prev and goal != start:
316
+ return None
317
+ path = [goal]
318
+ while path[-1] != start:
319
+ path.append(prev[path[-1]])
320
+ path.reverse()
321
+ new_cells = [c for c in path if c not in net.cells]
322
+ if not new_cells or len(new_cells) > C.FIX_MAX_NEW_CELLS:
323
+ return None
324
+ name = street_name_for(f"fix:{city.road_version}:bypass")
325
+ bridges = sum(1 for c in new_cells if c in WATER_CELLS)
326
+ fix = {
327
+ "action": "new_road",
328
+ "cells": [[c[0], c[1]] for c in new_cells],
329
+ "klass": "avenue",
330
+ "name": name,
331
+ }
332
+ desc = f"new avenue {name}, {len(new_cells)} cells"
333
+ if bridges:
334
+ desc += f" incl. a {bridges}-cell second bridge"
335
+ fix["desc"] = desc
336
+ fix["predicted_index"] = assign(city, now_s, fix).traffic_index
337
+ return fix
338
+
339
+
340
+ def _upgrade_candidates(
341
+ city: CityState, assignment: Assignment, now_s: float, limit: int = 2
342
+ ) -> list[dict]:
343
+ net = assignment.net
344
+ names: list[str] = []
345
+ for cell in assignment.top_cells(C.TRAFFIC_TOP_CELLS):
346
+ klass, name = net.cells[cell]
347
+ if klass == "street" and assignment.ratio(cell) > 0 and name not in names:
348
+ names.append(name)
349
+ if len(names) >= limit:
350
+ break
351
+ out = []
352
+ for name in names:
353
+ cells = [
354
+ [c[0], c[1]]
355
+ for c, (klass, n) in net.cells.items()
356
+ if n == name and klass == "street"
357
+ ]
358
+ if not cells:
359
+ continue
360
+ cells.sort(key=lambda c: (c[1], c[0]))
361
+ fix = {
362
+ "action": "upgrade_avenue",
363
+ "cells": cells,
364
+ "klass": "avenue",
365
+ "name": name,
366
+ "desc": f"upgrade {name} to a 6-lane avenue ({len(cells)} cells)",
367
+ }
368
+ fix["predicted_index"] = assign(city, now_s, fix).traffic_index
369
+ out.append(fix)
370
+ return out
371
+
372
+
373
+ def candidates(city: CityState, assignment: Assignment, now_s: float) -> list[dict]:
374
+ """2-4 pre-validated fixes, best predicted first, ids F1..Fn."""
375
+ found: list[dict] = []
376
+ bypass = _bypass_candidate(city, assignment, now_s)
377
+ if bypass:
378
+ found.append(bypass)
379
+ found.extend(_upgrade_candidates(city, assignment, now_s))
380
+ found.sort(key=lambda f: (f["predicted_index"], f["action"], f["name"]))
381
+ for i, fix in enumerate(found[:4]):
382
+ fix["id"] = f"F{i + 1}"
383
+ return found[:4]
384
+
385
+
386
+ def snapshot(city: CityState, now_s: Optional[float] = None) -> tuple[dict, list[dict]]:
387
+ """(stats, candidates) for the fix mechanic — the engine owns the facts."""
388
+ if now_s is None:
389
+ now_s = time.time()
390
+ assignment = assign(city, now_s)
391
+ st = stats(city, assignment)
392
+ cands = candidates(city, assignment, now_s) if assignment.max_ratio > 0 else []
393
+ return st, cands
engine/world.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY world state — the deterministic engine owns ALL facts.
2
+
3
+ The world is an append-only ordered list of Features (events). City-at-any-
4
+ moment is a pure function of that list; the JS renderer derives all geometry
5
+ from it and engine/city.py derives the Python views.
6
+
7
+ The LLM never touches this module's internals: the engine composes event
8
+ args (placement/traffic own all coordinates), validation clamps/repairs via
9
+ engine/tools.py, the deterministic seed is crc32(f"{wish_id}:{call_index}"),
10
+ and a terse observation string goes back to the planner loop.
11
+
12
+ Wire/internal names keep godseed's wish_id/wish_* verbatim (zero-risk reuse);
13
+ user-facing copy says "petition" elsewhere.
14
+
15
+ Pure stdlib, synchronous, no pydantic.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import copy
21
+ import re
22
+ import time
23
+ import zlib
24
+ from dataclasses import dataclass
25
+ from typing import Any, Iterable, Optional
26
+
27
+ from . import summary as _summary
28
+ from . import tools as _tools
29
+
30
+ _FEATURE_ID_RE = re.compile(r"^e_(\d+)$")
31
+
32
+ _EPITAPH_KEEP = 64 # in-memory tail; the full archive lives in traces/wishes.jsonl
33
+
34
+
35
+ @dataclass
36
+ class Feature:
37
+ """One immutable city event. Canonical JSON shape per ARCHITECTURE.md."""
38
+
39
+ id: str
40
+ wish_id: str
41
+ tool: str
42
+ args: dict
43
+ seed: int
44
+ t: float
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ "id": self.id,
49
+ "wish_id": self.wish_id,
50
+ "tool": self.tool,
51
+ "args": copy.deepcopy(self.args),
52
+ "seed": self.seed,
53
+ "t": self.t,
54
+ }
55
+
56
+ @classmethod
57
+ def from_dict(cls, d: dict) -> "Feature":
58
+ return cls(
59
+ id=d["id"],
60
+ wish_id=d["wish_id"],
61
+ tool=d["tool"],
62
+ args=copy.deepcopy(d.get("args") or {}),
63
+ seed=int(d.get("seed", 0)),
64
+ t=d.get("t", 0),
65
+ )
66
+
67
+
68
+ def _seed_for(wish_id: str, call_index: int) -> int:
69
+ return zlib.crc32(f"{wish_id}:{call_index}".encode("utf-8")) & 0x7FFFFFFF
70
+
71
+
72
+ def _observation(feature: Feature) -> str:
73
+ """Terse 'ok' observation the planner can record."""
74
+ t, a = feature.tool, feature.args
75
+ if t == "place_building":
76
+ return (
77
+ f"ok: {a['kind']} '{a['name']}' placed at ({a['cx']},{a['cz']}), "
78
+ f"{a['floors']} floors"
79
+ )
80
+ if t == "lay_road":
81
+ name = a.get("name") or "unnamed"
82
+ return f"ok: {len(a['cells'])} cells of {a['klass']} laid ({name})"
83
+ if t == "apply_fix":
84
+ return f"ok: {a['action']} applied on {a['name']} ({len(a['cells'])} cells)"
85
+ if t == "note":
86
+ return "ok: noted on the city ledger"
87
+ return "ok" # pragma: no cover — every known tool is handled above
88
+
89
+
90
+ class World:
91
+ """Append-only event list + derived views. Single-threaded (asyncio)."""
92
+
93
+ def __init__(self) -> None:
94
+ self._features: list[Feature] = []
95
+ self._epitaphs: list[str] = []
96
+ self._next_index: int = 0
97
+ self._last_signature: Optional[tuple] = None
98
+
99
+ # ------------------------------------------------------------- loading
100
+
101
+ @classmethod
102
+ def load(cls, features: Iterable[Any], epitaphs: Optional[Iterable[str]] = None) -> "World":
103
+ """Rebuild a world from persisted event dicts (or Feature objects).
104
+
105
+ Persisted data is trusted verbatim — values (including seeds) are NOT
106
+ re-validated, so genesis and replayed traces stay byte-identical.
107
+ """
108
+ world = cls()
109
+ for f in features or ():
110
+ feature = f if isinstance(f, Feature) else Feature.from_dict(f)
111
+ world._features.append(feature)
112
+ world._next_index = world._derive_next_index()
113
+ world._epitaphs = [str(e)[:120] for e in (epitaphs or ()) if e][-_EPITAPH_KEEP:]
114
+ return world
115
+
116
+ def _derive_next_index(self) -> int:
117
+ best = -1
118
+ for f in self._features:
119
+ m = _FEATURE_ID_RE.match(f.id)
120
+ if m:
121
+ best = max(best, int(m.group(1)))
122
+ return best + 1 if best >= 0 else len(self._features)
123
+
124
+ # ------------------------------------------------------------- mutation
125
+
126
+ def apply(
127
+ self,
128
+ wish_id: str,
129
+ call_index: int,
130
+ call: Any,
131
+ t: Optional[float] = None,
132
+ ) -> tuple[Optional[Feature], str]:
133
+ """Validate and apply one event.
134
+
135
+ Returns (feature, observation) on success, (None, rejection) otherwise.
136
+ Out-of-range numbers are clamped; unknown tools/shapes are rejected with
137
+ a terse observation string. The world only changes on success.
138
+ """
139
+ if not isinstance(call, dict):
140
+ return None, "rejected: malformed call"
141
+ tool = call.get("tool")
142
+ if isinstance(tool, str):
143
+ tool = tool.strip().lower()
144
+ canonical, err = _tools.validate_call(tool, call.get("args"))
145
+ if err:
146
+ return None, err
147
+ # An identical consecutive call within one wish is refused with a nudge
148
+ # (small models repeat themselves; the city does not).
149
+ signature = (str(wish_id), tool, repr(sorted(canonical.items())))
150
+ if signature == self._last_signature:
151
+ return None, "rejected: already done; change something, or say done"
152
+ self._last_signature = signature
153
+ feature = Feature(
154
+ id=f"e_{self._next_index:06d}",
155
+ wish_id=str(wish_id),
156
+ tool=tool,
157
+ args=canonical,
158
+ seed=_seed_for(str(wish_id), int(call_index)),
159
+ t=time.time() if t is None else t,
160
+ )
161
+ self._next_index += 1
162
+ self._features.append(feature)
163
+ return feature, _observation(feature)
164
+
165
+ def record_epitaph(self, text: Any) -> None:
166
+ """Record a granted petition's blurb (the ticker shows the last few)."""
167
+ if text:
168
+ self._epitaphs.append(str(text)[:120])
169
+ if len(self._epitaphs) > _EPITAPH_KEEP:
170
+ del self._epitaphs[: -_EPITAPH_KEEP]
171
+
172
+ # ------------------------------------------------------------- views
173
+
174
+ @property
175
+ def features(self) -> tuple[Feature, ...]:
176
+ return tuple(self._features)
177
+
178
+ @property
179
+ def epitaphs(self) -> tuple[str, ...]:
180
+ return tuple(self._epitaphs)
181
+
182
+ @property
183
+ def version(self) -> int:
184
+ return len(self._features)
185
+
186
+ @property
187
+ def epoch(self) -> int:
188
+ """Number of distinct granted petitions (genesis is epoch 0)."""
189
+ seen: set[str] = set()
190
+ for f in self._features:
191
+ if f.wish_id != "genesis":
192
+ seen.add(f.wish_id)
193
+ return len(seen)
194
+
195
+ def state_dict(self) -> dict:
196
+ """Full world state for /api/state and SSE hello consumers."""
197
+ return {
198
+ "version": self.version,
199
+ "epoch": self.epoch,
200
+ "features": [f.to_dict() for f in self._features],
201
+ }
202
+
203
+ def summary(self, now_s: Optional[float] = None) -> str:
204
+ """Compact (<600 chars) description for the LLM prompt."""
205
+ return _summary.summarize(self, now_s)
mind/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GODSEED mind — Agent C.
2
+
3
+ The Mind interprets a wish, narrates a short Reading aloud, then runs a
4
+ grammar-constrained plan-act-observe loop over the engine's 11-tool DSL.
5
+ It never computes geometry and never owns world state (the "her" pattern):
6
+ the deterministic engine clamps, executes, and records every act.
7
+
8
+ Public surface:
9
+ Planner — async grant(wish, world_summary, act, emit) -> WishTrace dict
10
+ make_backend — GODSEED_BACKEND env -> MockBackend | LlamaCppBackend | ZeroGPUBackend
11
+ judge — default-deny moderation judgment (injected into engine moderation)
12
+ """
13
+
14
+ from .backends import LlamaCppBackend, MockBackend, ZeroGPUBackend, make_backend
15
+ from .moderation_judge import judge
16
+ from .planner import Planner
17
+
18
+ __all__ = [
19
+ "Planner",
20
+ "make_backend",
21
+ "MockBackend",
22
+ "LlamaCppBackend",
23
+ "ZeroGPUBackend",
24
+ "judge",
25
+ ]
mind/backends.py ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generation backends for the NEMOCITY mind.
2
+
3
+ Protocol (duck-typed; see `Backend`):
4
+
5
+ name: str # "mock" | "llamacpp" | "zerogpu"
6
+ model_id: str # human-readable model identifier
7
+ oneshot: bool # always True — NEMOCITY is one-shot only
8
+ async def generate_stream(prompt: str, grammar: str | None,
9
+ max_tokens: int) -> AsyncIterator[str]
10
+
11
+ `grammar` is a plain string TAG (mind/validate.py), never a real grammar:
12
+ "build" | "fix" | "moderation" select the wanted JSON shape (validation +
13
+ temperature 0.4), a "!strict" suffix means a retry pass at temperature 0,
14
+ None means free text. The protocol signature is byte-compatible with
15
+ godseed's so the copied plumbing keeps working.
16
+
17
+ Selected via the CITY_BACKEND env: mock (default) | zerogpu | llamacpp.
18
+
19
+ - MockBackend: deterministic keyword-driven scripts (mind/mock_scripts.py)
20
+ with small delays so streaming looks alive. Local dev + headless demos.
21
+ - ZeroGPUBackend: transformers + spaces.GPU, Nemotron-Nano-9B-v2 resident on
22
+ cuda. ONE @spaces.GPU call per petition (the June 12 lesson). Ship path.
23
+ - LlamaCppBackend: llama-cpp-python + a local GGUF; offgrid stretch, not the
24
+ ship path.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import concurrent.futures
31
+ import json
32
+ import os
33
+ import re
34
+ import threading
35
+ from typing import AsyncIterator, Protocol, runtime_checkable
36
+
37
+ from . import prompts
38
+ from .mock_scripts import build_script, fix_script
39
+ from .validate import (
40
+ FIX_TAG,
41
+ base_tag,
42
+ is_moderation_grammar,
43
+ is_strict,
44
+ parse_build,
45
+ parse_fix,
46
+ parse_moderation,
47
+ )
48
+
49
+
50
+ @runtime_checkable
51
+ class Backend(Protocol):
52
+ """Structural type for generation backends."""
53
+
54
+ name: str
55
+ model_id: str
56
+
57
+ def generate_stream(
58
+ self, prompt: str, grammar: str | None, max_tokens: int
59
+ ) -> AsyncIterator[str]: ...
60
+
61
+
62
+ # --------------------------------------------------------------------------
63
+ # Mock backend — what demos run on
64
+ # --------------------------------------------------------------------------
65
+
66
+ _MOCK_DENYLIST: tuple[tuple[re.Pattern[str], str], ...] = (
67
+ (re.compile(r"\b(nazi|hitler|swastika|kkk|genocide|lynch)\b", re.I), "hate"),
68
+ (re.compile(r"\b(kill|murder|slaughter|massacre|behead|stab)\b", re.I), "violence"),
69
+ (re.compile(r"\b(bomb|terror|terrorist|terrorism|shooting)\b", re.I), "violence"),
70
+ (re.compile(r"\b(rape|porn|sex|sexual|nude|naked|nsfw)\b", re.I), "sexual"),
71
+ (re.compile(r"\b(suicide|self[- ]?harm|cutting)\b", re.I), "self-harm"),
72
+ )
73
+
74
+
75
+ def _chunks(text: str, size: int) -> list[str]:
76
+ return [text[i : i + size] for i in range(0, len(text), size)]
77
+
78
+
79
+ class MockBackend:
80
+ """Deterministic scripted backend.
81
+
82
+ Routing (marker constants from mind/prompts.py):
83
+ - moderation tag -> keyword-denylist verdict on the text after
84
+ CANDIDATE_MARKER;
85
+ - prompts containing FIX_MARKER -> fix script (reads the rendered
86
+ candidates/numbers back out of the prompt);
87
+ - everything else -> BUILD JSON keyed on the petition line after the
88
+ last PETITION_MARKER.
89
+
90
+ Token delays come from CITY_MOCK_DELAY (seconds per chunk, default
91
+ 0.012); tests pass delay=0.0 explicitly.
92
+ """
93
+
94
+ name = "mock"
95
+ model_id = "nemocity-mock-scripts"
96
+ oneshot = True
97
+
98
+ def __init__(self, delay: float | None = None):
99
+ if delay is None:
100
+ try:
101
+ delay = float(os.environ.get("CITY_MOCK_DELAY", "0.012"))
102
+ except ValueError:
103
+ delay = 0.012
104
+ self.delay = max(0.0, delay)
105
+
106
+ async def _tick(self) -> None:
107
+ if self.delay:
108
+ await asyncio.sleep(self.delay)
109
+ else:
110
+ await asyncio.sleep(0) # stay fair to the event loop
111
+
112
+ @staticmethod
113
+ def _tail(prompt: str, marker: str) -> str:
114
+ parts = prompt.rsplit(marker, 1)
115
+ return parts[-1] if len(parts) == 2 else prompt
116
+
117
+ def _petition(self, prompt: str) -> str:
118
+ tail = self._tail(prompt, prompts.PETITION_MARKER)
119
+ return (tail.splitlines() or [""])[0].strip()
120
+
121
+ def _moderate(self, prompt: str) -> dict:
122
+ tail = self._tail(prompt, prompts.CANDIDATE_MARKER)
123
+ candidate = (tail.splitlines() or [""])[0].strip()
124
+ for pattern, category in _MOCK_DENYLIST:
125
+ if pattern.search(candidate):
126
+ return {"allowed": False, "category": category}
127
+ return {"allowed": True, "category": ""}
128
+
129
+ async def generate_stream(
130
+ self, prompt: str, grammar: str | None = None, max_tokens: int = 700
131
+ ) -> AsyncIterator[str]:
132
+ if is_moderation_grammar(grammar):
133
+ verdict = json.dumps(self._moderate(prompt))
134
+ for chunk in _chunks(verdict, 8):
135
+ await self._tick()
136
+ yield chunk
137
+ return
138
+
139
+ if prompts.FIX_MARKER in prompt:
140
+ payload = fix_script(prompt)
141
+ else:
142
+ payload = build_script(self._petition(prompt))
143
+ for chunk in _chunks(payload, 12):
144
+ await self._tick()
145
+ yield chunk
146
+
147
+
148
+ # --------------------------------------------------------------------------
149
+ # llama.cpp backend — local GGUF (offgrid stretch; NOT the ship path)
150
+ # --------------------------------------------------------------------------
151
+
152
+ _THREAD_DONE = object()
153
+
154
+
155
+ class LlamaCppBackend:
156
+ """llama-cpp-python backend (CPU). No grammars — tags only pick the
157
+ temperature; the lenient parsers + planner fallback cover messy output.
158
+
159
+ Lazy: nothing is imported or downloaded until the first generate call.
160
+ Model resolution order:
161
+ 1. CITY_GGUF env — local path to a .gguf file;
162
+ 2. hf_hub_download(CITY_GGUF_REPO, CITY_GGUF_FILE).
163
+
164
+ Generation runs in a thread executor; chunks cross into the event loop
165
+ through a bounded asyncio.Queue (backpressure included). If the consumer
166
+ abandons the stream early, a cancel flag stops the producer thread.
167
+ """
168
+
169
+ name = "llamacpp"
170
+ oneshot = True
171
+
172
+ DEFAULT_REPO = "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF"
173
+ DEFAULT_FILE = "NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf"
174
+
175
+ def __init__(
176
+ self,
177
+ model_path: str | None = None,
178
+ n_ctx: int = 4096,
179
+ n_threads: int | None = None,
180
+ ):
181
+ self.model_path = model_path or os.environ.get("CITY_GGUF") or None
182
+ self.repo_id = os.environ.get("CITY_GGUF_REPO", self.DEFAULT_REPO)
183
+ self.filename = os.environ.get("CITY_GGUF_FILE", self.DEFAULT_FILE)
184
+ self.n_ctx = n_ctx
185
+ self.n_threads = n_threads
186
+ self.model_id = self.model_path or f"{self.repo_id}:{self.filename}"
187
+ self._llm = None
188
+ self._lock = threading.Lock()
189
+
190
+ # -- lazy model load (thread-safe; called from executor threads) --------
191
+
192
+ def _ensure_llm(self):
193
+ with self._lock:
194
+ if self._llm is not None:
195
+ return self._llm
196
+ from llama_cpp import Llama # heavy import deferred
197
+
198
+ path = self.model_path
199
+ if not path or not os.path.exists(path):
200
+ from huggingface_hub import hf_hub_download
201
+
202
+ path = hf_hub_download(repo_id=self.repo_id, filename=self.filename)
203
+ kwargs = {"model_path": path, "n_ctx": self.n_ctx, "verbose": False}
204
+ threads = self.n_threads or (os.cpu_count() or 4)
205
+ kwargs["n_threads"] = threads
206
+ kwargs["n_batch"] = 512 # larger prompt-eval batch -> faster prefill
207
+ self._llm = Llama(**kwargs)
208
+ self.model_id = path
209
+ return self._llm
210
+
211
+ async def generate_stream(
212
+ self, prompt: str, grammar: str | None = None, max_tokens: int = 700
213
+ ) -> AsyncIterator[str]:
214
+ loop = asyncio.get_running_loop()
215
+ queue: asyncio.Queue = asyncio.Queue(maxsize=64)
216
+ cancelled = threading.Event()
217
+ if grammar is None:
218
+ temperature = 0.7
219
+ else:
220
+ temperature = 0.0 if is_strict(grammar) else 0.35
221
+
222
+ def _put(item) -> bool:
223
+ """Push from the producer thread; honor cancellation."""
224
+ future = asyncio.run_coroutine_threadsafe(queue.put(item), loop)
225
+ while not cancelled.is_set():
226
+ try:
227
+ future.result(timeout=0.25)
228
+ return True
229
+ except concurrent.futures.TimeoutError:
230
+ continue
231
+ except Exception:
232
+ return False
233
+ future.cancel()
234
+ return False
235
+
236
+ def _produce() -> None:
237
+ try:
238
+ llm = self._ensure_llm()
239
+ stream = llm.create_completion(
240
+ prompt=prompt,
241
+ max_tokens=max_tokens,
242
+ stream=True,
243
+ temperature=temperature,
244
+ top_p=0.9,
245
+ repeat_penalty=1.1,
246
+ )
247
+ for part in stream:
248
+ if cancelled.is_set():
249
+ break
250
+ text = part["choices"][0].get("text", "")
251
+ if text and not _put(text):
252
+ break
253
+ except Exception as exc: # surfaced to the consumer below
254
+ _put(exc)
255
+ finally:
256
+ _put(_THREAD_DONE)
257
+
258
+ producer = loop.run_in_executor(None, _produce)
259
+ try:
260
+ while True:
261
+ item = await queue.get()
262
+ if item is _THREAD_DONE:
263
+ break
264
+ if isinstance(item, Exception):
265
+ raise item
266
+ yield item
267
+ finally:
268
+ cancelled.set()
269
+ # Drain so a blocked producer can reach its sentinel and exit.
270
+ while not queue.empty():
271
+ queue.get_nowait()
272
+ await producer
273
+
274
+
275
+ # --------------------------------------------------------------------------
276
+ # ZeroGPU backend — Nemotron-Nano-9B-v2 (the ship path)
277
+ # --------------------------------------------------------------------------
278
+
279
+ class ZeroGPUBackend:
280
+ """transformers + spaces.GPU backend (HF ZeroGPU).
281
+
282
+ All heavy imports (torch, transformers, spaces) are deferred to the first
283
+ generate call so this module imports cleanly anywhere. The GPU-decorated
284
+ function is built lazily; if `spaces` is unavailable (local dev) the bare
285
+ function is used.
286
+
287
+ No grammar support, so when a tag is supplied the output is validated
288
+ (mind/validate.py) and regenerated once at temperature ~0 on failure. If
289
+ both attempts are malformed the raw text is yielded anyway — the
290
+ planner's retry/fallback path handles it from there.
291
+
292
+ Streaming note: tokens are buffered per generation (validation requires
293
+ the full text) and then yielded in small chunks, so SSE consumers still
294
+ see incremental output.
295
+ """
296
+
297
+ name = "zerogpu"
298
+ oneshot = True # a petition is ONE @spaces.GPU call (ZeroGPU detaches the
299
+ # GPU between calls; godseed's per-turn loop of ~8 calls crashed on #2)
300
+
301
+ # Nemotron-Nano-9B-v2: its nemotron_h arch is IN-TREE in transformers, so it
302
+ # loads with trust_remote_code=False and runs the kernel-free native Mamba-2
303
+ # path. The Nemotron-3 4B/30B repos hard-require remote code + mamba-ssm
304
+ # kernels, which cannot import on the ZeroGPU image (June 12).
305
+ DEFAULT_MODEL = "nvidia/NVIDIA-Nemotron-Nano-9B-v2"
306
+ GPU_DURATION_S = 240
307
+
308
+ def __init__(self, model_id: str | None = None, max_input_tokens: int = 4096):
309
+ self.model_id = model_id or os.environ.get(
310
+ "CITY_HF_MODEL", self.DEFAULT_MODEL
311
+ )
312
+ self.max_input_tokens = max_input_tokens
313
+ self._tokenizer = None
314
+ self._model = None
315
+ self._gpu_generate = None
316
+ self._on_cuda = False
317
+ self._lock = threading.Lock()
318
+ # Load weights to CPU at construction (app startup). CRITICAL: do NOT
319
+ # touch CUDA here. Initializing CUDA in the main process poisons
320
+ # ZeroGPU's per-request GPU attach (the forked worker inherits a broken
321
+ # context -> "NVML_SUCCESS INTERNAL ASSERT FAILED" in the CUDA allocator
322
+ # on every forward, June 12). The model moves to cuda guarded by
323
+ # `import spaces` below, which virtualizes the placement.
324
+ self._ensure()
325
+
326
+ # -- setup (model resident; cuda placement guarded by spaces) -----------
327
+
328
+ def _ensure(self):
329
+ with self._lock:
330
+ if self._gpu_generate is not None:
331
+ return self._gpu_generate
332
+ import torch # noqa: F401
333
+
334
+ from transformers import AutoModelForCausalLM, AutoTokenizer
335
+
336
+ # trust_remote_code MUST stay False: the repo also ships custom code
337
+ # that hard-requires mamba-ssm kernels (unimportable on ZeroGPU);
338
+ # False forces the in-tree NemotronH class with its native fallback.
339
+ self._tokenizer = AutoTokenizer.from_pretrained(self.model_id)
340
+ self._model = AutoModelForCausalLM.from_pretrained(
341
+ self.model_id,
342
+ dtype=torch.bfloat16, # v5 name (torch_dtype removed in transformers 5)
343
+ low_cpu_mem_usage=True,
344
+ )
345
+ self._model.eval()
346
+ # Move to cuda ONCE at load, guarded by `import spaces` (the lib
347
+ # virtualizes it and keeps the model resident across @spaces.GPU
348
+ # calls). With ONE-SHOT generation a petition is a single GPU call,
349
+ # so the multi-call NVML crash can't happen — and keeping the model
350
+ # resident avoids the ~67s CPU->GPU transfer that, done per-call,
351
+ # ate the whole GPU budget and left no time to generate (June 12).
352
+ # Resident + one call = ~14s real output.
353
+ try:
354
+ import spaces # noqa: F401
355
+ self._model.to("cuda")
356
+ except ImportError:
357
+ pass # local/CPU dev
358
+
359
+ def _generate(prompt: str, max_new_tokens: int, temperature: float) -> str:
360
+ import torch as _torch # noqa: F401
361
+
362
+ # Nemotron-Nano-9B-v2 is a reasoning model that THINKS by
363
+ # default; "/no_think" in the system slot disables it (June 12
364
+ # bench: raw completion leaked </think> into outputs). The
365
+ # planner's full completion-style prompt rides as one user turn.
366
+ if getattr(self._tokenizer, "chat_template", None):
367
+ text_in = self._tokenizer.apply_chat_template(
368
+ [
369
+ {"role": "system", "content": "/no_think"},
370
+ {"role": "user", "content": prompt},
371
+ ],
372
+ tokenize=False,
373
+ add_generation_prompt=True,
374
+ )
375
+ else:
376
+ text_in = prompt
377
+ inputs = self._tokenizer(
378
+ text_in,
379
+ return_tensors="pt",
380
+ truncation=True,
381
+ max_length=self.max_input_tokens,
382
+ ).to(self._model.device)
383
+ with _torch.no_grad():
384
+ output = self._model.generate(
385
+ **inputs,
386
+ max_new_tokens=max_new_tokens,
387
+ do_sample=temperature > 0.05,
388
+ temperature=max(temperature, 0.05),
389
+ top_p=0.9,
390
+ pad_token_id=self._tokenizer.eos_token_id,
391
+ )
392
+ new_tokens = output[0][inputs["input_ids"].shape[1]:]
393
+ text = self._tokenizer.decode(new_tokens, skip_special_tokens=True)
394
+ # Defensive: strip any (possibly empty) think block that slips out.
395
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
396
+ text = text.replace("<think>", "").replace("</think>", "")
397
+ return text.strip()
398
+
399
+ try:
400
+ import spaces # import-guarded at call time, per contract
401
+
402
+ self._gpu_generate = spaces.GPU(duration=self.GPU_DURATION_S)(_generate)
403
+ except Exception:
404
+ self._gpu_generate = _generate
405
+ return self._gpu_generate
406
+
407
+ @staticmethod
408
+ def _is_valid(grammar: str, text: str) -> bool:
409
+ if is_moderation_grammar(grammar):
410
+ return parse_moderation(text)[1] is None
411
+ if base_tag(grammar) == FIX_TAG:
412
+ return parse_fix(text)[1] is None
413
+ return parse_build(text)[1] is None
414
+
415
+ async def _gen(self, generate, prompt: str, max_tokens: int, temperature: float) -> str:
416
+ """Run one generation, retrying transient ZeroGPU CUDA/NVML failures.
417
+ Each @spaces.GPU call re-schedules onto a (possibly healthier) GPU, so a
418
+ retry can clear the intermittent NVML allocator assert seen June 12."""
419
+ loop = asyncio.get_running_loop()
420
+ last_exc = None
421
+ for attempt in range(3):
422
+ try:
423
+ return await loop.run_in_executor(None, generate, prompt, max_tokens, temperature)
424
+ except Exception as exc: # noqa: BLE001
425
+ msg = str(exc)
426
+ transient = any(s in msg for s in ("NVML", "CUDA", "cuda", "device", "out of memory"))
427
+ last_exc = exc
428
+ if not transient or attempt == 2:
429
+ raise
430
+ # drop the poisoned cuda placement so the next call re-homes the model
431
+ self._on_cuda = False
432
+ import torch as _t
433
+ try:
434
+ _t.cuda.empty_cache()
435
+ except Exception:
436
+ pass
437
+ await asyncio.sleep(0.5 * (attempt + 1))
438
+ raise last_exc # pragma: no cover
439
+
440
+ async def generate_stream(
441
+ self, prompt: str, grammar: str | None = None, max_tokens: int = 700
442
+ ) -> AsyncIterator[str]:
443
+ loop = asyncio.get_running_loop()
444
+ generate = await loop.run_in_executor(None, self._ensure)
445
+
446
+ if grammar is None:
447
+ text = await self._gen(generate, prompt, max_tokens, 0.7)
448
+ else:
449
+ temperature = 0.0 if is_strict(grammar) else 0.4
450
+ strict_prompt = (
451
+ prompt + "\nReply with exactly one JSON object and nothing else."
452
+ )
453
+ text = await self._gen(generate, strict_prompt, max_tokens, temperature)
454
+ if not self._is_valid(grammar, text):
455
+ retry_prompt = (
456
+ strict_prompt
457
+ + "\nYour previous output was malformed. Output ONLY the JSON object."
458
+ )
459
+ text = await self._gen(generate, retry_prompt, max_tokens, 0.0)
460
+ # Still malformed? Yield as-is; the planner retries then falls back.
461
+
462
+ for chunk in _chunks(text, 24):
463
+ await asyncio.sleep(0)
464
+ yield chunk
465
+
466
+
467
+ # --------------------------------------------------------------------------
468
+ # Factory
469
+ # --------------------------------------------------------------------------
470
+
471
+ _BACKEND_ALIASES = {
472
+ "mock": "mock",
473
+ "llamacpp": "llamacpp",
474
+ "llama": "llamacpp",
475
+ "gguf": "llamacpp",
476
+ "zerogpu": "zerogpu",
477
+ "zero-gpu": "zerogpu",
478
+ "transformers": "zerogpu",
479
+ }
480
+
481
+
482
+ def make_backend(name: str | None = None):
483
+ """Build the backend selected by `name` or the CITY_BACKEND env.
484
+
485
+ Defaults to mock (always works, zero deps beyond stdlib).
486
+ """
487
+ raw = (name or os.environ.get("CITY_BACKEND") or "mock").strip().lower()
488
+ resolved = _BACKEND_ALIASES.get(raw)
489
+ if resolved == "mock":
490
+ return MockBackend()
491
+ if resolved == "llamacpp":
492
+ return LlamaCppBackend()
493
+ if resolved == "zerogpu":
494
+ return ZeroGPUBackend()
495
+ raise ValueError(
496
+ f"unknown CITY_BACKEND {raw!r} (expected mock | zerogpu | llamacpp)"
497
+ )
mind/mock_scripts.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic scripted BUILD/FIX JSON for the MockBackend.
2
+
3
+ Each petition maps to a category by keyword; each category emits a complete
4
+ BUILD JSON string (parsed by mind/validate.py exactly like real backend
5
+ output), with names and blurbs varied by a crc32 seed of the petition — same
6
+ petition in, same permit out, so demos replay exactly. The category library
7
+ exercises every building kind.
8
+
9
+ fix_script reads the candidate ids and report numbers back out of the
10
+ rendered fix prompt (the "- F1: desc (predicted index 41)" lines from
11
+ mind/prompts.py — keep the two in lockstep) and picks the best predicted
12
+ index, so mock fixes cite real numbers too.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import random
19
+ import re
20
+ import zlib
21
+
22
+ NAME_MAX = 24
23
+ BLURB_MAX = 140
24
+
25
+
26
+ def _seed_for(petition: str) -> int:
27
+ return zlib.crc32(" ".join(petition.lower().split()).encode("utf-8")) & 0x7FFFFFFF
28
+
29
+
30
+ def _entry(kind: str, name: str, near=None, floors=None, hue=None) -> dict:
31
+ return {"kind": kind, "name": name[:NAME_MAX], "near": near, "floors": floors, "hue": hue}
32
+
33
+
34
+ def _plan(blurb: str, buildings: list[dict]) -> dict:
35
+ return {
36
+ "intent": "build",
37
+ "blurb": blurb[:BLURB_MAX],
38
+ "buildings": buildings[:3],
39
+ "decline_reason": None,
40
+ }
41
+
42
+
43
+ _STOP_WORDS = {
44
+ "a", "an", "the", "please", "build", "make", "made", "add", "want",
45
+ "wants", "need", "needs", "near", "for", "with", "of", "in", "on", "to",
46
+ "and", "my", "me", "us", "we", "i", "new", "some", "little", "big",
47
+ }
48
+
49
+
50
+ def _petition_name(petition: str, suffix: str = "House") -> str:
51
+ words = [w for w in re.findall(r"[A-Za-z]+", petition) if w.lower() not in _STOP_WORDS]
52
+ base = " ".join(w.capitalize() for w in words[:2])
53
+ name = f"{base} {suffix}".strip()
54
+ return (name[:NAME_MAX].rstrip() or f"Petition {suffix}")[:NAME_MAX]
55
+
56
+
57
+ _CAFES = ("Cafe Luna", "The Brass Kettle", "Marigold Coffee", "The Daily Grind", "Cafe Fig")
58
+ _HOUSES = ("Wren Cottage", "Maple End", "The Little Blue", "Juniper House", "Quince Cottage")
59
+ _APTS = ("Linden Court", "The Beacon Flats", "Harbor House", "The Gables", "Rookery Flats")
60
+ _TOWERS = ("Meridian Tower", "The Spindle", "Apex Tower", "Cobalt Point", "The Needle")
61
+ _PARKS = ("Dapple Green", "Whistle Park", "Petal Commons", "Old Elm Green")
62
+ _STADIUMS = ("The Civic Bowl", "Tin Cup Stadium", "The Roaring Yard")
63
+ _CHURCHES = ("The Quiet Spire", "St. Alder's", "Lantern Chapel")
64
+ _SCHOOLS = ("Alder Primary", "Hilltop School", "The Brightwork School")
65
+ _HOSPITALS = ("Riverside General", "St. Junia Hospital", "Bluebell General")
66
+ _FACTORIES = ("The Tin Works", "Cobalt Works", "The Bottling Hall")
67
+ _MARKETS = ("Penny Market", "The Mercantile", "Six Crows Market")
68
+ _BANKS = ("The Counting House", "First Acorn Bank", "The Strongbox")
69
+ _SHOPS = ("The Odd Shelf", "Tortoise & Finch", "The Blue Awning")
70
+ _NEARS = ("downtown", "the park", "Main St", "1st Ave")
71
+
72
+
73
+ def _district(petition: str, rng: random.Random) -> dict:
74
+ flats, cafe, shop = rng.choice(_APTS), rng.choice(_CAFES), rng.choice(_SHOPS)
75
+ buildings = [
76
+ _entry("apartments", flats, "downtown", rng.randint(4, 7), rng.choice((25, 35, 200))),
77
+ _entry("cafe", cafe, "downtown", 1, rng.choice((30, 40, 50))),
78
+ ]
79
+ if rng.random() < 0.7:
80
+ buildings.append(_entry("shop", shop, "downtown", 1, rng.choice((10, 180, 320))))
81
+ blurb = rng.choice((
82
+ f"Zoning approved: a new block downtown — {flats} rising with {cafe} on the corner.",
83
+ f"A whole block takes shape: {flats}, {cafe}, and room for more.",
84
+ ))
85
+ return _plan(blurb, buildings)
86
+
87
+
88
+ def _river(petition: str, rng: random.Random) -> dict:
89
+ park, cafe = rng.choice(_PARKS), rng.choice(("The Ferry Light", "Pier Nine Cafe", "The Eddy"))
90
+ blurb = rng.choice((
91
+ f"The waterfront gets its due: {park} on the bank, {cafe} watching the water.",
92
+ f"Permits issued by the river — {park}, and {cafe} for the crossing crowd.",
93
+ ))
94
+ return _plan(blurb, [
95
+ _entry("park", park, "the river"),
96
+ _entry("cafe", cafe, "the river", 1, rng.choice((35, 45))),
97
+ ])
98
+
99
+
100
+ def _cafe(petition: str, rng: random.Random) -> dict:
101
+ name = rng.choice(_CAFES)
102
+ near = rng.choice(_NEARS)
103
+ blurb = rng.choice((
104
+ f"{name} opens near {near} — pastries at dawn, lamplight at dusk.",
105
+ f"Permit granted: {name}, sidewalk tables facing the street.",
106
+ ))
107
+ return _plan(blurb, [_entry("cafe", name, near, 1, rng.choice((30, 40, 50)))])
108
+
109
+
110
+ def _house(petition: str, rng: random.Random) -> dict:
111
+ name = rng.choice(_HOUSES)
112
+ blurb = rng.choice((
113
+ f"{name} gets its lot — a porch, a chimney, a light in the window.",
114
+ f"A home is granted: {name}, keys cut while the paint dries.",
115
+ ))
116
+ return _plan(blurb, [_entry("house", name, rng.choice(_NEARS), rng.randint(1, 2), rng.choice((20, 35, 200, 350)))])
117
+
118
+
119
+ def _apartments(petition: str, rng: random.Random) -> dict:
120
+ name = rng.choice(_APTS)
121
+ blurb = rng.choice((
122
+ f"{name} approved — {rng.randint(4, 7)} floors of new neighbors.",
123
+ f"Housing first: {name} will fill with families by the weekend.",
124
+ ))
125
+ return _plan(blurb, [_entry("apartments", name, rng.choice(_NEARS), rng.randint(4, 7), rng.choice((25, 200, 160)))])
126
+
127
+
128
+ def _tower(petition: str, rng: random.Random) -> dict:
129
+ name = rng.choice(_TOWERS)
130
+ blurb = rng.choice((
131
+ f"{name} cleared for the skyline — cranes by morning.",
132
+ f"Downtown grows up: {name}, {rng.randint(8, 16)} floors against the horizon.",
133
+ ))
134
+ return _plan(blurb, [_entry("tower", name, "downtown", rng.randint(8, 16), rng.choice((210, 220, 35)))])
135
+
136
+
137
+ def _park(petition: str, rng: random.Random) -> dict:
138
+ name = rng.choice(_PARKS)
139
+ blurb = rng.choice((
140
+ f"Green space wins: {name}, benches and old trees included.",
141
+ f"{name} is set aside — the city keeps a place to breathe.",
142
+ ))
143
+ return _plan(blurb, [_entry("park", name, rng.choice(_NEARS))])
144
+
145
+
146
+ def _stadium(petition: str, rng: random.Random) -> dict:
147
+ name = rng.choice(_STADIUMS)
148
+ blurb = rng.choice((
149
+ f"{name} approved — floodlights, roar, and a home team to be named.",
150
+ f"Game on: {name} takes the big lot.",
151
+ ))
152
+ return _plan(blurb, [_entry("stadium", name, "downtown", 1, rng.choice((10, 210)))])
153
+
154
+
155
+ def _church(petition: str, rng: random.Random) -> dict:
156
+ name = rng.choice(_CHURCHES)
157
+ blurb = f"{name} is granted its steeple — bells on the hour."
158
+ return _plan(blurb, [_entry("church", name, rng.choice(_NEARS), 1, rng.choice((40, 350)))])
159
+
160
+
161
+ def _school(petition: str, rng: random.Random) -> dict:
162
+ name = rng.choice(_SCHOOLS)
163
+ blurb = rng.choice((
164
+ f"{name} chartered — chalk dust and a morning bell.",
165
+ f"The city invests in small citizens: {name} opens soon.",
166
+ ))
167
+ return _plan(blurb, [_entry("school", name, rng.choice(_NEARS), rng.randint(1, 2), 200)])
168
+
169
+
170
+ def _hospital(petition: str, rng: random.Random) -> dict:
171
+ name = rng.choice(_HOSPITALS)
172
+ blurb = f"{name} approved — the city will be looked after."
173
+ return _plan(blurb, [_entry("hospital", name, rng.choice(_NEARS), rng.randint(3, 6), 200)])
174
+
175
+
176
+ def _bank(petition: str, rng: random.Random) -> dict:
177
+ name = rng.choice(_BANKS)
178
+ blurb = f"{name} chartered downtown — marble face, careful ledgers."
179
+ return _plan(blurb, [_entry("bank", name, "downtown", rng.randint(2, 4), 210)])
180
+
181
+
182
+ def _factory(petition: str, rng: random.Random) -> dict:
183
+ name = rng.choice(_FACTORIES)
184
+ blurb = rng.choice((
185
+ f"{name} zoned for the edge of town — jobs by the shift.",
186
+ f"Industry arrives: {name}, smokestack and all.",
187
+ ))
188
+ return _plan(blurb, [_entry("factory", name, "the river", rng.randint(1, 2), 30)])
189
+
190
+
191
+ def _market(petition: str, rng: random.Random) -> dict:
192
+ name = rng.choice(_MARKETS)
193
+ blurb = rng.choice((
194
+ f"{name} licensed — stalls up by Saturday.",
195
+ f"Permit granted: {name}, the street will smell of bread.",
196
+ ))
197
+ return _plan(blurb, [_entry("market", name, rng.choice(_NEARS), 1, rng.choice((35, 15)))])
198
+
199
+
200
+ def _default(petition: str, rng: random.Random) -> dict:
201
+ name = _petition_name(petition)
202
+ blurb = f"City hall reads the petition twice, nods, and grants {name}."
203
+ return _plan(blurb, [_entry("house", name, rng.choice(_NEARS), rng.randint(1, 2), rng.choice((20, 200, 320)))])
204
+
205
+
206
+ # Order matters: most specific first; "house" is the safety net before default.
207
+ _CATEGORIES: tuple[tuple[re.Pattern[str], object], ...] = (
208
+ (re.compile(r"\b(districts?|downtown|neighbou?rhoods?|blocks?|quarters?|villages?|towns?|suburbs?|expand|grow)\b", re.I), _district),
209
+ (re.compile(r"\b(rivers?|riverside|waterfront|bridges?|docks?|harbou?rs?|piers?|ferry)\b", re.I), _river),
210
+ (re.compile(r"\b(caf[eé]s?|coffee|espresso|diners?|restaurants?|baker(?:y|ies)|bistros?|tea|ramen|pizz\w*|brunch|noodles?)\b", re.I), _cafe),
211
+ (re.compile(r"\b(apartments?|condos?|flats?|hotels?|housing|lofts?)\b", re.I), _apartments),
212
+ (re.compile(r"\b(towers?|offices?|skyscrapers?|high-?rises?|headquarters|startups?)\b", re.I), _tower),
213
+ (re.compile(r"\b(stadiums?|arenas?|sports?|football|soccer|baseball|matches?)\b", re.I), _stadium),
214
+ (re.compile(r"\b(church(?:es)?|temples?|chapels?|mosques?|cathedrals?|shrines?)\b", re.I), _church),
215
+ (re.compile(r"\b(schools?|librar(?:y|ies)|universit(?:y|ies)|colleges?|academ(?:y|ies)|kids|children)\b", re.I), _school),
216
+ (re.compile(r"\b(hospitals?|clinics?|doctors?|medical|health)\b", re.I), _hospital),
217
+ (re.compile(r"\b(banks?|finance|credit|vaults?)\b", re.I), _bank),
218
+ (re.compile(r"\b(factor(?:y|ies)|industr\w*|plants?|warehouses?|mills?|workshops?)\b", re.I), _factory),
219
+ (re.compile(r"\b(markets?|malls?|grocer\w*|supermarkets?|shops?|stores?|boutiques?|bazaars?|pharmac\w*)\b", re.I), _market),
220
+ (re.compile(r"\b(parks?|gardens?|greens?|trees?|playgrounds?|plazas?|squares?|fountains?)\b", re.I), _park),
221
+ (re.compile(r"\b(houses?|homes?|cottages?|bungalows?|cabins?|townhouses?)\b", re.I), _house),
222
+ )
223
+
224
+
225
+ def build_script(petition: str) -> str:
226
+ """The full BUILD JSON string for a petition. Deterministic: same
227
+ petition (modulo case/whitespace) always yields the same permit."""
228
+ petition = (petition or "").strip() or "a small building"
229
+ rng = random.Random(_seed_for(petition))
230
+ for pattern, builder in _CATEGORIES:
231
+ if pattern.search(petition):
232
+ return json.dumps(builder(petition, rng), ensure_ascii=False) # type: ignore[operator]
233
+ return json.dumps(_default(petition, rng), ensure_ascii=False)
234
+
235
+
236
+ _CANDIDATE_LINE_RE = re.compile(r"-\s*(F\d+):\s*([^\n(]*)\(predicted index (\d+)\)")
237
+ _TRAFFIC_INDEX_RE = re.compile(r'"traffic_index"\s*:\s*(\d+)')
238
+ _WORST_RE = re.compile(r'"worst"\s*:\s*"([^"]{1,48})"')
239
+
240
+
241
+ def fix_script(prompt: str) -> str:
242
+ """The FIX JSON string, built from the candidates/numbers rendered into
243
+ the fix prompt by mind/prompts.py. Picks the lowest predicted index."""
244
+ text = str(prompt or "")
245
+ m = _WORST_RE.search(text)
246
+ worst = m.group(1) if m else "the worst crossing"
247
+ ti = _TRAFFIC_INDEX_RE.search(text)
248
+ pairs = _CANDIDATE_LINE_RE.findall(text)
249
+ if pairs:
250
+ cid, desc, pred = min(pairs, key=lambda p: int(p[2]))
251
+ desc = " ".join(desc.split()).rstrip(" ,") or "the engine's bypass"
252
+ if ti:
253
+ diagnosis = (
254
+ f"Index {ti.group(1)} with {worst} over capacity; "
255
+ f"{cid} ({desc}) brings the predicted index to {pred}."
256
+ )
257
+ else:
258
+ diagnosis = f"{worst} is over capacity; {cid} ({desc}) brings the predicted index to {pred}."
259
+ else:
260
+ m = re.search(r"\b(F\d+)\b", text)
261
+ cid = m.group(1) if m else "F1"
262
+ diagnosis = f"{worst} runs over capacity; applying {cid}."
263
+ blurb = f"CITY ENGINEER: relief ordered for {worst}."
264
+ return json.dumps(
265
+ {"diagnosis": diagnosis[:160], "choice": cid, "blurb": blurb[:120]},
266
+ ensure_ascii=False,
267
+ )
mind/moderation_judge.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM moderation judgment for NEMOCITY — default-deny, always.
2
+
3
+ This is layer 3 of the engine's moderation stack (engine/moderation.py):
4
+ charset/length checks and the folded wordlist run first; this judge is the
5
+ final yes/no, OFF by default via the CITY_JUDGE env (the server injects
6
+ `judge` with the shared backend only when enabled).
7
+
8
+ MODERATION_GRAMMAR is the "moderation" tag (mind/validate.py), not a real
9
+ grammar — backends use it to pick the verdict shape.
10
+
11
+ Hard rule: ANY exception, timeout, or parse failure means NOT allowed. A
12
+ broken judge must fail closed — one slur on a storefront during judging week
13
+ ends every award.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+
20
+ from .prompts import build_moderation_prompt
21
+ from .validate import MODERATION_GRAMMAR, parse_moderation
22
+
23
+ JUDGE_MAX_TOKENS = 60
24
+ JUDGE_TIMEOUT_S = 30.0
25
+
26
+
27
+ async def _collect_verdict(text: str, backend) -> str:
28
+ prompt = build_moderation_prompt(text)
29
+ parts: list[str] = []
30
+ total = 0
31
+ stream = backend.generate_stream(prompt, MODERATION_GRAMMAR, JUDGE_MAX_TOKENS)
32
+ try:
33
+ async for chunk in stream:
34
+ if not chunk:
35
+ continue
36
+ parts.append(chunk)
37
+ total += len(chunk)
38
+ if total >= 2000: # a verdict is ~50 chars; anything huge is broken
39
+ break
40
+ finally:
41
+ try:
42
+ await stream.aclose()
43
+ except Exception:
44
+ pass
45
+ return "".join(parts)
46
+
47
+
48
+ async def judge(text: str, backend, *, timeout_s: float = JUDGE_TIMEOUT_S) -> dict:
49
+ """Judge a petition. Returns {"allowed": bool, "category": str | None}.
50
+
51
+ category is None when allowed; otherwise a short label (from the model,
52
+ or a synthetic one when the judge itself failed). Default-deny: every
53
+ failure mode — backend exception, timeout, malformed verdict — returns
54
+ {"allowed": False, ...}. This function never raises.
55
+ """
56
+ try:
57
+ raw = await asyncio.wait_for(_collect_verdict(text, backend), timeout=timeout_s)
58
+ except Exception:
59
+ return {"allowed": False, "category": "judge_error"}
60
+
61
+ obj, err = parse_moderation(raw)
62
+ if err is not None or obj is None:
63
+ return {"allowed": False, "category": "unreadable_verdict"}
64
+
65
+ if obj["allowed"]:
66
+ return {"allowed": True, "category": None}
67
+ return {"allowed": False, "category": obj["category"] or "unspecified"}
mind/planner.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Planner — NEMOCITY one-shot mind (no turn loop, ever).
2
+
3
+ grant(): ONE backend generation turns a petition into the BUILD permit JSON
4
+ (mind/prompts.py), parsed leniently (mind/validate.py), retried once at
5
+ temperature 0 ("!strict" tag) with the parse error in context, then a
6
+ deterministic fallback (one house) — a petition NEVER builds nothing. The
7
+ engine executes every act and owns all placement; the planner never touches
8
+ world state. ONE @spaces.GPU call per petition in the normal path.
9
+
10
+ Interface (mirrors godseed's planner/queue contract — the queue worker calls
11
+ grant positionally with (wish, world_summary, act, emit)):
12
+
13
+ await grant(wish, world_summary, act, emit, wish_id=None) -> trace dict
14
+ act: async (building: dict) -> observation str. Called once per
15
+ building (1..3) with the stable 5-key shape
16
+ {"kind": str, "name": str, "near": str|None,
17
+ "floors": int|None, "hue": int|None}.
18
+ emit: async (event: dict) -> None. Emitted, in order:
19
+ {"type": "plan", "plan": {...validated BUILD JSON...}} (once)
20
+ {"type": "thought_token", "text": ...} (blurb + per-act lines)
21
+ wish_id is included in the plan event only when the caller
22
+ passes it; otherwise the worker injects it before broadcast
23
+ (the godseed convention).
24
+
25
+ await fix(stats, candidates, act_fix, emit, wish_id=None) -> trace dict
26
+ act_fix: async ({"choice","diagnosis","blurb"}) -> observation str,
27
+ called exactly once. Parse failure / invalid / missing choice falls
28
+ back to the candidate with the lowest predicted_index plus a canned
29
+ diagnosis built from stats.
30
+
31
+ Trace shape (the queue worker merges wish_id/text/submitted_at/moderation/
32
+ feature_ids, exactly like godseed):
33
+
34
+ {"reading": blurb, "turns": [{"thought","call","observation"}...],
35
+ "epitaph": blurb, "ms_total": int, "model": str, "backend": str,
36
+ "raw_json": validated plan dict, "raw_text": raw model output,
37
+ "fallback": bool, "declined": bool, "decline_reason": str|None}
38
+
39
+ For fix traces reading=diagnosis and epitaph=blurb. A declined petition has
40
+ turns=[] and declined=True — the worker should emit wish_rejected with the
41
+ decline_reason instead of wish_granted.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import re
47
+ import time
48
+ from typing import Awaitable, Callable
49
+
50
+ from . import prompts
51
+ from .backends import make_backend
52
+ from .validate import BUILD_TAG, FIX_TAG, STRICT_SUFFIX, parse_build, parse_fix
53
+
54
+ Act = Callable[[dict], Awaitable[str]]
55
+ Emit = Callable[[dict], Awaitable[None]]
56
+
57
+ FALLBACK_BLURB = "The city granted a home."
58
+
59
+ _RAW_MAX_CHARS = 6000
60
+ _OBSERVATION_MAX_CHARS = 500
61
+
62
+ _STOP_WORDS = {
63
+ "a", "an", "the", "please", "build", "make", "made", "add", "want",
64
+ "wants", "need", "needs", "near", "for", "with", "of", "in", "on", "to",
65
+ "and", "my", "me", "us", "we", "i", "new", "some", "little", "big",
66
+ }
67
+
68
+
69
+ def _default_name(wish: str) -> str:
70
+ """A building name derived from the petition's own words."""
71
+ words = [w for w in re.findall(r"[A-Za-z]+", str(wish or "")) if w.lower() not in _STOP_WORDS]
72
+ base = " ".join(w.capitalize() for w in words[:2])
73
+ name = f"{base} House".strip()
74
+ return (name[:24].rstrip() or "Petition House")[:24]
75
+
76
+
77
+ def _fallback_plan(wish: str) -> dict:
78
+ """The safety net: a petition must never build nothing."""
79
+ return {
80
+ "intent": "build",
81
+ "blurb": FALLBACK_BLURB,
82
+ "buildings": [
83
+ {"kind": "house", "name": _default_name(wish), "near": None, "floors": None, "hue": None}
84
+ ],
85
+ "decline_reason": None,
86
+ }
87
+
88
+
89
+ def _thought_for(building: dict) -> str:
90
+ kind = str((building or {}).get("kind") or "building")
91
+ name = str((building or {}).get("name") or "").strip()
92
+ return f"Permit issued: {name} ({kind})." if name else f"Permit issued: a {kind}."
93
+
94
+
95
+ def _canned_fix(stats: dict, candidate: dict | None) -> tuple[str, str]:
96
+ """Deterministic diagnosis + blurb citing whatever real numbers we have."""
97
+ worst = " ".join(str(stats.get("worst") or "the worst crossing").split())[:48]
98
+ ti = stats.get("traffic_index")
99
+ pred = (candidate or {}).get("predicted_index")
100
+ desc = " ".join(str((candidate or {}).get("desc") or "").split())[:80]
101
+ if isinstance(ti, (int, float)) and not isinstance(ti, bool):
102
+ diagnosis = f"Traffic index {int(ti)}; {worst} is over capacity."
103
+ else:
104
+ diagnosis = f"The network strains at {worst}."
105
+ if isinstance(pred, (int, float)) and not isinstance(pred, bool):
106
+ diagnosis += f" {desc or 'The fix'} brings the predicted index to {int(pred)}."
107
+ blurb = f"City Engineer orders relief at {worst}."
108
+ return diagnosis[:160], blurb[:120]
109
+
110
+
111
+ class Planner:
112
+ """Grants one petition (or one traffic fix) at a time against an injected
113
+ backend. Owns prompting, parsing, and pacing; never raises for backend or
114
+ act misbehavior — it always returns a complete trace."""
115
+
116
+ BUILD_MAX_TOKENS = 700
117
+ FIX_MAX_TOKENS = 300
118
+
119
+ def __init__(self, backend=None):
120
+ self.backend = backend if backend is not None else make_backend()
121
+
122
+ # ------------------------------------------------------------------ emit
123
+
124
+ @staticmethod
125
+ async def _safe_emit(emit: Emit, event: dict) -> None:
126
+ """Broadcast failures must never kill a grant."""
127
+ try:
128
+ await emit(event)
129
+ except Exception:
130
+ pass
131
+
132
+ async def _emit_text(self, emit: Emit, text: str, *, end: str = "\n") -> None:
133
+ """Stream already-known text as small chunks (the ticker stays alive)."""
134
+ words = text.split()
135
+ step = 3
136
+ for i in range(0, len(words), step):
137
+ chunk = " ".join(words[i : i + step])
138
+ if i + step < len(words):
139
+ chunk += " "
140
+ await self._safe_emit(emit, {"type": "thought_token", "text": chunk})
141
+ if end:
142
+ await self._safe_emit(emit, {"type": "thought_token", "text": end})
143
+
144
+ async def _emit_plan(self, emit: Emit, plan: dict, wish_id: str | None) -> None:
145
+ event = {"type": "plan", "plan": plan}
146
+ if wish_id is not None:
147
+ event["wish_id"] = wish_id
148
+ await self._safe_emit(emit, event)
149
+
150
+ # --------------------------------------------------------------- collect
151
+
152
+ async def _collect(self, prompt: str, tag: str, max_tokens: int) -> str:
153
+ parts: list[str] = []
154
+ total = 0
155
+ stream = self.backend.generate_stream(prompt, tag, max_tokens)
156
+ try:
157
+ async for chunk in stream:
158
+ if not chunk:
159
+ continue
160
+ parts.append(chunk)
161
+ total += len(chunk)
162
+ if total >= _RAW_MAX_CHARS:
163
+ break
164
+ finally:
165
+ try:
166
+ await stream.aclose()
167
+ except Exception:
168
+ pass
169
+ return "".join(parts)
170
+
171
+ async def _attempt(self, prompt: str, tag: str, max_tokens: int) -> str:
172
+ try:
173
+ return await self._collect(prompt, tag, max_tokens)
174
+ except Exception:
175
+ return "" # a dead backend falls through to the parser/fallback
176
+
177
+ async def _plan_with_retry(
178
+ self, prompt: str, tag: str, max_tokens: int, parser
179
+ ) -> tuple[dict | None, str]:
180
+ """One generation + one temperature-0 retry. Returns (obj|None, raw)."""
181
+ raw = await self._attempt(prompt, tag, max_tokens)
182
+ obj, err = parser(raw)
183
+ if err is None:
184
+ return obj, raw
185
+ retry_prompt = (
186
+ f"{prompt}\n{prompts.REASK_NOTICE} ({err}). Output ONLY the JSON object."
187
+ )
188
+ raw2 = await self._attempt(retry_prompt, tag + STRICT_SUFFIX, max_tokens)
189
+ obj, err = parser(raw2)
190
+ if err is None:
191
+ return obj, raw2
192
+ return None, raw or raw2
193
+
194
+ def _trace(self, *, reading, turns, epitaph, started, raw_json, raw_text,
195
+ fallback=False, declined=False, decline_reason=None) -> dict:
196
+ return {
197
+ "reading": reading,
198
+ "turns": turns,
199
+ "epitaph": epitaph,
200
+ "ms_total": int((time.perf_counter() - started) * 1000),
201
+ "model": str(getattr(self.backend, "model_id", "unknown")),
202
+ "backend": str(getattr(self.backend, "name", "unknown")),
203
+ "raw_json": raw_json,
204
+ "raw_text": str(raw_text or "")[:_RAW_MAX_CHARS],
205
+ "fallback": fallback,
206
+ "declined": declined,
207
+ "decline_reason": decline_reason,
208
+ }
209
+
210
+ # ----------------------------------------------------------------- grant
211
+
212
+ async def grant(
213
+ self, wish: str, world_summary: str, act: Act, emit: Emit, *, wish_id: str | None = None
214
+ ) -> dict:
215
+ """Grant one petition. See the module docstring for the contract."""
216
+ started = time.perf_counter()
217
+ wish = str(wish or "")
218
+ world_summary = str(world_summary or "")
219
+
220
+ prompt = prompts.build_build_prompt(wish, world_summary)
221
+ plan, raw = await self._plan_with_retry(
222
+ prompt, BUILD_TAG, self.BUILD_MAX_TOKENS, parse_build
223
+ )
224
+ fallback = plan is None
225
+ if fallback:
226
+ plan = _fallback_plan(wish)
227
+
228
+ if plan["intent"] == "decline":
229
+ await self._emit_plan(emit, plan, wish_id)
230
+ reason = plan.get("decline_reason") or "the petition could not be read as a building"
231
+ blurb = plan.get("blurb") or "City hall declines this petition."
232
+ await self._emit_text(emit, blurb, end="\n\n")
233
+ return self._trace(
234
+ reading=blurb, turns=[], epitaph=blurb, started=started,
235
+ raw_json=plan, raw_text=raw, declined=True, decline_reason=reason,
236
+ )
237
+
238
+ for building in plan["buildings"]:
239
+ if not building.get("name"):
240
+ building["name"] = _default_name(wish)
241
+ blurb = plan.get("blurb") or FALLBACK_BLURB
242
+ plan["blurb"] = blurb
243
+
244
+ await self._emit_plan(emit, plan, wish_id)
245
+ await self._emit_text(emit, blurb, end="\n\n")
246
+
247
+ turns: list[dict] = []
248
+ for building in plan["buildings"][:3]:
249
+ thought = _thought_for(building)
250
+ await self._emit_text(emit, thought)
251
+ try:
252
+ observation = await act(building)
253
+ except Exception as exc:
254
+ observation = f"error: act failed ({type(exc).__name__})"
255
+ turns.append({
256
+ "thought": thought,
257
+ "call": dict(building),
258
+ "observation": str(observation or "")[:_OBSERVATION_MAX_CHARS],
259
+ })
260
+
261
+ return self._trace(
262
+ reading=blurb, turns=turns, epitaph=blurb, started=started,
263
+ raw_json=plan, raw_text=raw, fallback=fallback,
264
+ )
265
+
266
+ # ------------------------------------------------------------------- fix
267
+
268
+ async def fix(
269
+ self, stats, candidates, act_fix: Act, emit: Emit, *, wish_id: str | None = None
270
+ ) -> dict:
271
+ """One traffic fix: {stats, candidates} -> ONE generation -> one
272
+ act_fix call. Engine pre-validated the candidates; an invalid or
273
+ missing model choice falls back to the best predicted_index."""
274
+ started = time.perf_counter()
275
+ stats = stats if isinstance(stats, dict) else {}
276
+ candidates = [c for c in (candidates or ()) if isinstance(c, dict)]
277
+
278
+ prompt = prompts.build_fix_prompt(stats, candidates)
279
+ obj, raw = await self._plan_with_retry(
280
+ prompt, FIX_TAG, self.FIX_MAX_TOKENS, parse_fix
281
+ )
282
+
283
+ by_id = {str(c.get("id")): c for c in candidates if c.get("id")}
284
+ choice = None
285
+ if obj and obj.get("choice"):
286
+ wanted = str(obj["choice"]).strip().lower()
287
+ for cid in by_id:
288
+ if cid.lower() == wanted:
289
+ choice = cid
290
+ break
291
+
292
+ fallback = choice is None
293
+ if fallback:
294
+ def _pred(c: dict) -> float:
295
+ value = c.get("predicted_index")
296
+ ok = isinstance(value, (int, float)) and not isinstance(value, bool)
297
+ return float(value) if ok else float("inf")
298
+
299
+ best = min(candidates, key=_pred) if candidates else None
300
+ choice = str(best.get("id")) if best and best.get("id") else None
301
+ diagnosis, blurb = _canned_fix(stats, best)
302
+ else:
303
+ canned = _canned_fix(stats, by_id[choice])
304
+ diagnosis = (obj.get("diagnosis") or "").strip()[:160] or canned[0]
305
+ blurb = (obj.get("blurb") or "").strip()[:120] or canned[1]
306
+
307
+ plan = {"diagnosis": diagnosis, "choice": choice, "blurb": blurb}
308
+ await self._emit_plan(emit, plan, wish_id)
309
+ await self._emit_text(emit, diagnosis, end="\n\n")
310
+
311
+ try:
312
+ observation = await act_fix(plan)
313
+ except Exception as exc:
314
+ observation = f"error: act failed ({type(exc).__name__})"
315
+ turns = [{
316
+ "thought": diagnosis,
317
+ "call": dict(plan),
318
+ "observation": str(observation or "")[:_OBSERVATION_MAX_CHARS],
319
+ }]
320
+
321
+ return self._trace(
322
+ reading=diagnosis, turns=turns, epitaph=blurb, started=started,
323
+ raw_json=plan, raw_text=raw, fallback=fallback,
324
+ )
mind/prompts.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt construction for the NEMOCITY mind (one-shot BUILD + FIX).
2
+
3
+ Persona: a terse, warm municipal planner AI running city hall of one shared
4
+ miniature city. The engine owns every fact, coordinate, and placement; the
5
+ model only translates petitions into a tiny permit JSON (no coordinates,
6
+ ever — the "her" pattern).
7
+
8
+ Kind docs are rendered from engine/constants.py BUILDINGS + SYNONYMS when
9
+ importable (single source of truth). During the parallel build the engine
10
+ may not exist yet, so a vendored copy of the ARCHITECTURE.md table is kept
11
+ here as a fallback (see _VENDORED_* — marked DEVIATION).
12
+
13
+ Marker constants are load-bearing: MockBackend routes prompts containing
14
+ FIX_MARKER to the fix script, recovers the petition after PETITION_MARKER,
15
+ and judges moderation after CANDIDATE_MARKER. Change them only in lockstep
16
+ with mind/backends.py, mind/mock_scripts.py, and tests/test_mind.py.
17
+
18
+ GODSEED lesson kept: NO multi-example few-shot (the model copied example
19
+ values verbatim into live plans). ONE inline example shape + "your own
20
+ values, JSON only" carries the format.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+
27
+ # --------------------------------------------------------------------------
28
+ # Markers (relied upon by MockBackend and the planner's re-ask path)
29
+ # --------------------------------------------------------------------------
30
+
31
+ PETITION_MARKER = "PETITION: "
32
+ FIX_MARKER = "TRAFFIC REPORT"
33
+ CANDIDATE_MARKER = "CANDIDATE: "
34
+ REASK_NOTICE = "Your previous reply was not valid JSON"
35
+
36
+ # --------------------------------------------------------------------------
37
+ # Building table — engine/constants.py is the single source of truth
38
+ # --------------------------------------------------------------------------
39
+
40
+ KIND_ORDER: tuple[str, ...] = (
41
+ "house", "townhouse", "apartments", "cafe", "shop", "market", "bank",
42
+ "office", "tower", "school", "hospital", "fire_station", "warehouse",
43
+ "factory", "park", "plaza", "stadium", "church", "town_hall",
44
+ )
45
+
46
+ # DEVIATION: vendored copy of engine/constants.py BUILDINGS (mirrors the
47
+ # ARCHITECTURE.md table exactly). Used only while engine.constants is absent
48
+ # or incompatible — e.g. during the parallel build, or in isolated mind tests.
49
+ _VENDORED_BUILDINGS: dict[str, dict] = {
50
+ "house": {"w": 1, "d": 1, "floors": (1, 2), "residents": 4, "jobs": 0, "attract": 0, "duration_s": 20},
51
+ "townhouse": {"w": 1, "d": 1, "floors": (2, 3), "residents": 6, "jobs": 0, "attract": 0, "duration_s": 20},
52
+ "apartments": {"w": 2, "d": 2, "floors": (4, 7), "residents": 16, "jobs": 0, "attract": 0, "duration_s": 45},
53
+ "cafe": {"w": 1, "d": 1, "floors": (1, 1), "residents": 0, "jobs": 3, "attract": 5, "duration_s": 20},
54
+ "shop": {"w": 1, "d": 1, "floors": (1, 2), "residents": 0, "jobs": 4, "attract": 4, "duration_s": 20},
55
+ "market": {"w": 1, "d": 2, "floors": (1, 1), "residents": 0, "jobs": 8, "attract": 6, "duration_s": 30},
56
+ "bank": {"w": 2, "d": 1, "floors": (2, 4), "residents": 0, "jobs": 12, "attract": 2, "duration_s": 30},
57
+ "office": {"w": 1, "d": 1, "floors": (3, 8), "residents": 0, "jobs": 40, "attract": 1, "duration_s": 30},
58
+ "tower": {"w": 2, "d": 2, "floors": (8, 16), "residents": 0, "jobs": 60, "attract": 2, "duration_s": 45},
59
+ "school": {"w": 2, "d": 2, "floors": (1, 2), "residents": 0, "jobs": 10, "attract": 3, "duration_s": 30},
60
+ "hospital": {"w": 3, "d": 3, "floors": (3, 6), "residents": 0, "jobs": 25, "attract": 2, "duration_s": 45},
61
+ "fire_station": {"w": 2, "d": 1, "floors": (2, 2), "residents": 0, "jobs": 8, "attract": 1, "duration_s": 30},
62
+ "warehouse": {"w": 2, "d": 2, "floors": (1, 1), "residents": 0, "jobs": 10, "attract": 0, "duration_s": 30},
63
+ "factory": {"w": 3, "d": 2, "floors": (1, 2), "residents": 0, "jobs": 18, "attract": 0, "duration_s": 45},
64
+ "park": {"w": 2, "d": 2, "floors": (0, 0), "residents": 0, "jobs": 0, "attract": 8, "duration_s": 20},
65
+ "plaza": {"w": 1, "d": 1, "floors": (0, 0), "residents": 0, "jobs": 0, "attract": 6, "duration_s": 20},
66
+ "stadium": {"w": 4, "d": 4, "floors": (1, 1), "residents": 0, "jobs": 15, "attract": 9, "duration_s": 45},
67
+ "church": {"w": 2, "d": 1, "floors": (1, 1), "residents": 0, "jobs": 2, "attract": 3, "duration_s": 30},
68
+ "town_hall": {"w": 2, "d": 2, "floors": (2, 2), "residents": 0, "jobs": 6, "attract": 4, "duration_s": 45},
69
+ }
70
+
71
+ # DEVIATION: vendored synonym map (engine/tools.py owns the live one).
72
+ _VENDORED_SYNONYMS: dict[str, str] = {
73
+ "skyscraper": "tower", "highrise": "tower",
74
+ "apartment": "apartments", "apartment building": "apartments",
75
+ "condo": "apartments", "hotel": "apartments",
76
+ "coffee shop": "cafe", "diner": "cafe", "restaurant": "cafe", "bakery": "cafe",
77
+ "store": "shop", "boutique": "shop", "pharmacy": "shop", "gym": "shop",
78
+ "mall": "market", "grocery": "market", "supermarket": "market",
79
+ "police station": "fire_station", "police": "fire_station",
80
+ "library": "town_hall", "museum": "town_hall", "city hall": "town_hall",
81
+ "temple": "church", "mosque": "church", "chapel": "church",
82
+ "garden": "park", "playground": "park",
83
+ "fountain": "plaza", "square": "plaza",
84
+ "arena": "stadium",
85
+ "clinic": "hospital",
86
+ "plant": "factory",
87
+ }
88
+
89
+
90
+ def _load_tables() -> tuple[dict, dict, bool]:
91
+ """engine.constants BUILDINGS + SYNONYMS, vendored copies as fallback."""
92
+ buildings = synonyms = None
93
+ try:
94
+ from engine import constants as _c # type: ignore
95
+
96
+ buildings = getattr(_c, "BUILDINGS", None)
97
+ synonyms = getattr(_c, "SYNONYMS", None)
98
+ except Exception:
99
+ # engine/__init__.py may be mid-write during the parallel build while
100
+ # constants.py itself is already valid — load the file directly.
101
+ try:
102
+ import importlib.util
103
+ from pathlib import Path
104
+
105
+ path = Path(__file__).resolve().parents[1] / "engine" / "constants.py"
106
+ if path.exists():
107
+ spec = importlib.util.spec_from_file_location("_nemocity_constants", path)
108
+ mod = importlib.util.module_from_spec(spec)
109
+ spec.loader.exec_module(mod) # type: ignore[union-attr]
110
+ buildings = getattr(mod, "BUILDINGS", None)
111
+ synonyms = getattr(mod, "SYNONYMS", None)
112
+ except Exception:
113
+ pass
114
+ if not isinstance(buildings, dict) or len(buildings) < 10:
115
+ return dict(_VENDORED_BUILDINGS), dict(_VENDORED_SYNONYMS), False
116
+ if not isinstance(synonyms, dict) or not synonyms:
117
+ try:
118
+ from engine import tools as _t # type: ignore
119
+
120
+ synonyms = getattr(_t, "SYNONYMS", None)
121
+ except Exception:
122
+ synonyms = None
123
+ if not isinstance(synonyms, dict) or not synonyms:
124
+ synonyms = dict(_VENDORED_SYNONYMS)
125
+ return buildings, synonyms, True
126
+
127
+
128
+ BUILDINGS, SYNONYMS, TABLES_FROM_ENGINE = _load_tables()
129
+
130
+
131
+ def kind_names() -> tuple[str, ...]:
132
+ ordered = tuple(k for k in KIND_ORDER if k in BUILDINGS)
133
+ return ordered or tuple(BUILDINGS)
134
+
135
+
136
+ def _spec_field(spec, names: tuple[str, ...], default=None):
137
+ if isinstance(spec, dict):
138
+ for n in names:
139
+ if spec.get(n) is not None:
140
+ return spec[n]
141
+ else:
142
+ for n in names:
143
+ value = getattr(spec, n, None)
144
+ if value is not None:
145
+ return value
146
+ return default
147
+
148
+
149
+ def _floors_range(spec) -> tuple[int, int]:
150
+ value = _spec_field(spec, ("floors",))
151
+ if isinstance(value, (list, tuple)) and len(value) == 2:
152
+ return int(value[0]), int(value[1])
153
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
154
+ return int(value), int(value)
155
+ lo = _spec_field(spec, ("floors_min", "min_floors"))
156
+ hi = _spec_field(spec, ("floors_max", "max_floors"))
157
+ if lo is not None and hi is not None:
158
+ return int(lo), int(hi)
159
+ return 1, 1
160
+
161
+
162
+ def render_kind_docs() -> str:
163
+ """One terse model-readable line per kind, from the engine table."""
164
+ lines = []
165
+ for kind in kind_names():
166
+ spec = BUILDINGS[kind]
167
+ try:
168
+ w = int(_spec_field(spec, ("w", "width"), 1))
169
+ d = int(_spec_field(spec, ("d", "depth"), 1))
170
+ lo, hi = _floors_range(spec)
171
+ residents = int(_spec_field(spec, ("residents",), 0) or 0)
172
+ jobs = int(_spec_field(spec, ("jobs",), 0) or 0)
173
+ attract = int(_spec_field(spec, ("attract",), 0) or 0)
174
+ except Exception:
175
+ lines.append(f"- {kind}")
176
+ continue
177
+ bits = [f"{w}x{d}"]
178
+ if hi > lo:
179
+ bits.append(f"floors {lo}-{hi}")
180
+ elif lo:
181
+ bits.append(f"floors {lo}")
182
+ else:
183
+ bits.append("open ground")
184
+ if residents:
185
+ bits.append(f"{residents} residents")
186
+ if jobs:
187
+ bits.append(f"{jobs} jobs")
188
+ if attract:
189
+ bits.append(f"draw {attract}")
190
+ lines.append(f"- {kind}: " + ", ".join(bits))
191
+ return "\n".join(lines)
192
+
193
+
194
+ def render_synonym_line() -> str:
195
+ groups: dict[str, list[str]] = {}
196
+ for src, dst in SYNONYMS.items():
197
+ groups.setdefault(str(dst), []).append(str(src))
198
+ parts = [f"{'/'.join(sorted(srcs))} -> {dst}" for dst, srcs in sorted(groups.items())]
199
+ return "Synonyms the engine repairs: " + "; ".join(parts) + "; anything unknown -> house."
200
+
201
+
202
+ # --------------------------------------------------------------------------
203
+ # Personas & prompt builders
204
+ # --------------------------------------------------------------------------
205
+
206
+ PERSONA = """You are City Hall of NEMOCITY, one shared miniature city — a terse, warm
207
+ municipal planner AI. The deterministic engine owns every fact, map, and
208
+ placement; you NEVER output coordinates. You only translate a visitor's
209
+ petition into a small building permit. Build generously: a district,
210
+ neighborhood, or "more homes" petition deserves 2-3 buildings. Never refuse
211
+ a buildable thing — map strange asks to the closest kind on the list.
212
+ Decline only pure nonsense."""
213
+
214
+ _MAX_PETITION_CHARS = 160
215
+ _MAX_SUMMARY_CHARS = 600
216
+ _MAX_STATS_CHARS = 500
217
+ _MAX_ERROR_CHARS = 160
218
+
219
+
220
+ def _one_line(text, limit: int) -> str:
221
+ """Collapse whitespace/newlines to one bounded line (prompt hygiene)."""
222
+ return " ".join(str(text or "").split())[:limit]
223
+
224
+
225
+ def build_build_prompt(petition: str, world_summary: str) -> str:
226
+ """The whole petition in ONE model call -> the BUILD permit JSON."""
227
+ petition_line = _one_line(petition, _MAX_PETITION_CHARS) or "a small building"
228
+ summary = _one_line(world_summary, _MAX_SUMMARY_CHARS) or (
229
+ "The city is young; a few blocks stand by the river."
230
+ )
231
+ return (
232
+ f"{PERSONA}\n\n"
233
+ f'KINDS (the only legal "kind" values):\n'
234
+ f"{render_kind_docs()}\n"
235
+ f"{render_synonym_line()}\n\n"
236
+ f"The city today: {summary}\n\n"
237
+ f"{PETITION_MARKER}{petition_line}\n\n"
238
+ "GRANT IT in ONE reply: a single JSON object, nothing else. Example of "
239
+ "the SHAPE only (its values belong to no petition; never copy them):\n"
240
+ '{"intent":"build","blurb":"A sunny corner cafe joins the park block.",'
241
+ '"buildings":[{"kind":"cafe","name":"Cafe Luna","near":"the park",'
242
+ '"floors":1,"hue":35}],"decline_reason":null}\n'
243
+ "Rules: 1 to 3 buildings. kind from the list above. name <= 24 chars, "
244
+ 'give it personality. near = a landmark, street, or "downtown" (free '
245
+ "text; the engine resolves and places everything). floors and hue are "
246
+ "optional hints. blurb <= 140 chars, warm city-planner voice. Set "
247
+ 'intent "decline" with a decline_reason ONLY for pure nonsense. '
248
+ "Your own values, JSON only:\n"
249
+ )
250
+
251
+
252
+ def _render_candidates(candidates) -> str:
253
+ # Line format "- F1: desc (predicted index 41)" is parsed back by
254
+ # mind/mock_scripts.fix_script — change only in lockstep.
255
+ lines = []
256
+ for i, c in enumerate(candidates or ()):
257
+ c = c if isinstance(c, dict) else {}
258
+ cid = str(c.get("id") or f"F{i + 1}")
259
+ desc = _one_line(c.get("desc") or c.get("action") or "engine-routed fix", 80)
260
+ pred = c.get("predicted_index")
261
+ suffix = ""
262
+ if isinstance(pred, (int, float)) and not isinstance(pred, bool):
263
+ suffix = f" (predicted index {int(pred)})"
264
+ lines.append(f"- {cid}: {desc}{suffix}")
265
+ return "\n".join(lines) or "- F1: engine-routed bypass road"
266
+
267
+
268
+ def build_fix_prompt(stats, candidates) -> str:
269
+ """{stats, candidates} in ONE model call -> the FIX choice JSON."""
270
+ try:
271
+ stats_line = json.dumps(stats, separators=(",", ":"), ensure_ascii=False)
272
+ except (TypeError, ValueError):
273
+ stats_line = str(stats)
274
+ stats_line = _one_line(stats_line, _MAX_STATS_CHARS)
275
+ return (
276
+ "You are the City Engineer of NEMOCITY, a terse municipal traffic AI. "
277
+ "The deterministic engine measured the road network; every number "
278
+ "below is real. Pick exactly ONE candidate fix by its id and justify "
279
+ "it by citing the real numbers (street names, ratios, indexes) from "
280
+ "the report.\n\n"
281
+ f"{FIX_MARKER}: {stats_line}\n"
282
+ "CANDIDATE FIXES (pre-validated by the engine; choose one id):\n"
283
+ f"{_render_candidates(candidates)}\n\n"
284
+ "Reply with ONE JSON object, nothing else. Example of the SHAPE only "
285
+ "(never copy its values):\n"
286
+ '{"diagnosis":"Old Bridge carries 3.1x capacity at rush; a second '
287
+ 'crossing cuts the index 78 to 41.","choice":"F1","blurb":"Relief is '
288
+ 'coming to Old Bridge."}\n'
289
+ "diagnosis <= 160 chars and MUST cite the report's real numbers. "
290
+ "choice = one candidate id. blurb <= 120 chars, news-headline voice. "
291
+ "Your own values, JSON only:\n"
292
+ )
293
+
294
+
295
+ def build_moderation_prompt(text: str) -> str:
296
+ """Prompt for the moderation judge (tag-constrained verdict)."""
297
+ candidate = _one_line(text, _MAX_PETITION_CHARS)
298
+ return (
299
+ "You are the gatekeeper of one small public city, seen by children "
300
+ "and strangers.\n"
301
+ "Judge only the text below — a building petition someone wants "
302
+ "granted in the city. Disallow: hate, slurs, harassment, sexual "
303
+ "content, anything sexual about minors, gore, encouragement of "
304
+ "self-harm, doxxing, spam, and hate symbols or instructions to shape "
305
+ "the city into them.\n"
306
+ "Playful nonsense, mild grumbling, and strange asks are allowed.\n\n"
307
+ f"{CANDIDATE_MARKER}{candidate}\n\n"
308
+ 'Reply with exactly one JSON object: {"allowed": true|false, '
309
+ '"category": "<short label, empty if allowed>"}\n'
310
+ "JUDGMENT: "
311
+ )
mind/validate.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lenient JSON validation for the NEMOCITY mind (one-shot BUILD + FIX).
2
+
3
+ NEMOCITY has no GBNF grammars: every backend generates free text and the
4
+ parsers here recover the JSON. The old `grammar` parameter on the Backend
5
+ protocol is repurposed as a plain string TAG telling the backend which shape
6
+ is wanted (temperature + validation), never an actual grammar:
7
+
8
+ BUILD_TAG "build" BUILD permit JSON
9
+ FIX_TAG "fix" FIX choice JSON
10
+ MODERATION_TAG "moderation" moderation verdict JSON
11
+ None free text
12
+ "<tag>!strict" retry pass: temperature 0
13
+
14
+ Parsing is the same strictness for every backend: mock output is trusted but
15
+ parsed identically so the pipeline never special-cases a backend. The engine
16
+ does ALL semantic clamping (kind synonyms, floors ranges, placement) — these
17
+ parsers only guarantee structure.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+
25
+ BUILD_TAG = "build"
26
+ FIX_TAG = "fix"
27
+ MODERATION_TAG = "moderation"
28
+ STRICT_SUFFIX = "!strict"
29
+
30
+ # Name kept from godseed so copied call sites (moderation_judge) still work;
31
+ # it is a tag now, not a grammar.
32
+ MODERATION_GRAMMAR = MODERATION_TAG
33
+
34
+ NAME_MAX_CHARS = 24
35
+ NEAR_MAX_CHARS = 60
36
+ BLURB_MAX_CHARS = 140
37
+ DIAGNOSIS_MAX_CHARS = 160
38
+ FIX_BLURB_MAX_CHARS = 120
39
+ DECLINE_MAX_CHARS = 160
40
+ MAX_BUILDINGS = 3
41
+
42
+
43
+ def is_moderation_grammar(grammar: str | None) -> bool:
44
+ return bool(grammar) and (
45
+ str(grammar).split("!", 1)[0] == MODERATION_TAG or '"allowed"' in str(grammar)
46
+ )
47
+
48
+
49
+ def base_tag(grammar: str | None) -> str | None:
50
+ """Tag without the !strict retry suffix."""
51
+ return None if grammar is None else str(grammar).split("!", 1)[0]
52
+
53
+
54
+ def is_strict(grammar: str | None) -> bool:
55
+ return bool(grammar) and str(grammar).endswith(STRICT_SUFFIX)
56
+
57
+
58
+ _THINK_RE = re.compile(r"<think>.*?</think>", re.S)
59
+ _FENCE_RE = re.compile(r"```[a-zA-Z]*")
60
+
61
+
62
+ def strip_noise(raw: str) -> str:
63
+ """Drop <think> blocks and markdown code fences before brace-scanning."""
64
+ text = _THINK_RE.sub("", str(raw or ""))
65
+ text = text.replace("<think>", "").replace("</think>", "")
66
+ return _FENCE_RE.sub("", text)
67
+
68
+
69
+ def extract_json_object(raw: str) -> str | None:
70
+ """Return the first balanced top-level {...} in `raw`, or None.
71
+
72
+ Brace-scans with string/escape awareness so JSON embedded in stray prose
73
+ (a misbehaving unconstrained backend) is still recoverable.
74
+ """
75
+ start = raw.find("{")
76
+ if start < 0:
77
+ return None
78
+ depth = 0
79
+ in_string = False
80
+ escaped = False
81
+ for i in range(start, len(raw)):
82
+ ch = raw[i]
83
+ if in_string:
84
+ if escaped:
85
+ escaped = False
86
+ elif ch == "\\":
87
+ escaped = True
88
+ elif ch == '"':
89
+ in_string = False
90
+ continue
91
+ if ch == '"':
92
+ in_string = True
93
+ elif ch == "{":
94
+ depth += 1
95
+ elif ch == "}":
96
+ depth -= 1
97
+ if depth == 0:
98
+ return raw[start : i + 1]
99
+ return None
100
+
101
+
102
+ def _one_line(text: str, limit: int) -> str:
103
+ return " ".join(str(text or "").split())[:limit]
104
+
105
+
106
+ def _as_int(value) -> int | None:
107
+ if isinstance(value, bool):
108
+ return None
109
+ if isinstance(value, int):
110
+ return value
111
+ if isinstance(value, float):
112
+ return int(round(value))
113
+ if isinstance(value, str):
114
+ try:
115
+ return int(round(float(value.strip())))
116
+ except ValueError:
117
+ return None
118
+ return None
119
+
120
+
121
+ def _coerce_building(item) -> dict | None:
122
+ """One BUILD entry, normalized to a stable 5-key shape; None if unusable.
123
+
124
+ kind must be a non-empty string (the engine maps synonyms / unknowns).
125
+ Unknown keys are dropped; types are coerced; name/near are whitespace-
126
+ collapsed and clipped.
127
+ """
128
+ if not isinstance(item, dict):
129
+ return None
130
+ kind = item.get("kind")
131
+ if not isinstance(kind, str) or not kind.strip():
132
+ return None
133
+ name = item.get("name")
134
+ name = _one_line(name, NAME_MAX_CHARS) if isinstance(name, str) and name.strip() else None
135
+ near = item.get("near")
136
+ near = _one_line(near, NEAR_MAX_CHARS) if isinstance(near, str) and near.strip() else None
137
+ return {
138
+ "kind": _one_line(kind.lower(), 40),
139
+ "name": name,
140
+ "near": near,
141
+ "floors": _as_int(item.get("floors")),
142
+ "hue": _as_int(item.get("hue")),
143
+ }
144
+
145
+
146
+ _BUILDING_FRAGMENT_RE = re.compile(r'\{[^{}]*"kind"\s*:\s*"')
147
+
148
+
149
+ def parse_build(raw: str) -> tuple[dict | None, str | None]:
150
+ """Parse a one-shot BUILD reply. Returns (plan, None) or (None, error).
151
+
152
+ Plan shape (always fully normalized):
153
+ {"intent": "build"|"decline", "blurb": str,
154
+ "buildings": [entry x0..3], "decline_reason": str|None}
155
+
156
+ Lenient by design: code fences and <think> blocks are stripped, the outer
157
+ object is balanced-brace extracted, then salvaged key by key. When the
158
+ outer JSON is broken, individual {"kind": ...} objects are scraped from
159
+ the raw text so a truncated array still yields buildings.
160
+ """
161
+ if not raw or not str(raw).strip():
162
+ return None, "empty output"
163
+ text = strip_noise(str(raw))
164
+
165
+ obj = None
166
+ candidate = extract_json_object(text)
167
+ if candidate is not None:
168
+ try:
169
+ obj = json.loads(candidate)
170
+ except (json.JSONDecodeError, ValueError):
171
+ obj = None
172
+
173
+ intent = "build"
174
+ blurb = ""
175
+ decline_reason = None
176
+ buildings: list[dict] = []
177
+ if isinstance(obj, dict):
178
+ if obj.get("intent") in ("build", "decline"):
179
+ intent = obj["intent"]
180
+ b = obj.get("blurb")
181
+ if isinstance(b, str):
182
+ blurb = _one_line(b, BLURB_MAX_CHARS)
183
+ dr = obj.get("decline_reason")
184
+ if isinstance(dr, str) and dr.strip():
185
+ decline_reason = _one_line(dr, DECLINE_MAX_CHARS)
186
+ arr = obj.get("buildings")
187
+ if isinstance(arr, list):
188
+ for item in arr:
189
+ entry = _coerce_building(item)
190
+ if entry is not None:
191
+ buildings.append(entry)
192
+
193
+ # Salvage: broken outer object / broken array — scrape balanced
194
+ # {"kind": ...} fragments straight from the text.
195
+ if not buildings:
196
+ for m in _BUILDING_FRAGMENT_RE.finditer(text):
197
+ frag = extract_json_object(text[m.start():])
198
+ if not frag:
199
+ continue
200
+ try:
201
+ item = json.loads(frag)
202
+ except (json.JSONDecodeError, ValueError):
203
+ continue
204
+ entry = _coerce_building(item)
205
+ if entry is not None:
206
+ buildings.append(entry)
207
+
208
+ buildings = buildings[:MAX_BUILDINGS]
209
+ if intent == "decline" and not buildings:
210
+ return {
211
+ "intent": "decline",
212
+ "blurb": blurb,
213
+ "buildings": [],
214
+ "decline_reason": decline_reason or "the petition could not be read as a building",
215
+ }, None
216
+ if not buildings:
217
+ return None, "no usable BUILD JSON"
218
+ return {"intent": "build", "blurb": blurb, "buildings": buildings, "decline_reason": None}, None
219
+
220
+
221
+ _FIX_CHOICE_RE = re.compile(r'"choice"\s*:\s*"([^"]{1,12})"')
222
+ _FIX_DIAGNOSIS_RE = re.compile(r'"diagnosis"\s*:\s*"([^"]{1,300})"')
223
+ _FIX_BLURB_RE = re.compile(r'"blurb"\s*:\s*"([^"]{1,200})"')
224
+
225
+
226
+ def parse_fix(raw: str) -> tuple[dict | None, str | None]:
227
+ """Parse a one-shot FIX reply. Returns (obj, None) or (None, error).
228
+
229
+ Obj shape: {"diagnosis": str, "choice": str|None, "blurb": str}.
230
+ choice is NOT validated against the candidate list here — the planner
231
+ does that and falls back to the best predicted candidate.
232
+ """
233
+ if not raw or not str(raw).strip():
234
+ return None, "empty output"
235
+ text = strip_noise(str(raw))
236
+
237
+ diagnosis = choice = blurb = None
238
+ candidate = extract_json_object(text)
239
+ if candidate is not None:
240
+ try:
241
+ obj = json.loads(candidate)
242
+ except (json.JSONDecodeError, ValueError):
243
+ obj = None
244
+ if isinstance(obj, dict):
245
+ d = obj.get("diagnosis")
246
+ if isinstance(d, str) and d.strip():
247
+ diagnosis = _one_line(d, DIAGNOSIS_MAX_CHARS)
248
+ c = obj.get("choice")
249
+ if isinstance(c, (str, int)) and not isinstance(c, bool) and str(c).strip():
250
+ choice = str(c).strip()[:12]
251
+ b = obj.get("blurb")
252
+ if isinstance(b, str) and b.strip():
253
+ blurb = _one_line(b, FIX_BLURB_MAX_CHARS)
254
+
255
+ if diagnosis is None and choice is None:
256
+ # Key-by-key salvage from a broken outer object.
257
+ m = _FIX_CHOICE_RE.search(text)
258
+ choice = m.group(1).strip() if m else None
259
+ m = _FIX_DIAGNOSIS_RE.search(text)
260
+ diagnosis = _one_line(m.group(1), DIAGNOSIS_MAX_CHARS) if m else None
261
+ if blurb is None:
262
+ m = _FIX_BLURB_RE.search(text)
263
+ blurb = _one_line(m.group(1), FIX_BLURB_MAX_CHARS) if m else None
264
+
265
+ if diagnosis is None and choice is None:
266
+ return None, "no usable FIX JSON"
267
+ return {"diagnosis": diagnosis or "", "choice": choice, "blurb": blurb or ""}, None
268
+
269
+
270
+ def parse_moderation(raw: str) -> tuple[dict | None, str | None]:
271
+ """Parse a moderation verdict. Returns (obj, None) or (None, error).
272
+
273
+ `allowed` must be a real JSON boolean — anything fuzzier than that is a
274
+ parse error, and parse errors mean DENY upstream (default-deny).
275
+ """
276
+ if not raw or not raw.strip():
277
+ return None, "empty output"
278
+ candidate = extract_json_object(strip_noise(raw))
279
+ if candidate is None:
280
+ return None, "no JSON object found"
281
+ try:
282
+ obj = json.loads(candidate)
283
+ except (json.JSONDecodeError, ValueError) as exc:
284
+ return None, f"invalid JSON: {exc.msg if hasattr(exc, 'msg') else exc}"
285
+ if not isinstance(obj, dict):
286
+ return None, "top level is not a JSON object"
287
+ allowed = obj.get("allowed")
288
+ if not isinstance(allowed, bool):
289
+ return None, '"allowed" is not a boolean'
290
+ category = obj.get("category")
291
+ if category is not None and not isinstance(category, str):
292
+ return None, '"category" is not a string'
293
+ return {"allowed": allowed, "category": (category or "").strip()[:40]}, None
package.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "nemocity",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Tooling for the NEMOCITY web client (tools/shot.mjs headless screenshots). web/ itself is plain ES modules with vendored three. If node_modules is absent, shot.mjs falls back to ../glassblower/node_modules/playwright.",
6
+ "scripts": {
7
+ "shot": "node tools/shot.mjs"
8
+ },
9
+ "devDependencies": {
10
+ "playwright": "^1.60.0"
11
+ }
12
+ }
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NEMOCITY deps. Never pin gradio/spaces/torch — the Spaces image force-installs
2
+ # them to match the README `sdk_version` (here 6.16.0, the pre-SSR build proven
3
+ # live on the org ZeroGPU by pareidolia with this exact gr.Server launch pattern).
4
+ #
5
+ # Stage A (CITY_BACKEND=mock): the gorgeous zero-GPU city needs NO ML deps — only
6
+ # the web/API stack below. gradio itself comes from the image.
7
+ fastapi>=0.115
8
+ uvicorn>=0.30
9
+ pydantic>=2.7
10
+ huggingface_hub>=0.30
11
+
12
+ # Stage B (CITY_BACKEND=zerogpu): uncomment for the live Nemotron-Nano-9B-v2 path.
13
+ # nemotron_h is in-tree from transformers 4.55.4; <5 keeps hub<1.0 compatible with
14
+ # gradio 6.16.0 (the documented working combo). spaces/torch come from the image.
15
+ # transformers>=4.56,<5
16
+ # accelerate>=0.34.0
17
+ # einops
18
+ # sentencepiece
server/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """NEMOCITY server package — HTTP/SSE transport layer (AGENT-SERVER).
2
+
3
+ Modules:
4
+ schemas pydantic request/response models for the HTTP API
5
+ sse asyncio broadcast hub for /api/stream
6
+ ratelimit signed-cookie client identity + sliding-window rate limit
7
+ persistence traces/wishes.jsonl store, boot rebuild, optional HF dataset sync
8
+ """
server/persistence.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trace persistence + boot-time world rebuild + optional HF dataset sync.
2
+
3
+ The single source of durable truth is ``traces/wishes.jsonl``: one JSON line per
4
+ granted petition (the WishTrace of ARCHITECTURE.md, extended with a ``features``
5
+ array — the engine-clamped, seeded events the petition produced — so the city can be
6
+ rebuilt exactly on boot: genesis + every trace's features, in order).
7
+
8
+ Dataset sync (both persistence across Space restarts and the published agent-trace
9
+ evidence): when ``HF_TOKEN`` and ``CITY_DATASET`` are set, wishes.jsonl is
10
+ downloaded on boot if the local file is missing and uploaded after each granted
11
+ petition. Every network error is swallowed and logged — the city must never die of
12
+ a flaky connection.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import logging
20
+ import os
21
+ import threading
22
+ from pathlib import Path
23
+ from typing import Any, Optional
24
+
25
+ log = logging.getLogger("nemocity.persistence")
26
+
27
+ TRACES_FILENAME = "wishes.jsonl"
28
+
29
+
30
+ def trace_features(trace: dict[str, Any]) -> list[dict[str, Any]]:
31
+ """The full features stored on a trace line (``[]`` for lines without them)."""
32
+ features = trace.get("features")
33
+ if isinstance(features, list):
34
+ return [f for f in features if isinstance(f, dict)]
35
+ return []
36
+
37
+
38
+ def rebuild_features(
39
+ genesis: list[dict[str, Any]], traces: list[dict[str, Any]]
40
+ ) -> list[dict[str, Any]]:
41
+ """Ordered feature list for boot: genesis + each trace's features, in order."""
42
+ features: list[dict[str, Any]] = [dict(f) for f in genesis]
43
+ for trace in traces:
44
+ found = trace_features(trace)
45
+ if not found and trace.get("feature_ids"):
46
+ # A pre-extension line: its features can't be replayed; the world simply
47
+ # rebuilds without them rather than refusing to boot.
48
+ log.warning(
49
+ "trace %s carries feature_ids but no features; skipped in rebuild",
50
+ trace.get("wish_id"),
51
+ )
52
+ features.extend(found)
53
+ return features
54
+
55
+
56
+ class TraceStore:
57
+ """Append-only JSONL store under ``traces_dir``. Thread-safe appends (uploads
58
+ run in a worker thread; appends may too, depending on the engine)."""
59
+
60
+ def __init__(self, traces_dir: Path | str, filename: str = TRACES_FILENAME) -> None:
61
+ self.dir = Path(traces_dir)
62
+ self.dir.mkdir(parents=True, exist_ok=True)
63
+ self.path = self.dir / filename
64
+ self._lock = threading.Lock()
65
+
66
+ def append(
67
+ self, trace: dict[str, Any], features: Optional[list[dict[str, Any]]] = None
68
+ ) -> dict[str, Any]:
69
+ """Write one trace line; returns the dict actually written (trace + features)."""
70
+ line_obj = dict(trace)
71
+ if features is not None:
72
+ line_obj["features"] = features
73
+ encoded = json.dumps(line_obj, ensure_ascii=False)
74
+ with self._lock:
75
+ with self.path.open("a", encoding="utf-8") as fh:
76
+ fh.write(encoded + "\n")
77
+ return line_obj
78
+
79
+ def load(self) -> list[dict[str, Any]]:
80
+ """All trace lines, in order. Corrupt lines are skipped with a warning (a
81
+ half-written final line after a crash must not brick the boot)."""
82
+ if not self.path.exists():
83
+ return []
84
+ traces: list[dict[str, Any]] = []
85
+ with self.path.open("r", encoding="utf-8") as fh:
86
+ for lineno, raw in enumerate(fh, start=1):
87
+ raw = raw.strip()
88
+ if not raw:
89
+ continue
90
+ try:
91
+ obj = json.loads(raw)
92
+ except json.JSONDecodeError:
93
+ log.warning("skipping corrupt trace line %d in %s", lineno, self.path)
94
+ continue
95
+ if isinstance(obj, dict):
96
+ traces.append(obj)
97
+ return traces
98
+
99
+
100
+ class DatasetSync:
101
+ """Mirror wishes.jsonl to/from an HF dataset repo. Inert unless both HF_TOKEN
102
+ and CITY_DATASET are set; never raises for network problems."""
103
+
104
+ def __init__(
105
+ self,
106
+ store: TraceStore,
107
+ token: Optional[str] = None,
108
+ dataset: Optional[str] = None,
109
+ ) -> None:
110
+ self.store = store
111
+ self.token = token if token is not None else os.environ.get("HF_TOKEN")
112
+ self.dataset = dataset if dataset is not None else os.environ.get("CITY_DATASET")
113
+
114
+ @property
115
+ def enabled(self) -> bool:
116
+ return bool(self.token and self.dataset)
117
+
118
+ def download_if_missing(self) -> bool:
119
+ """Fetch wishes.jsonl from the dataset when the local file doesn't exist
120
+ (fresh Space boot). Returns True only if a file was restored."""
121
+ if not self.enabled or self.store.path.exists():
122
+ return False
123
+ try:
124
+ from huggingface_hub import hf_hub_download
125
+
126
+ fetched = hf_hub_download(
127
+ repo_id=self.dataset,
128
+ repo_type="dataset",
129
+ filename=self.store.path.name,
130
+ token=self.token,
131
+ )
132
+ self.store.path.write_bytes(Path(fetched).read_bytes())
133
+ log.info("restored %s from dataset %s", self.store.path.name, self.dataset)
134
+ return True
135
+ except Exception as exc: # noqa: BLE001 — sync must never crash the app
136
+ log.warning("dataset download skipped (%s): %s", self.dataset, exc)
137
+ return False
138
+
139
+ def upload(self) -> bool:
140
+ """Push the current wishes.jsonl to the dataset. Returns success; the next
141
+ granted wish retries naturally on failure."""
142
+ if not self.enabled or not self.store.path.exists():
143
+ return False
144
+ try:
145
+ from huggingface_hub import HfApi
146
+
147
+ HfApi(token=self.token).upload_file(
148
+ path_or_fileobj=str(self.store.path),
149
+ path_in_repo=self.store.path.name,
150
+ repo_id=self.dataset,
151
+ repo_type="dataset",
152
+ commit_message="nemocity: petition granted",
153
+ )
154
+ return True
155
+ except Exception as exc: # noqa: BLE001 — sync must never crash the app
156
+ log.warning("dataset upload failed (next wish retries): %s", exc)
157
+ return False
158
+
159
+
160
+ class PersistenceService:
161
+ """What the rest of the app talks to.
162
+
163
+ Boot: ``features = service.boot(genesis_features)`` (download-if-missing +
164
+ rebuild). Afterwards ``service.traces()`` serves the ledger from
165
+ memory — no file reads per request.
166
+ Wish: ``await service.record(trace, features)`` — append + fire-and-forget
167
+ dataset upload off the event loop.
168
+ """
169
+
170
+ def __init__(
171
+ self, traces_dir: Path | str, sync: Optional[DatasetSync] = None
172
+ ) -> None:
173
+ self.store = TraceStore(traces_dir)
174
+ self.sync = sync if sync is not None else DatasetSync(self.store)
175
+ self._traces: list[dict[str, Any]] = []
176
+ self._booted = False
177
+ self._upload_tasks: set[asyncio.Task[Any]] = set()
178
+
179
+ @property
180
+ def path(self) -> Path:
181
+ return self.store.path
182
+
183
+ def boot(self, genesis: list[dict[str, Any]]) -> list[dict[str, Any]]:
184
+ """Restore (if configured) + load traces + return the full ordered feature
185
+ list (genesis + every trace's features)."""
186
+ self.sync.download_if_missing()
187
+ self._traces = self.store.load()
188
+ self._booted = True
189
+ return rebuild_features(genesis, self._traces)
190
+
191
+ def traces(self) -> list[dict[str, Any]]:
192
+ """All granted-wish traces in grant order (loads lazily if boot was skipped)."""
193
+ if not self._booted:
194
+ self._traces = self.store.load()
195
+ self._booted = True
196
+ return self._traces
197
+
198
+ def find(self, wish_id: str) -> Optional[dict[str, Any]]:
199
+ for trace in self.traces():
200
+ if trace.get("wish_id") == wish_id:
201
+ return trace
202
+ return None
203
+
204
+ async def record(
205
+ self, trace: dict[str, Any], features: Optional[list[dict[str, Any]]] = None
206
+ ) -> dict[str, Any]:
207
+ """Persist one granted wish, then upload to the dataset in the background.
208
+ Never raises for sync problems; the append itself is the only hard step."""
209
+ line_obj = self.store.append(trace, features)
210
+ self.traces().append(line_obj)
211
+ if self.sync.enabled:
212
+ task = asyncio.create_task(asyncio.to_thread(self.sync.upload))
213
+ # keep a strong reference so the task isn't garbage-collected mid-flight
214
+ self._upload_tasks.add(task)
215
+ task.add_done_callback(self._upload_tasks.discard)
216
+ return line_obj
217
+
218
+ async def drain(self) -> None:
219
+ """Wait for in-flight uploads (shutdown hygiene)."""
220
+ if self._upload_tasks:
221
+ await asyncio.gather(*tuple(self._upload_tasks), return_exceptions=True)
server/ratelimit.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Client identity + sliding-window rate limiting (in-memory, single process).
2
+
3
+ Identity: a signed cookie token hashed together with the client IP — stable for a
4
+ visitor, cheap to verify, no accounts. The HMAC secret comes from ``CITY_SECRET``
5
+ (set it on the Space so identities survive restarts) or is generated per-process.
6
+
7
+ Limit: 3 petitions / hour / client, true sliding window. Denied attempts are NOT
8
+ recorded, so hammering the endpoint never extends the lockout. The Space runs a
9
+ single uvicorn worker, so in-memory state is authoritative.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+ import os
17
+ import secrets
18
+ import time
19
+ from collections import deque
20
+ from typing import Callable, Optional, Tuple
21
+
22
+ COOKIE_NAME = "nemocity_id"
23
+ DEFAULT_LIMIT = 3
24
+ DEFAULT_WINDOW_SECONDS = 3600.0
25
+ # Defense-in-depth: a per-IP ceiling (several clients can share a NAT, so it is
26
+ # intentionally looser than the per-client limit). Cookie-clearing can mint fresh
27
+ # client ids; this cap bounds that abuse.
28
+ DEFAULT_IP_LIMIT = 12
29
+
30
+
31
+ def client_ip(request) -> str:
32
+ """Best client IP we can get. SECURITY: the FIRST X-Forwarded-For hop is
33
+ client-controlled (a forged header mints a fresh rate-limit bucket per
34
+ request — verify-fleet finding). The trustworthy entry is the one the
35
+ closest trusted proxy appended: the LAST hop. On HF Spaces the edge proxy
36
+ appends the real client IP, so take the last entry; locally use the peer."""
37
+ forwarded = request.headers.get("x-forwarded-for", "")
38
+ if forwarded:
39
+ hops = [h.strip() for h in forwarded.split(",") if h.strip()]
40
+ if hops:
41
+ return hops[-1]
42
+ return request.client.host if request.client else "unknown"
43
+
44
+
45
+ class ClientIdentity:
46
+ """Mints and verifies the signed visitor cookie; derives the rate-limit id."""
47
+
48
+ def __init__(self, secret: Optional[str] = None) -> None:
49
+ self.secret = secret or os.environ.get("CITY_SECRET") or secrets.token_hex(32)
50
+
51
+ def _sign(self, token: str) -> str:
52
+ return hmac.new(
53
+ self.secret.encode("utf-8"), token.encode("utf-8"), hashlib.sha256
54
+ ).hexdigest()[:32]
55
+
56
+ def mint(self) -> str:
57
+ token = secrets.token_hex(8)
58
+ return f"{token}.{self._sign(token)}"
59
+
60
+ def verify(self, cookie_value: Optional[str]) -> Optional[str]:
61
+ """Return the token if the cookie is authentic, else None."""
62
+ if not cookie_value or "." not in cookie_value:
63
+ return None
64
+ token, _, signature = cookie_value.rpartition(".")
65
+ if not token or not hmac.compare_digest(signature, self._sign(token)):
66
+ return None
67
+ return token
68
+
69
+ def resolve(self, request) -> Tuple[str, Optional[str]]:
70
+ """Return ``(client_id, cookie_to_set)``.
71
+
72
+ ``cookie_to_set`` is a freshly minted signed value when the request carried
73
+ no valid cookie (the caller sets it on the response), else None.
74
+ ``client_id = sha256(token | ip)`` — signed cookie + IP hash per contract.
75
+ """
76
+ token = self.verify(request.cookies.get(COOKIE_NAME))
77
+ new_cookie: Optional[str] = None
78
+ if token is None:
79
+ new_cookie = self.mint()
80
+ token = new_cookie.split(".", 1)[0]
81
+ ip = client_ip(request)
82
+ cid = hashlib.sha256(f"{token}|{ip}".encode("utf-8")).hexdigest()[:16]
83
+ return cid, new_cookie
84
+
85
+
86
+ class RateLimiter:
87
+ """Sliding-window counter: at most ``limit`` recorded hits per ``window`` seconds
88
+ per key. ``clock`` is injectable for tests (monotonic seconds)."""
89
+
90
+ def __init__(
91
+ self,
92
+ limit: int = DEFAULT_LIMIT,
93
+ window: float = DEFAULT_WINDOW_SECONDS,
94
+ clock: Callable[[], float] = time.monotonic,
95
+ ) -> None:
96
+ self.limit = limit
97
+ self.window = window
98
+ self.clock = clock
99
+ self._hits: dict[str, deque[float]] = {}
100
+ self._last_sweep = clock()
101
+
102
+ def hit(self, key: str) -> Tuple[bool, float]:
103
+ """Try to record one hit for ``key``.
104
+
105
+ Returns ``(allowed, retry_after_seconds)``. Denied attempts are not
106
+ recorded, so the window slides only on real (allowed) wishes.
107
+ """
108
+ now = self.clock()
109
+ hits = self._hits.setdefault(key, deque())
110
+ while hits and now - hits[0] >= self.window:
111
+ hits.popleft()
112
+ if len(hits) >= self.limit:
113
+ return False, max(0.0, self.window - (now - hits[0]))
114
+ hits.append(now)
115
+ self._sweep_if_due(now)
116
+ return True, 0.0
117
+
118
+ def remaining(self, key: str) -> int:
119
+ now = self.clock()
120
+ hits = self._hits.get(key)
121
+ if not hits:
122
+ return self.limit
123
+ live = sum(1 for t in hits if now - t < self.window)
124
+ return max(0, self.limit - live)
125
+
126
+ def _sweep_if_due(self, now: float) -> None:
127
+ """Drop keys whose every hit has expired (bounds memory over long uptimes)."""
128
+ if now - self._last_sweep < self.window:
129
+ return
130
+ self._last_sweep = now
131
+ stale = [
132
+ key
133
+ for key, hits in self._hits.items()
134
+ if not hits or now - hits[-1] >= self.window
135
+ ]
136
+ for key in stale:
137
+ del self._hits[key]
server/schemas.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic request/response models for the NEMOCITY HTTP API.
2
+
3
+ Shapes follow ARCHITECTURE.md §HTTP API exactly (wire names keep godseed's wish_*;
4
+ "petition" is user-facing copy only):
5
+
6
+ GET /api/state -> StateResponse (includes server_now for the shared sim clock)
7
+ POST /api/wish {text} -> WishAccepted (errors: 400/429/503 with a city-hall `detail`)
8
+ POST /api/fix -> WishAccepted (409 when traffic is flowing smoothly)
9
+ GET /api/wishes -> list[WishSummary]
10
+ GET /api/wishes/{id} -> raw WishTrace dict (passed through, engine-owned shape)
11
+
12
+ Features and WishTraces are deliberately NOT strict models here: the engine owns those
13
+ shapes and the server passes them through untouched (the "her" pattern — transport never
14
+ reinterprets city facts).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Optional
20
+
21
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
22
+
23
+ # The moderation fast-path enforces the real petition limit (<=160 chars) with a
24
+ # readable reason; this transport-level cap only stops abusive megabyte bodies before
25
+ # they reach moderation.
26
+ TRANSPORT_MAX_WISH_CHARS = 2000
27
+
28
+
29
+ class WishRequest(BaseModel):
30
+ """Body of POST /api/wish (a petition)."""
31
+
32
+ model_config = ConfigDict(str_strip_whitespace=True)
33
+
34
+ text: str = Field(..., min_length=1, max_length=TRANSPORT_MAX_WISH_CHARS)
35
+
36
+ @field_validator("text")
37
+ @classmethod
38
+ def _not_blank(cls, value: str) -> str:
39
+ if not value.strip():
40
+ raise ValueError("a petition needs words")
41
+ return value.strip()
42
+
43
+
44
+ class WishAccepted(BaseModel):
45
+ """Response of POST /api/wish and POST /api/fix."""
46
+
47
+ wish_id: str
48
+ position: int = Field(..., ge=1, description="1-based place in the queue")
49
+
50
+
51
+ class CurrentWish(BaseModel):
52
+ """The petition city hall is processing right now."""
53
+
54
+ wish_id: str
55
+ text_preview: str
56
+
57
+
58
+ class QueueInfo(BaseModel):
59
+ length: int = 0
60
+ current: Optional[CurrentWish] = None
61
+
62
+
63
+ class WorldState(BaseModel):
64
+ """Snapshot of the deterministic city: ordered event list + counters."""
65
+
66
+ version: int = Field(..., ge=0, description="total number of features")
67
+ epoch: int = Field(..., ge=0, description="number of granted petitions")
68
+ features: list[dict[str, Any]]
69
+
70
+
71
+ class WishSummary(BaseModel):
72
+ """One row of the ledger index (GET /api/wishes)."""
73
+
74
+ wish_id: str
75
+ text: str
76
+ epitaph: str = ""
77
+ epoch: int
78
+ ts: float
79
+
80
+
81
+ class StateResponse(BaseModel):
82
+ """GET /api/state. server_now anchors every client's shared sim clock
83
+ (simTime = server_now - CITY_EPOCH_S); clients slew toward it, never snap."""
84
+
85
+ world: WorldState
86
+ queue: QueueInfo
87
+ wishes_recent: list[WishSummary]
88
+ server_now: float
server/sse.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Asyncio broadcast hub for /api/stream (ARCHITECTURE.md §SSE protocol).
2
+
3
+ One hub, many subscribers; every subscriber receives every published event. Design
4
+ constraints, in order of importance:
5
+
6
+ * The producer (the queue worker granting a petition) must NEVER block or fail
7
+ because a browser tab is slow: each subscriber gets a bounded queue and the OLDEST
8
+ event is dropped on overflow. A reconnecting client re-syncs via GET /api/state.
9
+ * Idle connections receive ``{"type": "heartbeat", "server_now": ...}`` every 15s so
10
+ proxies (and the HF Space router) keep the stream open; server_now lets clients
11
+ slew their shared sim clock against drift.
12
+ * Disconnects clean up after themselves: the wire generator unsubscribes in a
13
+ ``finally`` block, which Starlette triggers on client disconnect.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ import time
22
+ from typing import Any, AsyncIterator, Iterable, Optional
23
+
24
+ log = logging.getLogger("nemocity.sse")
25
+
26
+ HEARTBEAT_SECONDS = 15.0
27
+ DEFAULT_QUEUE_SIZE = 256
28
+
29
+
30
+ def format_sse(event: dict[str, Any]) -> str:
31
+ """Wire-encode one event. Events are plain JSON on the default `message` channel;
32
+ clients dispatch on ``data.type`` (matches the contract's event objects)."""
33
+ return f"data: {json.dumps(event, ensure_ascii=False, separators=(',', ':'))}\n\n"
34
+
35
+
36
+ class SSEHub:
37
+ """Fan-out broadcast hub. ``publish`` is synchronous and non-blocking; ``emit`` is
38
+ the async alias matching the planner's ``emit`` callback signature."""
39
+
40
+ def __init__(
41
+ self,
42
+ heartbeat: float = HEARTBEAT_SECONDS,
43
+ max_queue: int = DEFAULT_QUEUE_SIZE,
44
+ ) -> None:
45
+ self.heartbeat = heartbeat
46
+ self.max_queue = max_queue
47
+ self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
48
+
49
+ @property
50
+ def subscriber_count(self) -> int:
51
+ return len(self._subscribers)
52
+
53
+ # ------------------------------------------------------------------ producer side
54
+ def publish(self, event: dict[str, Any]) -> None:
55
+ """Deliver ``event`` to every subscriber. Never blocks: when a subscriber's
56
+ queue is full, its oldest pending event is dropped to make room."""
57
+ for queue in tuple(self._subscribers):
58
+ try:
59
+ queue.put_nowait(event)
60
+ except asyncio.QueueFull:
61
+ try:
62
+ queue.get_nowait() # drop the oldest event for this slow client
63
+ except asyncio.QueueEmpty: # pragma: no cover — only under a race
64
+ pass
65
+ try:
66
+ queue.put_nowait(event)
67
+ except asyncio.QueueFull: # pragma: no cover — only under a race
68
+ log.debug("subscriber queue still full; event dropped")
69
+
70
+ async def emit(self, event: dict[str, Any]) -> None:
71
+ """Async alias of :meth:`publish` (the planner/queue-worker emit callback)."""
72
+ self.publish(event)
73
+
74
+ # ---------------------------------------------------------------- subscriber side
75
+ def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
76
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=self.max_queue)
77
+ self._subscribers.add(queue)
78
+ return queue
79
+
80
+ def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
81
+ self._subscribers.discard(queue)
82
+
83
+ async def event_stream(
84
+ self,
85
+ initial_events: Iterable[dict[str, Any]] = (),
86
+ limit: Optional[int] = None,
87
+ ) -> AsyncIterator[str]:
88
+ """Async generator yielding wire-format SSE for one client.
89
+
90
+ ``initial_events`` (hello / queue snapshot) are sent first, then every
91
+ broadcast event; a heartbeat is sent whenever ``self.heartbeat`` seconds pass
92
+ without traffic. Subscription is registered before the first yield so no
93
+ event published after the request reaches this generator is missed.
94
+
95
+ ``limit`` (optional) closes the stream after that many events — a debug/test
96
+ aid (`curl '/api/stream?limit=20'`); browsers stream unbounded.
97
+ """
98
+ queue = self.subscribe()
99
+ sent = 0
100
+ try:
101
+ yield "retry: 3000\n\n" # client auto-reconnect hint
102
+ for event in initial_events:
103
+ yield format_sse(event)
104
+ sent += 1
105
+ if limit is not None and sent >= limit:
106
+ return
107
+ while True:
108
+ try:
109
+ event = await asyncio.wait_for(queue.get(), timeout=self.heartbeat)
110
+ except asyncio.TimeoutError:
111
+ event = {"type": "heartbeat", "server_now": time.time()}
112
+ yield format_sse(event)
113
+ sent += 1
114
+ if limit is not None and sent >= limit:
115
+ return
116
+ finally:
117
+ self.unsubscribe(queue)
tests/test_api.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP/SSE layer tests (AGENT-SERVER). Engine and mind are faked — these tests
2
+ exercise only the transport: endpoints, SSE hub, persistence, rate limiting, the
3
+ fix gate, error paths. Wire names keep godseed's wish_* per the contract.
4
+
5
+ Run:
6
+ .venv/bin/python -m pytest tests/test_api.py -x -q
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import sys
14
+ import time
15
+ import zlib
16
+ from pathlib import Path
17
+ from typing import Any, Optional
18
+
19
+ import pytest
20
+ from fastapi.testclient import TestClient
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
23
+
24
+ from app import ( # noqa: E402
25
+ FIX_COOLDOWN_REASON,
26
+ FIX_RATE_REASON,
27
+ TRAFFIC_SMOOTH_REASON,
28
+ create_app,
29
+ )
30
+ from server.persistence import PersistenceService, rebuild_features # noqa: E402
31
+ from server.ratelimit import ClientIdentity, RateLimiter # noqa: E402
32
+ from server.sse import SSEHub # noqa: E402
33
+
34
+ CITY_EPOCH_S = 1781222400.0
35
+
36
+ # Genesis fixture — a slice of ARCHITECTURE.md epoch-0 shapes (events are
37
+ # byte-compatible with godseed Features: id, wish_id, tool, args, seed, t).
38
+ GENESIS = [
39
+ {"id": "e_000000", "wish_id": "genesis", "tool": "lay_road",
40
+ "args": {"cells": [[0, -12], [0, -11], [0, -10]], "klass": "avenue",
41
+ "name": "Main St"}, "seed": 7, "t": CITY_EPOCH_S},
42
+ {"id": "e_000001", "wish_id": "genesis", "tool": "lay_road",
43
+ "args": {"cells": [[8, 0], [9, 0]], "klass": "street", "name": "Old Bridge"},
44
+ "seed": 11, "t": CITY_EPOCH_S},
45
+ {"id": "e_000002", "wish_id": "genesis", "tool": "place_building",
46
+ "args": {"kind": "town_hall", "name": "City Hall", "cx": -2, "cz": -3,
47
+ "w": 2, "d": 2, "floors": 2, "hue": 35, "variant": 0},
48
+ "seed": 13, "t": CITY_EPOCH_S},
49
+ {"id": "e_000003", "wish_id": "genesis", "tool": "note",
50
+ "args": {"text": "Nemocity is listening. Ask for a building.",
51
+ "kind": "milestone"}, "seed": 17, "t": CITY_EPOCH_S},
52
+ ]
53
+
54
+ PLAN = {
55
+ "intent": "build",
56
+ "blurb": "A little cafe rises near the park.",
57
+ "buildings": [{"kind": "cafe", "name": "Cafe Luna", "near": "the park"}],
58
+ "decline_reason": None,
59
+ }
60
+
61
+
62
+ # ------------------------------------------------------------------------------- fakes
63
+ class TrafficSmooth(Exception):
64
+ """Engine gate verdict; the server matches this by TYPE NAME, never by import."""
65
+
66
+
67
+ def fake_fast_check(text: str) -> Optional[str]:
68
+ """Moderation fast path: readable reason or None (engine contract)."""
69
+ if "forbidden" in text.lower():
70
+ return "City hall declines this petition; it reeks of the forbidden."
71
+ if len(text) > 160:
72
+ return "City hall reads only short petitions; trim yours to fewer words."
73
+ return None
74
+
75
+
76
+ class FakePlanner:
77
+ """Emits the City Hall paperwork (plan), makes one place_building call,
78
+ returns a WishTrace-shaped dict."""
79
+
80
+ async def grant(self, wish: str, world_summary: str, act, emit) -> dict[str, Any]:
81
+ await emit({"type": "plan", "plan": PLAN})
82
+ call = {
83
+ "tool": "place_building",
84
+ "args": {"kind": "cafe", "name": "Cafe Luna", "cx": 3, "cz": -2,
85
+ "w": 1, "d": 1, "floors": 1, "hue": 35, "variant": 2},
86
+ }
87
+ await act(call)
88
+ return {
89
+ "text": wish,
90
+ "submitted_at": time.time(),
91
+ "moderation": {"allowed": True, "category": None},
92
+ "plan": PLAN,
93
+ "epitaph": PLAN["blurb"],
94
+ "model": "fake", "backend": "fake", "ms_total": 1,
95
+ }
96
+
97
+
98
+ class FakeWorld:
99
+ def __init__(self, features: Optional[list[dict[str, Any]]] = None) -> None:
100
+ self.features: list[dict[str, Any]] = list(features or [])
101
+ self.version = len(self.features)
102
+ self.epoch = 0
103
+
104
+ def apply_feature(self, feature: dict[str, Any]) -> None:
105
+ self.features.append(feature)
106
+ self.version += 1
107
+
108
+
109
+ class FakeQueueWorker:
110
+ """Minimal stand-in for engine.queue_worker: pop -> grant -> persist -> emit,
111
+ plus the City Engineer gate (submit_fix raises TrafficSmooth when smooth)."""
112
+
113
+ def __init__(self, world: FakeWorld, hub: SSEHub,
114
+ persistence: PersistenceService, planner: FakePlanner,
115
+ traffic_smooth: bool = False) -> None:
116
+ self.world, self.hub, self.persistence, self.planner = (
117
+ world, hub, persistence, planner)
118
+ self.traffic_smooth = traffic_smooth
119
+ self._pending: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
120
+ self._wish_counter = 0
121
+ self._feature_counter = len(world.features)
122
+ self._current: Optional[dict[str, Any]] = None
123
+ self._task: Optional[asyncio.Task[None]] = None
124
+
125
+ async def start(self) -> None:
126
+ self._task = asyncio.create_task(self._run())
127
+
128
+ async def stop(self) -> None:
129
+ if self._task is not None:
130
+ self._task.cancel()
131
+ await asyncio.gather(self._task, return_exceptions=True)
132
+
133
+ async def submit(self, text: str, client_id: str) -> tuple[str, int]:
134
+ self._wish_counter += 1
135
+ wish_id = f"w_{self._wish_counter:06d}"
136
+ await self._pending.put({"wish_id": wish_id, "text": text})
137
+ return wish_id, self._pending.qsize()
138
+
139
+ async def submit_fix(self, client_id: str) -> tuple[str, int]:
140
+ if self.traffic_smooth:
141
+ raise TrafficSmooth("static assignment max demandRatio < 0.8")
142
+ self._wish_counter += 1
143
+ return f"w_{self._wish_counter:06d}", 1
144
+
145
+ def snapshot(self) -> dict[str, Any]:
146
+ current = None
147
+ if self._current is not None:
148
+ current = {"wish_id": self._current["wish_id"],
149
+ "text_preview": self._current["text"][:40]}
150
+ return {"length": self._pending.qsize() + (1 if current else 0),
151
+ "current": current}
152
+
153
+ async def _run(self) -> None:
154
+ while True:
155
+ wish = await self._pending.get()
156
+ self._current = wish
157
+ wish_id = wish["wish_id"]
158
+ # Test-only grace: starlette's TestClient buffers a whole response, so the
159
+ # SSE test must subscribe AFTER posting; hold the grant briefly so the
160
+ # subscriber sees every event. The real engine worker has no such wait.
161
+ deadline = asyncio.get_running_loop().time() + 0.5
162
+ while (self.hub.subscriber_count == 0
163
+ and asyncio.get_running_loop().time() < deadline):
164
+ await asyncio.sleep(0.005)
165
+ await self.hub.emit(
166
+ {"type": "wish_started", "wish_id": wish_id, "text": wish["text"]})
167
+ features: list[dict[str, Any]] = []
168
+ call_index = 0
169
+
170
+ async def act(call: dict[str, Any]) -> str:
171
+ nonlocal call_index
172
+ await self.hub.emit({"type": "tool_call", "wish_id": wish_id,
173
+ "call_index": call_index,
174
+ "tool": call["tool"], "args": call["args"]})
175
+ feature = {
176
+ "id": f"e_{self._feature_counter:06d}",
177
+ "wish_id": wish_id,
178
+ "tool": call["tool"],
179
+ "args": call["args"],
180
+ "seed": zlib.crc32(f"{wish_id}:{call_index}".encode()) & 0x7FFFFFFF,
181
+ "t": time.time(),
182
+ }
183
+ self._feature_counter += 1
184
+ call_index += 1
185
+ self.world.apply_feature(feature)
186
+ features.append(feature)
187
+ await self.hub.emit({"type": "world_delta", "feature": feature})
188
+ return f"ok: {call['tool']}"
189
+
190
+ async def emit(event: dict[str, Any]) -> None:
191
+ await self.hub.emit({"wish_id": wish_id, **event})
192
+
193
+ trace = await self.planner.grant(wish["text"], "a small city", act, emit)
194
+ trace["wish_id"] = wish_id
195
+ trace["feature_ids"] = [f["id"] for f in features]
196
+ await self.persistence.record(trace, features)
197
+ self.world.epoch += 1
198
+ await self.hub.emit({"type": "wish_granted", "wish_id": wish_id,
199
+ "epitaph": trace["epitaph"],
200
+ "epoch": self.world.epoch})
201
+ self._current = None
202
+
203
+
204
+ # ---------------------------------------------------------------------------- fixtures
205
+ def build_api(tmp_path: Path, *, traffic_smooth: bool = False,
206
+ fix_rate_limiter: Optional[RateLimiter] = None,
207
+ fix_cooldown: Optional[RateLimiter] = None):
208
+ persistence = PersistenceService(tmp_path / "traces")
209
+ features = persistence.boot(GENESIS)
210
+ world = FakeWorld(features)
211
+ hub = SSEHub(heartbeat=0.05) # fast heartbeats pad bounded test streams quickly
212
+ queue = FakeQueueWorker(world, hub, persistence, FakePlanner(),
213
+ traffic_smooth=traffic_smooth)
214
+ app = create_app(
215
+ world=world, queue=queue, hub=hub, persistence=persistence,
216
+ fast_check=fake_fast_check,
217
+ rate_limiter=RateLimiter(limit=3, window=3600.0),
218
+ ip_rate_limiter=RateLimiter(limit=100, window=3600.0),
219
+ fix_rate_limiter=fix_rate_limiter,
220
+ fix_cooldown=fix_cooldown,
221
+ identity=ClientIdentity(secret="test-secret"),
222
+ mount_web=False, mount_panel=False,
223
+ )
224
+ return app, queue
225
+
226
+
227
+ @pytest.fixture()
228
+ def api(tmp_path: Path):
229
+ app, _queue = build_api(tmp_path)
230
+ return app
231
+
232
+
233
+ def _poll_state(client: TestClient, predicate, timeout: float = 5.0) -> dict[str, Any]:
234
+ deadline = time.time() + timeout
235
+ while time.time() < deadline:
236
+ state = client.get("/api/state").json()
237
+ if predicate(state):
238
+ return state
239
+ time.sleep(0.02)
240
+ raise AssertionError("condition not reached before timeout")
241
+
242
+
243
+ # ------------------------------------------------------------------------------- tests
244
+ def test_post_wish_returns_id_and_position(api) -> None:
245
+ with TestClient(api) as client:
246
+ response = client.post("/api/wish", json={"text": "a ramen shop near the park"})
247
+ assert response.status_code == 200
248
+ body = response.json()
249
+ assert body["wish_id"].startswith("w_")
250
+ assert isinstance(body["position"], int) and body["position"] >= 1
251
+
252
+
253
+ def test_state_includes_server_now(api) -> None:
254
+ with TestClient(api) as client:
255
+ before = time.time()
256
+ state = client.get("/api/state").json()
257
+ after = time.time()
258
+ assert before <= state["server_now"] <= after
259
+
260
+
261
+ def test_sse_stream_emits_petition_lifecycle(api) -> None:
262
+ """TestClient buffers whole responses, so the stream is bounded with ?limit=N
263
+ (heartbeats pad it to N) and read after it closes; the fake worker holds the
264
+ petition until the subscription lands, so every lifecycle event is captured."""
265
+ with TestClient(api) as client:
266
+ posted = client.post("/api/wish", json={"text": "a cafe by the bridge"})
267
+ assert posted.status_code == 200
268
+ wish_id = posted.json()["wish_id"]
269
+
270
+ response = client.get("/api/stream", params={"limit": 16})
271
+ assert response.status_code == 200
272
+ assert response.headers["x-accel-buffering"] == "no"
273
+ assert response.headers["content-type"].startswith("text/event-stream")
274
+
275
+ events = [
276
+ json.loads(line[len("data: "):])
277
+ for line in response.text.splitlines()
278
+ if line.startswith("data: ")
279
+ ]
280
+ assert len(events) == 16
281
+ assert events[0]["type"] == "hello"
282
+ assert events[0]["world_version"] == len(GENESIS)
283
+ assert events[0]["epoch"] == 0
284
+ assert events[0]["server_now"] == pytest.approx(time.time(), abs=30)
285
+ assert events[1]["type"] == "queue"
286
+
287
+ for hb in (e for e in events if e["type"] == "heartbeat"):
288
+ assert isinstance(hb["server_now"], float)
289
+
290
+ seen = [e for e in events[2:] if e["type"] != "heartbeat"]
291
+ types = [e["type"] for e in seen]
292
+ assert types == ["wish_started", "plan", "tool_call", "world_delta",
293
+ "wish_granted"]
294
+
295
+ assert seen[0]["wish_id"] == wish_id
296
+ plan = seen[1]
297
+ assert plan["plan"] == PLAN # raw City Hall paperwork passes through intact
298
+ delta = next(e for e in seen if e["type"] == "world_delta")
299
+ assert delta["feature"]["tool"] == "place_building"
300
+ assert delta["feature"]["wish_id"] == wish_id
301
+ granted = seen[-1]
302
+ assert granted["epitaph"] and granted["epoch"] == 1
303
+
304
+
305
+ def test_state_reflects_new_feature_and_ledger(api) -> None:
306
+ with TestClient(api) as client:
307
+ before = client.get("/api/state").json()
308
+ baseline = len(before["world"]["features"])
309
+ assert baseline == len(GENESIS)
310
+ assert before["queue"] == {"length": 0, "current": None}
311
+
312
+ wish_id = client.post(
313
+ "/api/wish", json={"text": "a row of townhouses on Elm St"}
314
+ ).json()["wish_id"]
315
+
316
+ state = _poll_state(
317
+ client, lambda s: len(s["world"]["features"]) > baseline)
318
+ newest = state["world"]["features"][-1]
319
+ assert newest["wish_id"] == wish_id
320
+ assert newest["seed"] == zlib.crc32(f"{wish_id}:0".encode()) & 0x7FFFFFFF
321
+ assert state["world"]["version"] == baseline + 1
322
+ assert state["world"]["epoch"] == 1
323
+ assert state["wishes_recent"][0]["wish_id"] == wish_id
324
+
325
+ index = client.get("/api/wishes").json()
326
+ assert [w["wish_id"] for w in index] == [wish_id]
327
+ assert index[0]["epitaph"] == PLAN["blurb"]
328
+
329
+ trace = client.get(f"/api/wishes/{wish_id}").json()
330
+ assert trace["wish_id"] == wish_id
331
+ assert trace["plan"] == PLAN and trace["feature_ids"] == [newest["id"]]
332
+ assert trace["features"][0] == newest # embedded for boot rebuild
333
+
334
+ missing = client.get("/api/wishes/w_999999")
335
+ assert missing.status_code == 404
336
+
337
+
338
+ def test_rate_limit_429_on_fourth_wish(api) -> None:
339
+ with TestClient(api) as client:
340
+ for i in range(3):
341
+ ok = client.post("/api/wish", json={"text": f"small petition {i}"})
342
+ assert ok.status_code == 200, ok.text
343
+ blocked = client.post("/api/wish", json={"text": "one petition too many"})
344
+ assert blocked.status_code == 429
345
+ assert "Retry-After" in blocked.headers
346
+ detail = blocked.json()["detail"]
347
+ assert isinstance(detail, str) and detail # readable, not a stack trace
348
+
349
+
350
+ def test_moderation_fast_path_rejects_with_readable_reason(api) -> None:
351
+ with TestClient(api) as client:
352
+ response = client.post("/api/wish", json={"text": "a forbidden storefront"})
353
+ assert response.status_code == 400
354
+ assert response.json()["detail"] == (
355
+ "City hall declines this petition; it reeks of the forbidden.")
356
+ # rejected petitions never reach the city
357
+ assert len(client.get("/api/state").json()["world"]["features"]) == len(GENESIS)
358
+
359
+
360
+ def test_blank_wish_is_unprocessable(api) -> None:
361
+ with TestClient(api) as client:
362
+ assert client.post("/api/wish", json={"text": " "}).status_code == 422
363
+ assert client.post("/api/wish", json={}).status_code == 422
364
+
365
+
366
+ # ---------------------------------------------------------------------- /api/fix tests
367
+ def test_fix_409_when_traffic_smooth_and_cooldown_not_engaged(tmp_path: Path) -> None:
368
+ app, queue = build_api(tmp_path, traffic_smooth=True)
369
+ with TestClient(app) as client:
370
+ smooth = client.post("/api/fix")
371
+ assert smooth.status_code == 409
372
+ assert smooth.json()["detail"] == TRAFFIC_SMOOTH_REASON
373
+ # a no-op click must not block the citywide engineer: jam forms, fix lands
374
+ queue.traffic_smooth = False
375
+ fixed = client.post("/api/fix")
376
+ assert fixed.status_code == 200, fixed.text
377
+ body = fixed.json()
378
+ assert body["wish_id"].startswith("w_") and body["position"] >= 1
379
+
380
+
381
+ def test_fix_200_when_jammed_then_global_cooldown_429(tmp_path: Path) -> None:
382
+ app, _queue = build_api(tmp_path)
383
+ with TestClient(app) as client:
384
+ first = client.post("/api/fix")
385
+ assert first.status_code == 200, first.text
386
+ second = client.post("/api/fix")
387
+ assert second.status_code == 429
388
+ assert second.json()["detail"] == FIX_COOLDOWN_REASON
389
+ assert "Retry-After" in second.headers
390
+
391
+
392
+ def test_fix_client_rate_limit_two_per_hour(tmp_path: Path) -> None:
393
+ # a permissive global cooldown isolates the per-client 2/hr window
394
+ app, _queue = build_api(
395
+ tmp_path, fix_cooldown=RateLimiter(limit=100, window=120.0))
396
+ with TestClient(app) as client:
397
+ for _ in range(2):
398
+ assert client.post("/api/fix").status_code == 200
399
+ blocked = client.post("/api/fix")
400
+ assert blocked.status_code == 429
401
+ assert blocked.json()["detail"] == FIX_RATE_REASON
402
+ assert "Retry-After" in blocked.headers
403
+
404
+
405
+ # ------------------------------------------------------------------- unit: components
406
+ def test_sse_hub_drops_oldest_never_blocks() -> None:
407
+ async def scenario() -> list[int]:
408
+ hub = SSEHub(heartbeat=0.1, max_queue=3)
409
+ queue = hub.subscribe()
410
+ for i in range(6):
411
+ hub.publish({"type": "n", "i": i}) # never raises, never blocks
412
+ hub.unsubscribe(queue)
413
+ kept = []
414
+ while not queue.empty():
415
+ kept.append(queue.get_nowait()["i"])
416
+ return kept
417
+
418
+ assert asyncio.run(scenario()) == [3, 4, 5] # oldest were dropped
419
+
420
+
421
+ def test_rate_limiter_sliding_window() -> None:
422
+ now = [0.0]
423
+ limiter = RateLimiter(limit=3, window=3600.0, clock=lambda: now[0])
424
+ key = "client"
425
+ for t in (0.0, 1000.0, 2000.0):
426
+ now[0] = t
427
+ assert limiter.hit(key)[0]
428
+ now[0] = 2500.0
429
+ allowed, retry_after = limiter.hit(key)
430
+ assert not allowed and retry_after == 3600.0 - 2500.0 # until the t=0 hit expires
431
+ now[0] = 3600.5 # only the t=0 hit has slid out of the window
432
+ assert limiter.hit(key)[0]
433
+ assert limiter.remaining(key) == 0 # live: t=1000, t=2000, t=3600.5
434
+ allowed, _ = limiter.hit(key)
435
+ assert not allowed # denied attempts were never recorded, window is exact
436
+
437
+
438
+ def test_identity_cookie_roundtrip_and_tamper() -> None:
439
+ identity = ClientIdentity(secret="s3cret")
440
+ cookie = identity.mint()
441
+ token = cookie.split(".", 1)[0]
442
+ assert identity.verify(cookie) == token
443
+ assert identity.verify(cookie[:-1] + ("0" if cookie[-1] != "0" else "1")) is None
444
+ assert identity.verify("garbage") is None
445
+ assert identity.verify(None) is None
446
+
447
+
448
+ def test_persistence_rebuild_survives_corruption(tmp_path: Path) -> None:
449
+ traces_dir = tmp_path / "traces"
450
+ first = PersistenceService(traces_dir)
451
+ first.boot(GENESIS)
452
+ feature = {"id": "e_000004", "wish_id": "w_000001", "tool": "place_building",
453
+ "args": {"kind": "house", "name": "Elm Cottage", "cx": -1, "cz": -6,
454
+ "w": 1, "d": 1, "floors": 1, "hue": 20, "variant": 1},
455
+ "seed": 19, "t": CITY_EPOCH_S + 60.0}
456
+ asyncio.run(first.record(
457
+ {"wish_id": "w_000001", "text": "a little house", "submitted_at": 1.0,
458
+ "epitaph": "a home with a clay roof"},
459
+ [feature],
460
+ ))
461
+ # simulate a crash mid-append
462
+ with first.path.open("a", encoding="utf-8") as fh:
463
+ fh.write('{"wish_id": "w_000002", "text": "truncat')
464
+
465
+ second = PersistenceService(traces_dir)
466
+ rebuilt = second.boot(GENESIS)
467
+ assert rebuilt == GENESIS + [feature]
468
+ assert [t["wish_id"] for t in second.traces()] == ["w_000001"]
469
+ assert second.find("w_000001")["epitaph"] == "a home with a clay roof"
470
+ assert second.find("w_000404") is None
471
+
472
+
473
+ def test_rebuild_skips_traces_without_embedded_features() -> None:
474
+ legacy = {"wish_id": "w_000009", "feature_ids": ["e_0009"]} # no features array
475
+ assert rebuild_features(GENESIS, [legacy]) == GENESIS
tests/test_engine.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the NEMOCITY deterministic city engine.
2
+
3
+ Covers: genesis validity + rebuild equality, synonym/clamp/name behavior,
4
+ placement determinism + the never-fails property, the queue worker grant path
5
+ (act -> placement -> events), and the demand-driven infill rule.
6
+
7
+ Engine tests import no fastapi/mind — pure engine surface.
8
+ """
9
+
10
+ import asyncio
11
+ import sys
12
+ import zlib
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
18
+
19
+ from engine import constants as C
20
+ from engine import placement, tools
21
+ from engine.city import WATER_CELLS, CityState
22
+ from engine.genesis import GENESIS_FEATURES, genesis_features
23
+ from engine.moderation import Moderator
24
+ from engine.queue_worker import QueueWorker, TrafficSmooth
25
+ from engine.world import World
26
+
27
+ NOW = C.CITY_EPOCH_S + 10_000 # everything in genesis long complete
28
+
29
+
30
+ def make_world() -> World:
31
+ return World.load(genesis_features())
32
+
33
+
34
+ def make_city(world=None) -> CityState:
35
+ return CityState.from_events((world or make_world()).features)
36
+
37
+
38
+ # --------------------------------------------------------------------- genesis
39
+
40
+
41
+ def test_genesis_shape_and_seeds():
42
+ assert [f["id"] for f in GENESIS_FEATURES] == [f"e_{i:06d}" for i in range(19)]
43
+ for i, f in enumerate(GENESIS_FEATURES):
44
+ assert f["wish_id"] == "genesis"
45
+ assert f["t"] == float(C.CITY_EPOCH_S)
46
+ assert f["seed"] == zlib.crc32(f"genesis:{i}".encode()) & 0x7FFFFFFF
47
+ assert GENESIS_FEATURES[-1]["tool"] == "note"
48
+
49
+
50
+ def test_genesis_city_facts():
51
+ city = make_city()
52
+ assert city.population(NOW) == 20
53
+ assert city.jobs == 85
54
+ assert city.housing_capacity == 20
55
+ assert city.road_version == 6
56
+ # the Old Bridge is the only crossing, street class, on water
57
+ for cell in ((8, 0), (9, 0)):
58
+ rc = city.roads[cell]
59
+ assert rc.bridge and rc.klass == "street" and rc.name == "Old Bridge"
60
+ assert all(
61
+ not rc.bridge for cell, rc in city.roads.items() if rc.name != "Old Bridge"
62
+ )
63
+
64
+
65
+ def test_genesis_buildings_valid():
66
+ city = CityState()
67
+ for ev in genesis_features():
68
+ if ev["tool"] == "place_building":
69
+ a = ev["args"]
70
+ cells = [
71
+ (a["cx"] + dx, a["cz"] + dz)
72
+ for dx in range(a["w"]) for dz in range(a["d"])
73
+ ]
74
+ assert all(city.is_empty(c) for c in cells), a["name"]
75
+ city.apply(ev)
76
+ full = make_city()
77
+ for b in full.buildings:
78
+ assert full.door(b) is not None, f"{b.name} touches no road"
79
+
80
+
81
+ def test_world_rebuild_equality():
82
+ w1 = make_world()
83
+ w2 = World.load([f.to_dict() for f in w1.features])
84
+ assert [f.to_dict() for f in w1.features] == [f.to_dict() for f in w2.features]
85
+ assert w1.version == w2.version == 19
86
+ assert w1.epoch == 0
87
+
88
+
89
+ # ------------------------------------------------------------ synonyms / clamps
90
+
91
+
92
+ def test_resolve_kind_synonyms():
93
+ assert tools.resolve_kind("skyscraper") == "tower"
94
+ assert tools.resolve_kind("Coffee Shop") == "cafe"
95
+ assert tools.resolve_kind("apartment building") == "apartments"
96
+ assert tools.resolve_kind("police station") == "fire_station"
97
+ assert tools.resolve_kind("city hall") == "town_hall"
98
+ assert tools.resolve_kind("grocery") == "market"
99
+ assert tools.resolve_kind("hotel") == "apartments"
100
+ assert tools.resolve_kind("ramen stand") == "house" # unknown -> house
101
+ assert tools.resolve_kind(None) == "house"
102
+ assert tools.resolve_kind("towers") == "tower"
103
+
104
+
105
+ def test_place_building_clamps():
106
+ args, err = tools.validate_call("place_building", {
107
+ "kind": "office", "name": "A" * 80, "cx": -999, "cz": 999,
108
+ "floors": 99, "hue": 9999, "variant": 42,
109
+ })
110
+ assert err is None
111
+ assert args["floors"] == 8 # office max
112
+ assert args["cx"] == C.COORD_MIN and args["cz"] == C.COORD_MAX
113
+ assert len(args["name"]) <= C.NAME_MAX_LEN
114
+ assert args["hue"] == 360 and args["variant"] == 7
115
+ assert args["w"] == 1 and args["d"] == 1
116
+
117
+
118
+ def test_unknown_tool_rejected():
119
+ args, err = tools.validate_call("summon_dragon", {})
120
+ assert args is None and err.startswith("rejected:")
121
+
122
+
123
+ def test_sanitize_name_wordlist_and_length():
124
+ assert tools.sanitize_name("Cafe Luna") == "Cafe Luna"
125
+ assert tools.sanitize_name("X" * 99) == "X" * C.NAME_MAX_LEN
126
+ assert tools.sanitize_name("hitler plaza", default="The Plaza") == "The Plaza"
127
+ assert tools.sanitize_name(None, default="D") == "D"
128
+ name = tools.default_name("cafe", "a ramen shop near the park", "w_000001")
129
+ assert 0 < len(name) <= C.NAME_MAX_LEN
130
+ assert name == tools.default_name("cafe", "a ramen shop near the park", "w_000001")
131
+
132
+
133
+ def test_note_moderated():
134
+ args, err = tools.validate_call("note", {"text": "nazi parade", "kind": "milestone"})
135
+ assert args is None and err.startswith("rejected:")
136
+
137
+
138
+ # ------------------------------------------------------------------- placement
139
+
140
+
141
+ def test_placement_deterministic():
142
+ runs = []
143
+ for _ in range(2):
144
+ city = make_city()
145
+ events = placement.place(
146
+ city, kind="cafe", name="Cafe Luna", floors=None, hue=None,
147
+ near="the park", wish_id="w_000007", call_index=0,
148
+ )
149
+ runs.append(events)
150
+ assert runs[0] == runs[1]
151
+ args = runs[0][0]["args"]
152
+ assert args["kind"] == "cafe" and args["w"] == 1 and args["d"] == 1
153
+
154
+
155
+ def test_placement_near_park_lands_near_park():
156
+ city = make_city()
157
+ events = placement.place(
158
+ city, "cafe", "Cafe Luna", None, None, "the park", "w_000002", 0,
159
+ )
160
+ a = events[0]["args"]
161
+ park = next(b for b in city.buildings if b.kind == "park")
162
+ px, pz = park.centroid
163
+ assert abs(a["cx"] - px) <= 6 and abs(a["cz"] - pz) <= 6
164
+
165
+
166
+ def test_placement_never_fails_50_requests():
167
+ world = make_world()
168
+ city = make_city(world)
169
+ kinds = list(C.BUILDINGS)
170
+ nears = ("the park", "downtown", "the river", "Main St", "", "Riverside Works")
171
+ for i in range(50):
172
+ kind = kinds[zlib.crc32(f"k:{i}".encode()) % len(kinds)]
173
+ near = nears[zlib.crc32(f"n:{i}".encode()) % len(nears)]
174
+ wish_id = f"w_{i + 10:06d}"
175
+ events = placement.place(city, kind, f"Test {i}", None, None, near, wish_id, 0)
176
+ assert events and events[0]["tool"] == "place_building"
177
+ a = events[0]["args"]
178
+ cells = [
179
+ (a["cx"] + dx, a["cz"] + dz)
180
+ for dx in range(a["w"]) for dz in range(a["d"])
181
+ ]
182
+ for c in cells:
183
+ assert city.is_empty(c), f"request {i} ({kind}) overlaps at {c}"
184
+ for j, ev in enumerate(events):
185
+ feature, obs = world.apply(wish_id, j, ev, t=NOW)
186
+ assert feature is not None, obs
187
+ city.apply(feature)
188
+ b = city.buildings[-1]
189
+ assert city.door(b) is not None, f"{kind} #{i} ended up roadless"
190
+
191
+
192
+ def test_connector_when_no_frontage():
193
+ # Anchor far from roads: a remote park forces an auto-routed connector.
194
+ city = make_city()
195
+ events = placement.place(city, "stadium", "Big Bowl", None, None, "", "w_000099", 0)
196
+ for ev in events:
197
+ args, err = tools.validate_call(ev["tool"], ev["args"])
198
+ assert err is None
199
+ if len(events) == 2:
200
+ assert events[1]["tool"] == "lay_road"
201
+ assert events[1]["args"]["klass"] == "street"
202
+ assert 1 <= len(events[1]["args"]["cells"]) <= 2 * C.CONNECTOR_MAX
203
+
204
+
205
+ # ----------------------------------------------------------------- queue worker
206
+
207
+
208
+ class _OneShotPlanner:
209
+ """Calls act once per scripted building request, like mind.planner."""
210
+
211
+ def __init__(self, requests):
212
+ self.requests = requests
213
+
214
+ async def grant(self, wish, world_summary, act, emit):
215
+ await emit({"type": "plan", "plan": {"buildings": self.requests}})
216
+ turns = []
217
+ for req in self.requests:
218
+ obs = await act(req)
219
+ turns.append({"thought": "", "call": req, "observation": obs})
220
+ return {
221
+ "reading": "Permit approved.", "turns": turns,
222
+ "epitaph": "A fine addition to the skyline.",
223
+ "ms_total": 1, "model": "test", "backend": "test",
224
+ }
225
+
226
+
227
+ def _run_worker(planner, world):
228
+ events = []
229
+
230
+ async def emit(event):
231
+ events.append(event)
232
+
233
+ worker = QueueWorker(
234
+ world=world, moderator=Moderator(), planner=planner, emit=emit,
235
+ )
236
+
237
+ async def go():
238
+ wish_id, position = await worker.submit("a tall tower downtown", "client-1")
239
+ assert position == 1
240
+ await worker.start()
241
+ await asyncio.wait_for(worker._queue.join(), 10)
242
+ await worker.stop()
243
+ return wish_id
244
+
245
+ wish_id = asyncio.run(go())
246
+ return wish_id, events, worker
247
+
248
+
249
+ def test_grant_path_places_building_and_emits():
250
+ world = make_world()
251
+ planner = _OneShotPlanner([{"kind": "skyscraper", "name": "Apex Tower",
252
+ "near": "downtown", "floors": 12, "hue": 210}])
253
+ wish_id, events, _ = _run_worker(planner, world)
254
+ types = [e["type"] for e in events]
255
+ assert "plan" in types and "wish_granted" in types
256
+ plan_event = next(e for e in events if e["type"] == "plan")
257
+ assert plan_event["wish_id"] == wish_id
258
+ placed = [f for f in world.features if f.wish_id == wish_id and f.tool == "place_building"]
259
+ assert placed and placed[0].args["kind"] == "tower" # synonym repaired
260
+ deltas = [e for e in events if e["type"] == "world_delta"]
261
+ assert len(deltas) == len([f for f in world.features if f.wish_id == wish_id])
262
+
263
+
264
+ def test_infill_rule_one_bonus_home():
265
+ # Genesis jobs (85) already exceed housing (20) * 1.15 -> a granted petition
266
+ # that adds MORE jobs must pull in exactly ONE bonus home + note.
267
+ world = make_world()
268
+ planner = _OneShotPlanner([{"kind": "office", "name": "More Jobs Inc",
269
+ "near": "downtown"}])
270
+ wish_id, events, _ = _run_worker(planner, world)
271
+ mine = [f for f in world.features if f.wish_id == wish_id]
272
+ homes = [f for f in mine if f.tool == "place_building"
273
+ and f.args["kind"] in C.RESIDENTIAL_KINDS]
274
+ notes = [f for f in mine if f.tool == "note"]
275
+ assert len(homes) == 1
276
+ assert len(notes) == 1 and notes[0].args["kind"] == "infill"
277
+ # jobs - housing >= 16 -> the infill upgrades to apartments
278
+ assert homes[0].args["kind"] == "apartments"
279
+
280
+
281
+ def test_moderation_rejects_before_planning():
282
+ world = make_world()
283
+ planner = _OneShotPlanner([{"kind": "house"}])
284
+ events = []
285
+
286
+ async def emit(event):
287
+ events.append(event)
288
+
289
+ worker = QueueWorker(world=world, moderator=Moderator(), planner=planner, emit=emit)
290
+
291
+ async def go():
292
+ await worker.submit("build a nazi museum", "client-2")
293
+ await worker.start()
294
+ await asyncio.wait_for(worker._queue.join(), 10)
295
+ await worker.stop()
296
+
297
+ asyncio.run(go())
298
+ assert any(e["type"] == "wish_rejected" for e in events)
299
+ assert world.version == 19 # nothing landed
300
+
301
+
302
+ def test_summary_under_600_chars():
303
+ world = make_world()
304
+ text = world.summary(NOW)
305
+ assert len(text) < 600
306
+ assert "NEMOCITY" in text and "pop 20" in text
307
+
308
+
309
+ def test_submit_fix_gate_smooth_when_no_trips():
310
+ # A roads-only world has zero demand -> TrafficSmooth.
311
+ world = World.load([f for f in genesis_features() if f["tool"] == "lay_road"])
312
+ worker = QueueWorker(world=world, moderator=Moderator(), planner=None, emit=_noop)
313
+
314
+ async def go():
315
+ with pytest.raises(TrafficSmooth):
316
+ await worker.submit_fix("client-3")
317
+
318
+ asyncio.run(go())
319
+
320
+
321
+ async def _noop(event):
322
+ return None
tests/test_mind.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the NEMOCITY mind: lenient parsing, salvage, fallback, mock
2
+ grant/fix end-to-end, and the fix invalid-choice path. Async tests run via
3
+ asyncio.run (no pytest-asyncio dependency)."""
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ if str(ROOT) not in sys.path:
12
+ sys.path.insert(0, str(ROOT))
13
+
14
+ from mind import prompts # noqa: E402
15
+ from mind.backends import MockBackend # noqa: E402
16
+ from mind.moderation_judge import judge # noqa: E402
17
+ from mind.planner import FALLBACK_BLURB, Planner # noqa: E402
18
+ from mind.validate import BUILD_TAG, STRICT_SUFFIX, parse_build, parse_fix # noqa: E402
19
+
20
+
21
+ def run(coro):
22
+ return asyncio.run(coro)
23
+
24
+
25
+ class ScriptedBackend:
26
+ """Yields canned replies in order; records every (prompt, tag) it saw."""
27
+
28
+ name = "scripted"
29
+ model_id = "scripted-test"
30
+ oneshot = True
31
+
32
+ def __init__(self, replies):
33
+ self.replies = list(replies)
34
+ self.requests = []
35
+
36
+ async def generate_stream(self, prompt, grammar=None, max_tokens=700):
37
+ self.requests.append((prompt, grammar))
38
+ text = self.replies.pop(0) if self.replies else ""
39
+ for i in range(0, len(text), 16):
40
+ yield text[i : i + 16]
41
+
42
+
43
+ class Recorder:
44
+ def __init__(self, observation="ok: placed"):
45
+ self.calls = []
46
+ self.observation = observation
47
+
48
+ async def act(self, payload):
49
+ self.calls.append(payload)
50
+ return self.observation
51
+
52
+
53
+ def collector():
54
+ events = []
55
+
56
+ async def emit(event):
57
+ events.append(event)
58
+
59
+ return events, emit
60
+
61
+
62
+ # --------------------------------------------------------------------------
63
+ # parse_build
64
+ # --------------------------------------------------------------------------
65
+
66
+ def test_parse_build_plain():
67
+ raw = json.dumps({
68
+ "intent": "build",
69
+ "blurb": "A cafe for the corner.",
70
+ "buildings": [{"kind": "cafe", "name": "Cafe Luna", "near": "the park", "floors": 1, "hue": 35}],
71
+ "decline_reason": None,
72
+ })
73
+ plan, err = parse_build(raw)
74
+ assert err is None
75
+ assert plan["intent"] == "build"
76
+ assert plan["buildings"] == [
77
+ {"kind": "cafe", "name": "Cafe Luna", "near": "the park", "floors": 1, "hue": 35}
78
+ ]
79
+
80
+
81
+ def test_parse_build_fences_think_and_prose():
82
+ raw = (
83
+ "<think>hmm a cafe, where though</think>\n"
84
+ "Sure! Here is the permit you asked for:\n"
85
+ "```json\n"
86
+ '{"intent":"build","blurb":"Done.","buildings":[{"kind":"cafe","name":"Cafe Fig"}]}\n'
87
+ "```\nHope that helps!"
88
+ )
89
+ plan, err = parse_build(raw)
90
+ assert err is None
91
+ assert plan["buildings"][0]["kind"] == "cafe"
92
+ assert plan["buildings"][0]["name"] == "Cafe Fig"
93
+ assert plan["buildings"][0]["near"] is None
94
+
95
+
96
+ def test_parse_build_broken_array_salvage():
97
+ # Outer object truncated mid-array: the first building is still balanced.
98
+ raw = (
99
+ '{"intent":"build","blurb":"Two homes",'
100
+ '"buildings":[{"kind":"house","name":"Wren Cottage","near":"Elm St"},'
101
+ '{"kind":"house","name":"Maple'
102
+ )
103
+ plan, err = parse_build(raw)
104
+ assert err is None
105
+ assert len(plan["buildings"]) == 1
106
+ assert plan["buildings"][0]["name"] == "Wren Cottage"
107
+
108
+
109
+ def test_parse_build_coerce_clip_and_drop():
110
+ raw = json.dumps({
111
+ "intent": "build",
112
+ "blurb": "x" * 300,
113
+ "buildings": [
114
+ {"kind": "Tower", "name": "A" * 40, "floors": "12", "hue": 35.7, "mood": "smug"},
115
+ {"kind": "cafe"},
116
+ {"kind": "shop", "floors": True},
117
+ {"kind": "park"},
118
+ {"kind": "house"},
119
+ ],
120
+ })
121
+ plan, err = parse_build(raw)
122
+ assert err is None
123
+ assert len(plan["blurb"]) == 140
124
+ assert len(plan["buildings"]) == 3 # clipped
125
+ first = plan["buildings"][0]
126
+ assert first["kind"] == "tower"
127
+ assert len(first["name"]) == 24
128
+ assert first["floors"] == 12
129
+ assert first["hue"] == 36
130
+ assert "mood" not in first
131
+ assert plan["buildings"][2]["floors"] is None # bools are not floor counts
132
+
133
+
134
+ def test_parse_build_decline():
135
+ plan, err = parse_build('{"intent":"decline","blurb":"","buildings":[],"decline_reason":"pure gibberish"}')
136
+ assert err is None
137
+ assert plan["intent"] == "decline"
138
+ assert plan["buildings"] == []
139
+ assert plan["decline_reason"] == "pure gibberish"
140
+
141
+
142
+ def test_parse_build_garbage():
143
+ for raw in ("", " ", "no json here at all", '{"intent":"build","buildings":[]}'):
144
+ plan, err = parse_build(raw)
145
+ assert plan is None
146
+ assert err
147
+
148
+
149
+ # --------------------------------------------------------------------------
150
+ # parse_fix
151
+ # --------------------------------------------------------------------------
152
+
153
+ def test_parse_fix_messy():
154
+ raw = (
155
+ "<think></think>The bridge is the problem.\n```\n"
156
+ '{"diagnosis":"Old Bridge at 3.1x capacity; F1 cuts 78 to 41.","choice":"F1","blurb":"Relief at Old Bridge."}\n```'
157
+ )
158
+ obj, err = parse_fix(raw)
159
+ assert err is None
160
+ assert obj["choice"] == "F1"
161
+ assert "3.1x" in obj["diagnosis"]
162
+
163
+
164
+ def test_parse_fix_salvage_broken_outer():
165
+ raw = '{"diagnosis":"Index 78 on Old Bridge.","choice":"F2","blurb":"Fixing'
166
+ obj, err = parse_fix(raw)
167
+ assert err is None
168
+ assert obj["choice"] == "F2"
169
+ assert obj["diagnosis"] == "Index 78 on Old Bridge."
170
+
171
+
172
+ def test_parse_fix_garbage():
173
+ obj, err = parse_fix("nothing useful")
174
+ assert obj is None and err
175
+
176
+
177
+ # --------------------------------------------------------------------------
178
+ # grant — mock end-to-end
179
+ # --------------------------------------------------------------------------
180
+
181
+ def test_mock_grant_cafe_end_to_end():
182
+ planner = Planner(MockBackend(delay=0.0))
183
+ recorder = Recorder()
184
+ events, emit = collector()
185
+ trace = run(planner.grant(
186
+ "a cozy cafe near the park", "Nemocity: pop 20, 14 buildings.",
187
+ recorder.act, emit, wish_id="w_000042",
188
+ ))
189
+
190
+ plans = [e for e in events if e.get("type") == "plan"]
191
+ assert len(plans) == 1
192
+ assert plans[0]["wish_id"] == "w_000042"
193
+ assert plans[0]["plan"]["intent"] == "build"
194
+
195
+ assert 1 <= len(recorder.calls) <= 3
196
+ building = recorder.calls[0]
197
+ assert building["kind"] == "cafe"
198
+ assert building["name"] and len(building["name"]) <= 24
199
+ assert set(building) == {"kind", "name", "near", "floors", "hue"}
200
+
201
+ assert trace["reading"] == trace["epitaph"] == trace["raw_json"]["blurb"]
202
+ assert trace["turns"][0]["observation"] == "ok: placed"
203
+ assert not trace["fallback"] and not trace["declined"]
204
+ assert any(e.get("type") == "thought_token" for e in events)
205
+
206
+
207
+ def test_mock_grant_district_builds_multiple():
208
+ planner = Planner(MockBackend(delay=0.0))
209
+ recorder = Recorder()
210
+ events, emit = collector()
211
+ trace = run(planner.grant(
212
+ "a whole new district downtown", "Nemocity: pop 20.", recorder.act, emit,
213
+ ))
214
+ assert 2 <= len(recorder.calls) <= 3
215
+ assert len(trace["turns"]) == len(recorder.calls)
216
+ kinds = {b["kind"] for b in recorder.calls}
217
+ assert "apartments" in kinds
218
+
219
+
220
+ def test_mock_grant_is_deterministic():
221
+ planner = Planner(MockBackend(delay=0.0))
222
+
223
+ async def grab():
224
+ recorder = Recorder()
225
+ _, emit = collector()
226
+ await planner.grant("a ramen shop", "pop 20", recorder.act, emit)
227
+ return recorder.calls
228
+
229
+ assert run(grab()) == run(grab())
230
+
231
+
232
+ def test_grant_fallback_house_on_garbage():
233
+ backend = ScriptedBackend(["the model rambles with no json", "still rambling"])
234
+ planner = Planner(backend)
235
+ recorder = Recorder()
236
+ events, emit = collector()
237
+ trace = run(planner.grant("a glittering opera house", "pop 20", recorder.act, emit))
238
+
239
+ assert len(backend.requests) == 2
240
+ assert backend.requests[0][1] == BUILD_TAG
241
+ assert backend.requests[1][1] == BUILD_TAG + STRICT_SUFFIX
242
+ assert prompts.REASK_NOTICE in backend.requests[1][0]
243
+
244
+ assert trace["fallback"] is True
245
+ assert trace["epitaph"] == FALLBACK_BLURB
246
+ assert len(recorder.calls) == 1
247
+ assert recorder.calls[0]["kind"] == "house"
248
+ assert 0 < len(recorder.calls[0]["name"]) <= 24
249
+ assert [e for e in events if e.get("type") == "plan"]
250
+
251
+
252
+ def test_grant_survives_act_failure():
253
+ async def bad_act(_building):
254
+ raise RuntimeError("engine exploded")
255
+
256
+ planner = Planner(MockBackend(delay=0.0))
257
+ _, emit = collector()
258
+ trace = run(planner.grant("a small house", "pop 20", bad_act, emit))
259
+ assert trace["turns"]
260
+ assert trace["turns"][0]["observation"].startswith("error:")
261
+
262
+
263
+ def test_grant_survives_dead_backend():
264
+ class DeadBackend:
265
+ name = "dead"
266
+ model_id = "dead"
267
+ oneshot = True
268
+
269
+ async def generate_stream(self, prompt, grammar=None, max_tokens=700):
270
+ raise RuntimeError("no gpu")
271
+ yield "" # pragma: no cover
272
+
273
+ planner = Planner(DeadBackend())
274
+ recorder = Recorder()
275
+ _, emit = collector()
276
+ trace = run(planner.grant("a park", "pop 20", recorder.act, emit))
277
+ assert trace["fallback"] is True
278
+ assert recorder.calls[0]["kind"] == "house"
279
+
280
+
281
+ # --------------------------------------------------------------------------
282
+ # fix
283
+ # --------------------------------------------------------------------------
284
+
285
+ _STATS = {"traffic_index": 78, "worst": "Old Bridge"}
286
+ _CANDIDATES = [
287
+ {"id": "F1", "action": "new_road", "desc": "second bridge at Oak St, 7 cells", "predicted_index": 41},
288
+ {"id": "F2", "action": "upgrade_avenue", "desc": "widen River Rd", "predicted_index": 55},
289
+ ]
290
+
291
+
292
+ def test_mock_fix_end_to_end():
293
+ planner = Planner(MockBackend(delay=0.0))
294
+ recorder = Recorder(observation="ok: fix applied")
295
+ events, emit = collector()
296
+ trace = run(planner.fix(_STATS, _CANDIDATES, recorder.act, emit, wish_id="w_000099"))
297
+
298
+ assert len(recorder.calls) == 1
299
+ fix_call = recorder.calls[0]
300
+ assert fix_call["choice"] == "F1" # mock picks the best predicted index
301
+ assert "78" in fix_call["diagnosis"] or "41" in fix_call["diagnosis"]
302
+ assert len(fix_call["diagnosis"]) <= 160
303
+ assert len(fix_call["blurb"]) <= 120
304
+
305
+ plans = [e for e in events if e.get("type") == "plan"]
306
+ assert len(plans) == 1 and plans[0]["wish_id"] == "w_000099"
307
+ assert trace["reading"] == fix_call["diagnosis"]
308
+ assert trace["epitaph"] == fix_call["blurb"]
309
+ assert not trace["fallback"]
310
+
311
+
312
+ def test_fix_invalid_choice_falls_back_to_best_predicted():
313
+ backend = ScriptedBackend(
314
+ ['{"diagnosis":"I choose a fix that does not exist.","choice":"F9","blurb":"Bold."}']
315
+ )
316
+ planner = Planner(backend)
317
+ recorder = Recorder()
318
+ _, emit = collector()
319
+ trace = run(planner.fix(_STATS, _CANDIDATES, recorder.act, emit))
320
+
321
+ assert len(backend.requests) == 1 # valid JSON, no retry needed
322
+ assert recorder.calls[0]["choice"] == "F1" # lowest predicted_index
323
+ assert "Old Bridge" in recorder.calls[0]["diagnosis"]
324
+ assert trace["fallback"] is True
325
+
326
+
327
+ def test_fix_parse_failure_falls_back():
328
+ backend = ScriptedBackend(["garbage", "more garbage"])
329
+ planner = Planner(backend)
330
+ recorder = Recorder()
331
+ _, emit = collector()
332
+ trace = run(planner.fix(_STATS, _CANDIDATES, recorder.act, emit))
333
+ assert recorder.calls[0]["choice"] == "F1"
334
+ assert "78" in recorder.calls[0]["diagnosis"]
335
+ assert trace["fallback"] is True
336
+
337
+
338
+ # --------------------------------------------------------------------------
339
+ # prompts + moderation
340
+ # --------------------------------------------------------------------------
341
+
342
+ def test_build_prompt_compact_single_example():
343
+ prompt = prompts.build_build_prompt("a ramen shop near the park", "pop 20")
344
+ assert prompts.PETITION_MARKER + "a ramen shop near the park" in prompt
345
+ assert prompt.count('"intent"') == 1 # exactly ONE example shape, no few-shot
346
+ assert len(prompt) < 4800 # ~1200 token prefill budget
347
+ for kind in ("house", "tower", "fire_station", "town_hall"):
348
+ assert f"- {kind}:" in prompt
349
+
350
+
351
+ def test_fix_prompt_carries_marker_and_numbers():
352
+ prompt = prompts.build_fix_prompt(_STATS, _CANDIDATES)
353
+ assert prompts.FIX_MARKER in prompt
354
+ assert "(predicted index 41)" in prompt
355
+ assert '"traffic_index":78' in prompt
356
+
357
+
358
+ def test_moderation_judge_mock():
359
+ backend = MockBackend(delay=0.0)
360
+ ok = run(judge("a lovely cafe by the river", backend))
361
+ assert ok == {"allowed": True, "category": None}
362
+ bad = run(judge("kill them all in the square", backend))
363
+ assert bad["allowed"] is False
tests/test_traffic.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the NEMOCITY Python traffic mirror (static assignment).
2
+
3
+ Covers: mulberry32 cross-language equivalence (values pinned from
4
+ web/city/rng.js via node), trip-gen determinism, A* sanity over the bridge,
5
+ genesis bridge demand, fix candidates, the <2s perf budget, and the
6
+ ACCEPTANCE test: genesis + 3 extra west houses jams the Old Bridge
7
+ (demand ratio >= 1.0 at rush).
8
+ """
9
+
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
15
+
16
+ from engine import constants as C
17
+ from engine import traffic
18
+ from engine.city import CityState
19
+ from engine.genesis import genesis_features
20
+ from engine.world import World
21
+
22
+ NOW = C.CITY_EPOCH_S + 10_000
23
+ BRIDGE = ((8, 0), (9, 0))
24
+
25
+
26
+ def genesis_city() -> CityState:
27
+ return CityState.from_events(World.load(genesis_features()).features)
28
+
29
+
30
+ def west_growth_world() -> World:
31
+ """Genesis + 3 extra west houses on 1st Ave (the acceptance scenario)."""
32
+ world = World.load(genesis_features())
33
+ for i, (cx, cz) in enumerate(((4, -1), (5, -1), (6, -1))):
34
+ feature, obs = world.apply(
35
+ "w_000001", i,
36
+ {"tool": "place_building", "args": {
37
+ "kind": "house", "name": f"West House {i + 1}", "cx": cx, "cz": cz,
38
+ "w": 1, "d": 1, "floors": 1, "hue": 30, "variant": i}},
39
+ t=C.CITY_EPOCH_S + 60,
40
+ )
41
+ assert feature is not None, obs
42
+ return world
43
+
44
+
45
+ # ------------------------------------------------------------------ mulberry32
46
+
47
+
48
+ def test_mulberry32_matches_js_reference():
49
+ # Pinned from web/city/rng.js (node, June 12) — byte-stable contract.
50
+ expected = {
51
+ 0: (0.26642920868471265, 0.0003297457005828619, 0.22327202744781971),
52
+ 1: (0.62707394058816135, 0.0027357211802154779, 0.52744703995995224),
53
+ 12345: (0.97972826776094735, 0.30675226449966431, 0.484205421525985),
54
+ 0x7FFFFFFF: (0.4290980885270983, 0.12713524978607893, 0.38527749828062952),
55
+ }
56
+ for seed, values in expected.items():
57
+ rng = traffic.mulberry32(seed)
58
+ for v in values:
59
+ assert rng() == v
60
+
61
+
62
+ # -------------------------------------------------------------------- trip gen
63
+
64
+
65
+ def test_trip_table_deterministic():
66
+ a = traffic.trip_table(genesis_city(), NOW)
67
+ b = traffic.trip_table(genesis_city(), NOW)
68
+ assert [(h.id, d.id) for h, d in a] == [(h.id, d.id) for h, d in b]
69
+ assert len(a) == 20 # one trip per genesis resident
70
+
71
+
72
+ def test_trips_only_from_completed_buildings():
73
+ city = genesis_city()
74
+ # 1 s after epoch nothing is finished -> no homes emit commuters
75
+ assert traffic.trip_table(city, C.CITY_EPOCH_S + 1) == []
76
+
77
+
78
+ # ------------------------------------------------------------------------- A*
79
+
80
+
81
+ def test_astar_crosses_the_only_bridge():
82
+ city = genesis_city()
83
+ net = traffic.RoadNet.from_city(city)
84
+ west_home = next(b for b in city.buildings if b.name == "Elm Cottage")
85
+ factory = next(b for b in city.buildings if b.kind == "factory")
86
+ path = traffic._astar(net, {}, net.door(west_home), net.door(factory))
87
+ assert path is not None
88
+ assert any(cell in path for cell in BRIDGE)
89
+ # 4-connected, all on roads
90
+ for prev_cell, cell in zip(path, path[1:]):
91
+ assert abs(prev_cell[0] - cell[0]) + abs(prev_cell[1] - cell[1]) == 1
92
+ assert cell in net.cells
93
+
94
+
95
+ def test_assignment_deterministic():
96
+ a1 = traffic.assign(genesis_city(), NOW)
97
+ a2 = traffic.assign(genesis_city(), NOW)
98
+ assert a1.demand == a2.demand
99
+ assert a1.traffic_index == a2.traffic_index
100
+
101
+
102
+ def test_genesis_bridge_carries_demand_but_under_gate():
103
+ a = traffic.assign(genesis_city(), NOW)
104
+ ratios = [a.ratio(cell) for cell in BRIDGE]
105
+ assert all(r > 0 for r in ratios)
106
+ # the bottleneck-by-design sits NEAR the jam line but below the fix gate
107
+ assert all(r < C.FIX_GATE_RATIO for r in ratios)
108
+
109
+
110
+ # ------------------------------------------------------------------ ACCEPTANCE
111
+
112
+
113
+ def test_acceptance_west_growth_jams_old_bridge():
114
+ city = CityState.from_events(west_growth_world().features)
115
+ a = traffic.assign(city, NOW)
116
+ assert max(a.ratio(cell) for cell in BRIDGE) >= C.JAM_RATIO
117
+
118
+
119
+ # ------------------------------------------------------------------ candidates
120
+
121
+
122
+ def test_snapshot_stats_and_candidates():
123
+ city = CityState.from_events(west_growth_world().features)
124
+ stats, candidates = traffic.snapshot(city, NOW)
125
+ assert stats["max_ratio"] >= C.FIX_GATE_RATIO
126
+ assert stats["worst"] and stats["top"]
127
+ assert all(t["street"] for t in stats["top"])
128
+ assert 2 <= len(candidates) <= 4
129
+ current = stats["traffic_index"]
130
+ for i, cand in enumerate(candidates):
131
+ assert cand["id"] == f"F{i + 1}"
132
+ assert cand["action"] in ("new_road", "upgrade_avenue")
133
+ assert cand["cells"] and cand["klass"] in C.ROAD_CLASSES
134
+ assert cand["desc"]
135
+ assert cand["predicted_index"] < current # every fix actually helps
136
+ # best predicted first (F1 is the engine fallback choice)
137
+ predicted = [cand["predicted_index"] for cand in candidates]
138
+ assert predicted == sorted(predicted)
139
+
140
+
141
+ def test_candidate_apply_improves_assignment():
142
+ city = CityState.from_events(west_growth_world().features)
143
+ base = traffic.assign(city, NOW)
144
+ _, candidates = traffic.snapshot(city, NOW)
145
+ fixed = traffic.assign(city, NOW, candidates[0])
146
+ assert fixed.traffic_index < base.traffic_index
147
+ assert fixed.traffic_index == candidates[0]["predicted_index"]
148
+
149
+
150
+ def test_upgrade_candidate_repaints_in_place():
151
+ city = CityState.from_events(west_growth_world().features)
152
+ _, candidates = traffic.snapshot(city, NOW)
153
+ upgrades = [c for c in candidates if c["action"] == "upgrade_avenue"]
154
+ for cand in upgrades:
155
+ for cx, cz in cand["cells"]:
156
+ assert (cx, cz) in city.roads
157
+ assert city.roads[(cx, cz)].klass == "street"
158
+
159
+
160
+ # ------------------------------------------------------------------------ perf
161
+
162
+
163
+ def test_snapshot_under_two_seconds():
164
+ city = CityState.from_events(west_growth_world().features)
165
+ started = time.perf_counter()
166
+ traffic.snapshot(city, NOW)
167
+ assert time.perf_counter() - started < 2.0
tools/gen_web.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate web/city/constants.js + web/mock/world.json from engine constants
2
+ and genesis — the ONE source of truth crossing the Python/JS boundary.
3
+
4
+ Run from the repo root (the integrator runs this; output is deterministic, no
5
+ timestamps):
6
+
7
+ python tools/gen_web.py
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ ROOT = Path(__file__).resolve().parents[1]
17
+ sys.path.insert(0, str(ROOT))
18
+
19
+ from engine import constants as C # noqa: E402
20
+ from engine.genesis import genesis_features # noqa: E402
21
+
22
+
23
+ def _js(value) -> str:
24
+ return json.dumps(value, indent=2, sort_keys=False)
25
+
26
+
27
+ def constants_js() -> str:
28
+ buildings = {
29
+ kind: {
30
+ "w": spec["w"], "d": spec["d"],
31
+ "floors": list(spec["floors"]),
32
+ "residents": spec["residents"], "jobs": spec["jobs"],
33
+ "attract": spec["attract"],
34
+ # both spellings: engine uses duration_s, the JS kit reads duration
35
+ "duration": spec["duration_s"], "duration_s": spec["duration_s"],
36
+ }
37
+ for kind, spec in C.BUILDINGS.items()
38
+ }
39
+ durations = {kind: spec["duration_s"] for kind, spec in C.BUILDINGS.items()}
40
+ parts = [
41
+ "// GENERATED by tools/gen_web.py from engine/constants.py — do not edit.",
42
+ "// Values are the ARCHITECTURE.md contract; regenerate, never hand-tune.",
43
+ "",
44
+ f"export const GRID = {C.GRID};",
45
+ f"export const CELL = {C.CELL};",
46
+ f"export const COORD_MIN = {C.COORD_MIN};",
47
+ f"export const COORD_MAX = {C.COORD_MAX};",
48
+ f"export const CITY_EPOCH_S = {C.CITY_EPOCH_S};",
49
+ f"export const DAY_S = {C.DAY_S};",
50
+ "",
51
+ "export function riverCols(cz) {",
52
+ " return cz <= -5 ? [7, 8] : cz <= 3 ? [8, 9] : [9, 10];",
53
+ "}",
54
+ "",
55
+ f"export const BUILDINGS = {_js(buildings)};",
56
+ "",
57
+ f"export const ROAD_CLASSES = {_js(C.ROAD_CLASSES)};",
58
+ f"export const ROAD_DURATION_S = {C.ROAD_DURATION_S};",
59
+ f"export const DURATIONS = {_js(durations)};",
60
+ f"export const SYNONYMS = {_js(C.SYNONYMS)};",
61
+ f"export const STREET_NAMES = {_js(list(C.STREET_NAMES))};",
62
+ "",
63
+ "// traffic (mirrors engine/traffic.py — the Python side owns the facts)",
64
+ f"export const RUSH_HOURS = {_js(list(C.RUSH_HOURS))};",
65
+ f"export const RUSH_SIGMA = {C.RUSH_SIGMA};",
66
+ f"export const RUSH_BASE = {C.RUSH_BASE};",
67
+ f"export const RUSH_AMP = {C.RUSH_AMP};",
68
+ f"export const CAR_RATE = {C.CAR_RATE};",
69
+ f"export const CAR_MIN = {C.CAR_MIN};",
70
+ f"export const CAR_MAX = {C.CAR_MAX};",
71
+ f"export const CITIZENS_PER_POP = {C.CITIZENS_PER_POP};",
72
+ f"export const CITIZENS_MIN = {C.CITIZENS_MIN};",
73
+ f"export const CITIZENS_MAX = {C.CITIZENS_MAX};",
74
+ f"export const EMA_TAU_S = {C.EMA_TAU_S};",
75
+ f"export const TRIP_ATTRACT_FACTOR = {C.TRIP_ATTRACT_FACTOR};",
76
+ f"export const TRIP_DIST_EXP = {C.TRIP_DIST_EXP};",
77
+ f"export const DEMAND_PER_COMMUTER = {C.DEMAND_PER_COMMUTER};",
78
+ f"export const CONGESTION_COST_FACTOR = {C.CONGESTION_COST_FACTOR};",
79
+ f"export const TRAFFIC_TOP_CELLS = {C.TRAFFIC_TOP_CELLS};",
80
+ f"export const JAM_RATIO = {C.JAM_RATIO};",
81
+ f"export const GROWTH_RADIUS_MIN = {C.GROWTH_RADIUS_MIN};",
82
+ "",
83
+ ]
84
+ return "\n".join(parts)
85
+
86
+
87
+ def world_json() -> str:
88
+ features = genesis_features()
89
+ payload = {
90
+ "world": {
91
+ "version": len(features),
92
+ "epoch": 0,
93
+ "features": features,
94
+ }
95
+ }
96
+ return json.dumps(payload, indent=2) + "\n"
97
+
98
+
99
+ def main() -> None:
100
+ constants_path = ROOT / "web" / "city" / "constants.js"
101
+ world_path = ROOT / "web" / "mock" / "world.json"
102
+ world_path.parent.mkdir(parents=True, exist_ok=True)
103
+ constants_path.write_text(constants_js(), encoding="utf-8")
104
+ world_path.write_text(world_json(), encoding="utf-8")
105
+ print(f"wrote {constants_path.relative_to(ROOT)}")
106
+ print(f"wrote {world_path.relative_to(ROOT)}")
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()
tools/shot.mjs ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — headless screenshot harness (the screenshot-and-LOOK loop).
2
+ // Serves web/ itself on a free port (8200-8299), drives the mock city through
3
+ // deterministic seeks (?mock=1&t=<simTime> fixes the shared clock), and saves
4
+ // shots to tools/shots/.
5
+ //
6
+ // node tools/shot.mjs # full suite
7
+ // node tools/shot.mjs --god-night --street # subset
8
+ // node tools/shot.mjs --base http://localhost:8123
9
+ //
10
+ // Playwright resolves from this repo's node_modules if installed, else from
11
+ // the glassblower checkout next door (shares its browser cache).
12
+ import { mkdirSync } from "node:fs";
13
+ import { createServer } from "node:net";
14
+ import { spawn } from "node:child_process";
15
+ import { fileURLToPath } from "node:url";
16
+ import path from "node:path";
17
+
18
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
19
+ const WEB = path.join(HERE, "..", "web");
20
+ const OUT = path.join(HERE, "shots");
21
+ mkdirSync(OUT, { recursive: true });
22
+
23
+ const FLAGS = ["--god-day", "--god-night", "--street", "--heatmap", "--construction"];
24
+ const argv = process.argv.slice(2);
25
+ const baseIdx = argv.indexOf("--base");
26
+ let BASE = baseIdx > -1 ? argv[baseIdx + 1] : null;
27
+ const chosen = new Set(argv.filter((a) => FLAGS.includes(a)));
28
+ const want = (k) => chosen.size === 0 || chosen.has(k);
29
+
30
+ async function loadPlaywright() {
31
+ try {
32
+ return await import("playwright");
33
+ } catch {
34
+ const fallback = path.resolve(HERE, "../../glassblower/node_modules/playwright/index.mjs");
35
+ return import(`file://${fallback}`);
36
+ }
37
+ }
38
+
39
+ function freePort(start = 8200, end = 8299) {
40
+ return new Promise((resolve, reject) => {
41
+ const tryPort = (p) => {
42
+ if (p > end) return reject(new Error("no free port in 8200-8299"));
43
+ const srv = createServer();
44
+ srv.once("error", () => tryPort(p + 1));
45
+ srv.once("listening", () => srv.close(() => resolve(p)));
46
+ srv.listen(p, "127.0.0.1");
47
+ };
48
+ tryPort(start);
49
+ });
50
+ }
51
+
52
+ let server = null;
53
+ if (!BASE) {
54
+ const port = await freePort();
55
+ server = spawn("python3", ["-m", "http.server", String(port), "--directory", WEB], {
56
+ stdio: "ignore",
57
+ });
58
+ BASE = `http://127.0.0.1:${port}`;
59
+ await new Promise((r) => setTimeout(r, 900)); // let it bind
60
+ }
61
+ const cleanup = () => { if (server) { server.kill(); server = null; } };
62
+ process.on("exit", cleanup);
63
+ process.on("SIGINT", () => { cleanup(); process.exit(130); });
64
+
65
+ const { chromium } = await loadPlaywright();
66
+ const browser = await chromium.launch({
67
+ headless: true,
68
+ args: [
69
+ "--enable-unsafe-swiftshader",
70
+ "--use-gl=angle",
71
+ "--use-angle=swiftshader",
72
+ "--ignore-gpu-blocklist",
73
+ "--enable-webgl",
74
+ ],
75
+ });
76
+
77
+ const page = await browser.newPage({ viewport: { width: 1512, height: 945 }, deviceScaleFactor: 2 });
78
+ const errors = [];
79
+ page.on("console", (m) => { if (m.type() === "error") errors.push("console: " + m.text()); });
80
+ page.on("pageerror", (e) => errors.push("pageerror: " + e.message));
81
+
82
+ async function open(url) {
83
+ console.log("->", url);
84
+ await page.goto(`${BASE}/${url}`, { waitUntil: "networkidle", timeout: 45000 });
85
+ await page.waitForFunction(() => window.__app && window.__app.ready, { timeout: 30000 });
86
+ await page.evaluate(() => window.__app.pause(true));
87
+ await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready; });
88
+ }
89
+
90
+ async function shot(name) {
91
+ await page.screenshot({ path: path.join(OUT, name) });
92
+ const s = await page.evaluate(() => window.__app.stats());
93
+ console.log(` shot ${name} simTime=${s.simTime} calls=${s.calls ?? "?"} tris=${s.triangles ?? "?"} epoch=${s.epoch}`);
94
+ }
95
+
96
+ // ------------------------------------------------------------------ suite
97
+ // Day page: t=78 -> 07:48, morning rush + low golden sun. The welcome build
98
+ // (Cafe Luna) starts at t0+4 and its delta lands ~t0+7.7 — tick past it so a
99
+ // construction site is alive in the god view.
100
+ const needDayPage = want("--god-day") || want("--heatmap") || want("--construction") || want("--street");
101
+ if (needDayPage) {
102
+ await open("index.html?mock=1&t=78");
103
+ await page.evaluate(() => window.__app.tick(14));
104
+ if (want("--god-day")) await shot("god-day.png");
105
+
106
+ if (want("--heatmap")) {
107
+ await page.evaluate(() => { window.__app.setHeatmap(true); window.__app.tick(3); });
108
+ await shot("heatmap.png");
109
+ await page.evaluate(() => { window.__app.setHeatmap(false); window.__app.tick(0.5); });
110
+ }
111
+
112
+ if (want("--construction")) {
113
+ // apartments script (5 floors -> scaffold + crane), framed close mid-rise
114
+ await page.evaluate(() => {
115
+ window.__app.runScript(1); // Elm Court, anchor (1,-8) 2x2
116
+ window.__app.tick(6); // through plan + world_delta
117
+ window.__app.gazeCell(2, -7, 30);
118
+ window.__app.tick(9); // floors rising (45 s build)
119
+ });
120
+ await shot("construction.png");
121
+ }
122
+
123
+ if (want("--street")) {
124
+ // low camera down Main St (x=2). Manual override LAST on this page — it
125
+ // bypasses the rig, so no further rig-driven shots afterwards.
126
+ await page.evaluate(() => {
127
+ const a = window.__app.app;
128
+ window.__app.pause(true);
129
+ if (a.rig?.streetView) {
130
+ a.rig.streetView();
131
+ window.__app.tick(0.5);
132
+ } else if (a.camera) {
133
+ a.camera.position.set(2, 5, 60);
134
+ a.camera.lookAt(2, 2, -20);
135
+ a.renderOnce?.();
136
+ } else {
137
+ console.warn("street shot: no rig.streetView or app.camera exposed");
138
+ }
139
+ });
140
+ await shot("street.png");
141
+ }
142
+ }
143
+
144
+ // Night page: t=206 + tick(8) -> 21:24. Lamps, window glow, and the welcome
145
+ // construction site lit in orange.
146
+ if (want("--god-night")) {
147
+ await open("index.html?mock=1&t=206");
148
+ await page.evaluate(() => window.__app.tick(8));
149
+ await shot("god-night.png");
150
+ }
151
+
152
+ // ------------------------------------------------------------------ wrap
153
+ if (errors.length) {
154
+ console.log("\npage errors:");
155
+ for (const e of errors.slice(0, 25)) console.log(" " + e);
156
+ process.exitCode = 1;
157
+ } else {
158
+ console.log("\nno page errors");
159
+ }
160
+
161
+ await browser.close();
162
+ cleanup();
163
+ console.log("done ->", OUT);
traces/.gitkeep ADDED
File without changes
web/city/buildings.js ADDED
@@ -0,0 +1,827 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — the procedural building kit. 19 kinds assembled from shared
2
+ // part-archetype pools: ONE InstancedMesh per geometry archetype citywide
3
+ // (box / gable prism / cylinder / cone / dome) + ONE global emissive window
4
+ // pool + ONE emissive sign pool. Per-instance color carries the facade/roof
5
+ // palette with seeded jitter; whole city stays ~7 draw calls for buildings.
6
+ //
7
+ // Rise convention (construction.js drives, buildings.js owns the meshes):
8
+ // buildings.add() builds the FINISHED building at add time. construction.js
9
+ // masks it via setRise(id, yScale, floorsShown): solid parts + facade sign
10
+ // quads squash vertically (positions and Y-scales * yScale, ground-anchored);
11
+ // windows keep their FINAL matrices and are gated per floor by the aOn
12
+ // attribute (floor < floorsShown). setFinishGlow(id, k, intensity) sweeps the
13
+ // bottom-to-top window light-up at finish via the aBoost attribute.
14
+ //
15
+ // Night windows: pure in-shader stagger — uniform uDaylight (set every frame
16
+ // from update(dt, simTime, daylight); falls back to city.daylightValue) vs a
17
+ // per-instance hash threshold, so windows pop on across dusk with zero CPU.
18
+ import * as THREE from "three";
19
+ import { mulberry32 } from "./rng.js";
20
+ import { CELL, BUILDINGS } from "./constants.js";
21
+ import { PALETTE, FACADES, CIVIC_KINDS, ROOFS, EMISSIVE } from "./palette.js";
22
+ import { clamp, hsl } from "./util.js";
23
+
24
+ const FLOOR_H = 3; // m per floor (contract: floors*3)
25
+ const MARGIN = 0.8; // footprint inset: body = w*CELL - MARGIN
26
+ const WIN_GLOW = new THREE.Color(EMISSIVE.window);
27
+
28
+ const _m4 = new THREE.Matrix4();
29
+ const _p = new THREE.Vector3();
30
+ const _q = new THREE.Quaternion();
31
+ const _e = new THREE.Euler();
32
+ const _s = new THREE.Vector3();
33
+ const _c = new THREE.Color();
34
+ const _c2 = new THREE.Color();
35
+
36
+ // ---- instance pools --------------------------------------------------------
37
+ // Grow-in-chunks InstancedMesh wrapper. Freed slots collapse to zero scale and
38
+ // recycle; stored indices stay valid across growth. Dirty-range tracking keeps
39
+ // per-frame uploads to the written span (flush() runs from Buildings.update).
40
+ class Pool {
41
+ constructor(scene, baseGeo, material, capacity, { shadows = true, extra = null } = {}) {
42
+ this.scene = scene;
43
+ this.baseGeo = baseGeo;
44
+ this.material = material;
45
+ this.shadows = shadows;
46
+ this.extraDefs = extra || {};
47
+ this.top = 0;
48
+ this.free = [];
49
+ this.dirty = null; // [min, max] instance index span written this frame
50
+ this._build(capacity);
51
+ }
52
+
53
+ _build(capacity) {
54
+ const geo = this.baseGeo.clone();
55
+ this.extra = {};
56
+ for (const [name, size] of Object.entries(this.extraDefs)) {
57
+ const attr = new THREE.InstancedBufferAttribute(new Float32Array(capacity * size), size);
58
+ attr.setUsage(THREE.DynamicDrawUsage);
59
+ geo.setAttribute(name, attr);
60
+ this.extra[name] = attr;
61
+ }
62
+ const mesh = new THREE.InstancedMesh(geo, this.material, capacity);
63
+ mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
64
+ mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3).fill(1), 3);
65
+ mesh.count = this.top;
66
+ mesh.frustumCulled = false;
67
+ mesh.castShadow = this.shadows;
68
+ mesh.receiveShadow = this.shadows;
69
+ mesh.matrixAutoUpdate = false;
70
+ this.mesh = mesh;
71
+ this.geo = geo;
72
+ this.capacity = capacity;
73
+ this.scene.add(mesh);
74
+ }
75
+
76
+ _grow() {
77
+ const old = this.mesh, oldExtra = this.extra, oldGeo = this.geo;
78
+ this._build(this.capacity * 2);
79
+ this.mesh.instanceMatrix.array.set(old.instanceMatrix.array);
80
+ this.mesh.instanceColor.array.set(old.instanceColor.array);
81
+ for (const name of Object.keys(this.extraDefs)) this.extra[name].array.set(oldExtra[name].array);
82
+ this.mesh.instanceMatrix.needsUpdate = true;
83
+ this.mesh.instanceColor.needsUpdate = true;
84
+ for (const name of Object.keys(this.extraDefs)) this.extra[name].needsUpdate = true;
85
+ this.scene.remove(old);
86
+ old.dispose();
87
+ oldGeo.dispose();
88
+ }
89
+
90
+ alloc() {
91
+ let i = this.free.length ? this.free.pop() : -1;
92
+ if (i < 0) {
93
+ if (this.top >= this.capacity) this._grow();
94
+ i = this.top++;
95
+ this.mesh.count = this.top;
96
+ }
97
+ return i;
98
+ }
99
+
100
+ release(i) {
101
+ _m4.makeScale(0, 0, 0).setPosition(0, -10, 0);
102
+ this.setMatrix(i, _m4);
103
+ this.free.push(i);
104
+ }
105
+
106
+ setMatrix(i, m) {
107
+ this.mesh.setMatrixAt(i, m);
108
+ this._mark(i);
109
+ }
110
+
111
+ setColor(i, color) {
112
+ this.mesh.instanceColor.setXYZ(i, color.r, color.g, color.b);
113
+ this.mesh.instanceColor.needsUpdate = true;
114
+ }
115
+
116
+ setExtra(name, i, v) {
117
+ this.extra[name].setX(i, v);
118
+ this.extra[name].needsUpdate = true; // small attrs: full upload is cheap
119
+ }
120
+
121
+ _mark(i) {
122
+ if (!this.dirty) this.dirty = [i, i];
123
+ else {
124
+ if (i < this.dirty[0]) this.dirty[0] = i;
125
+ if (i > this.dirty[1]) this.dirty[1] = i;
126
+ }
127
+ }
128
+
129
+ flush() {
130
+ if (!this.dirty) return;
131
+ const attr = this.mesh.instanceMatrix;
132
+ attr.clearUpdateRanges();
133
+ attr.addUpdateRange(this.dirty[0] * 16, (this.dirty[1] - this.dirty[0] + 1) * 16);
134
+ attr.needsUpdate = true;
135
+ this.dirty = null;
136
+ }
137
+
138
+ dispose() {
139
+ this.scene.remove(this.mesh);
140
+ this.mesh.dispose();
141
+ this.geo.dispose();
142
+ }
143
+ }
144
+
145
+ // ---- archetype geometries (all solid geos ground-anchored: y in [0, 1]) ----
146
+ function gablePrismGeo() {
147
+ // ridge along X, base 1x1, apex y=1; duplicated verts for flat shading
148
+ const v = [];
149
+ const quad = (a, b, c, d) => v.push(...a, ...c, ...b, ...a, ...d, ...c); // CCW outward
150
+ quad([-0.5, 0, -0.5], [0.5, 0, -0.5], [0.5, 1, 0], [-0.5, 1, 0]); // north slope
151
+ quad([0.5, 0, 0.5], [-0.5, 0, 0.5], [-0.5, 1, 0], [0.5, 1, 0]); // south slope
152
+ v.push(0.5, 0, -0.5, 0.5, 1, 0, 0.5, 0, 0.5); // east gable
153
+ v.push(-0.5, 0, 0.5, -0.5, 1, 0, -0.5, 0, -0.5); // west gable
154
+ const geo = new THREE.BufferGeometry();
155
+ geo.setAttribute("position", new THREE.Float32BufferAttribute(v, 3));
156
+ geo.computeVertexNormals();
157
+ return geo;
158
+ }
159
+
160
+ // emissive window/sign quads share fog handling with ground.js's water shader
161
+ function windowMaterial() {
162
+ return new THREE.ShaderMaterial({
163
+ fog: true,
164
+ uniforms: THREE.UniformsUtils.merge([THREE.UniformsLib.fog, { uDaylight: { value: 1 } }]),
165
+ vertexShader: /* glsl */ `
166
+ attribute float aHash;
167
+ attribute float aOn;
168
+ attribute float aBoost;
169
+ varying float vHash, vOn, vBoost;
170
+ #include <common>
171
+ #include <fog_pars_vertex>
172
+ void main() {
173
+ vHash = aHash; vOn = aOn; vBoost = aBoost;
174
+ #ifdef USE_INSTANCING
175
+ vec4 wp = modelMatrix * instanceMatrix * vec4(position, 1.0);
176
+ #else
177
+ vec4 wp = modelMatrix * vec4(position, 1.0);
178
+ #endif
179
+ vec4 mvPosition = viewMatrix * wp;
180
+ gl_Position = projectionMatrix * mvPosition;
181
+ #include <fog_vertex>
182
+ }`,
183
+ fragmentShader: /* glsl */ `
184
+ #include <common>
185
+ #include <fog_pars_fragment>
186
+ uniform float uDaylight;
187
+ varying float vHash, vOn, vBoost;
188
+ void main() {
189
+ if (vOn < 0.5 && vBoost <= 0.01) discard;
190
+ float night = 1.0 - uDaylight;
191
+ // dusk stagger: each window pops when night passes its hash threshold
192
+ // (aHash 2.0 = a permanently dark window)
193
+ float lit = vOn * smoothstep(vHash, vHash + 0.05, night);
194
+ float m = max(lit, vBoost);
195
+ vec3 day = vec3(0.16, 0.19, 0.23) * mix(1.0, 0.35, night); // dark glass
196
+ vec3 glow = vec3(1.0, 0.788, 0.478) * (1.4 + 0.7 * fract(vHash * 7.91));
197
+ gl_FragColor = vec4(mix(day, glow, m), 1.0);
198
+ #include <fog_fragment>
199
+ }`,
200
+ });
201
+ }
202
+
203
+ function signMaterial() {
204
+ return new THREE.ShaderMaterial({
205
+ fog: true,
206
+ side: THREE.DoubleSide,
207
+ uniforms: THREE.UniformsUtils.merge([THREE.UniformsLib.fog, { uDaylight: { value: 1 } }]),
208
+ vertexShader: /* glsl */ `
209
+ attribute float aDay;
210
+ attribute float aNight;
211
+ varying float vDay, vNight;
212
+ varying vec3 vColor;
213
+ #include <common>
214
+ #include <fog_pars_vertex>
215
+ void main() {
216
+ vDay = aDay; vNight = aNight;
217
+ vColor = vec3(1.0);
218
+ #ifdef USE_INSTANCING_COLOR
219
+ vColor = instanceColor;
220
+ #endif
221
+ #ifdef USE_INSTANCING
222
+ vec4 wp = modelMatrix * instanceMatrix * vec4(position, 1.0);
223
+ #else
224
+ vec4 wp = modelMatrix * vec4(position, 1.0);
225
+ #endif
226
+ vec4 mvPosition = viewMatrix * wp;
227
+ gl_Position = projectionMatrix * mvPosition;
228
+ #include <fog_vertex>
229
+ }`,
230
+ fragmentShader: /* glsl */ `
231
+ #include <common>
232
+ #include <fog_pars_fragment>
233
+ uniform float uDaylight;
234
+ varying float vDay, vNight;
235
+ varying vec3 vColor;
236
+ void main() {
237
+ float night = smoothstep(0.25, 0.75, 1.0 - uDaylight);
238
+ gl_FragColor = vec4(vColor * mix(vDay, vNight, night), 1.0);
239
+ #include <fog_fragment>
240
+ }`,
241
+ });
242
+ }
243
+
244
+ // ---- Buildings -------------------------------------------------------------
245
+ export class Buildings {
246
+ constructor(scene, quality, city = null) {
247
+ this.scene = scene;
248
+ this.quality = quality || { instances: 1 };
249
+ this.city = city;
250
+ this.items = new Map(); // id -> record
251
+ this._daylight = 1;
252
+
253
+ const solid = new THREE.MeshStandardMaterial({
254
+ roughness: 0.85, metalness: 0, envMapIntensity: 0.25,
255
+ });
256
+ this.pools = {
257
+ box: new Pool(scene, new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), solid, 1024),
258
+ gable: new Pool(scene, gablePrismGeo(), solid, 256),
259
+ cyl: new Pool(scene, new THREE.CylinderGeometry(0.5, 0.5, 1, 10).translate(0, 0.5, 0), solid, 512),
260
+ cone: new Pool(scene, new THREE.ConeGeometry(0.5, 1, 7).translate(0, 0.5, 0), solid, 512),
261
+ dome: new Pool(scene, new THREE.SphereGeometry(0.5, 12, 6, 0, Math.PI * 2, 0, Math.PI / 2), solid, 16),
262
+ win: new Pool(scene, new THREE.PlaneGeometry(1, 1), windowMaterial(), 2048,
263
+ { shadows: false, extra: { aHash: 1, aOn: 1, aBoost: 1 } }),
264
+ sign: new Pool(scene, new THREE.PlaneGeometry(1, 1), signMaterial(), 256,
265
+ { shadows: false, extra: { aDay: 1, aNight: 1 } }),
266
+ };
267
+ }
268
+
269
+ /** Footprint/height info for construction.js. */
270
+ getInfo(id) {
271
+ const r = this.items.get(id);
272
+ if (!r) return null;
273
+ return { x: r.x, z: r.z, W: r.W, D: r.D, kind: r.kind, floors: r.floors, height: r.height };
274
+ }
275
+
276
+ /** world.js skeleton calls addFeature(f, opts); keep both names. */
277
+ addFeature(f) { return this.add(f); }
278
+
279
+ add(f) {
280
+ if (!f || f.tool !== "place_building" || !f.args || this.items.has(f.id)) return;
281
+ const a = f.args;
282
+ const spec = BUILDINGS[a.kind] || BUILDINGS.house;
283
+ const kind = BUILDINGS[a.kind] ? a.kind : "house";
284
+ const w = a.w ?? spec.w, d = a.d ?? spec.d;
285
+ const floors = clamp(a.floors ?? Math.round((spec.floors[0] + spec.floors[1]) / 2),
286
+ spec.floors[0], spec.floors[1]);
287
+ const variant = a.variant ?? 0;
288
+ const rng = mulberry32(((f.seed ?? 1) ^ Math.imul(variant, 0x9e3779b9)) >>> 0);
289
+
290
+ const rec = {
291
+ id: f.id, kind, floors,
292
+ x: a.cx * CELL + (w * CELL) / 2, z: a.cz * CELL + (d * CELL) / 2,
293
+ W: w * CELL - MARGIN, D: d * CELL - MARGIN,
294
+ height: Math.max(1, floors) * FLOOR_H,
295
+ parts: [], // { pool, idx, x, y, z, sx, sy, sz, rx, ry, rz } — y squashes
296
+ windows: [], // { idx, floor, yFrac } — fixed matrices, aOn/aBoost gated
297
+ yScale: 1, floorsShown: floors,
298
+ blob: null,
299
+ };
300
+
301
+ // facade + roof colors, seeded jitter +-6deg hue +-5% lightness
302
+ if (CIVIC_KINDS.includes(kind)) _c.set(PALETTE.civic);
303
+ else if (kind === "fire_station") _c.set("#BD6B6E");
304
+ else if (a.hue != null) hsl(a.hue, 0.42, 0.72, _c);
305
+ else _c.set(FACADES[Math.floor(rng() * FACADES.length)]);
306
+ _c.offsetHSL((rng() * 2 - 1) * (6 / 360), 0, (rng() * 2 - 1) * 0.05);
307
+ const facade = _c.clone();
308
+ _c2.set(ROOFS[kind] || "#D9D5CB").offsetHSL(0, 0, (rng() * 2 - 1) * 0.03);
309
+ const roof = _c2.clone();
310
+
311
+ const builder = KIND_BUILDERS[kind] || KIND_BUILDERS.house;
312
+ builder(this, rec, rng, facade, roof);
313
+
314
+ if (this.city && this.city.ground && this.city.ground.addBlob) {
315
+ rec.blob = this.city.ground.addBlob(rec.x, rec.z, Math.max(w, d) * CELL * 1.25, 0.22);
316
+ }
317
+ this.items.set(f.id, rec);
318
+ this._writeMatrices(rec);
319
+ }
320
+
321
+ remove(id) {
322
+ const r = this.items.get(id);
323
+ if (!r) return;
324
+ for (const p of r.parts) p.pool.release(p.idx);
325
+ for (const wn of r.windows) this.pools.win.release(wn.idx);
326
+ if (r.blob) r.blob.release();
327
+ this.items.delete(id);
328
+ }
329
+
330
+ reset() {
331
+ for (const id of [...this.items.keys()]) this.remove(id);
332
+ }
333
+
334
+ /**
335
+ * Construction mask. yScale in [0, ~1.1] (easeOutBack may overshoot),
336
+ * floorsShown = completed floors whose windows show at FINAL positions.
337
+ */
338
+ setRise(id, yScale, floorsShown) {
339
+ const r = this.items.get(id);
340
+ if (!r) return;
341
+ r.yScale = Math.max(0, yScale);
342
+ this._writeMatrices(r);
343
+ if (floorsShown !== r.floorsShown) {
344
+ r.floorsShown = floorsShown;
345
+ for (const wn of r.windows) {
346
+ this.pools.win.setExtra("aOn", wn.idx, wn.floor < floorsShown ? 1 : 0);
347
+ }
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Finish beat: windows light bottom-to-top. k in [0,1] = sweep position,
353
+ * intensity fades the celebration glow back to normal night behavior;
354
+ * intensity 0 clears.
355
+ */
356
+ setFinishGlow(id, k, intensity = 1) {
357
+ const r = this.items.get(id);
358
+ if (!r) return;
359
+ for (const wn of r.windows) {
360
+ const b = intensity * clamp((k - wn.yFrac + 0.06) / 0.12, 0, 1);
361
+ this.pools.win.setExtra("aBoost", wn.idx, b);
362
+ }
363
+ }
364
+
365
+ update(dt, simTime, daylight) {
366
+ this._daylight = daylight ?? (this.city ? this.city.daylightValue : this._daylight);
367
+ this.pools.win.material.uniforms.uDaylight.value = this._daylight;
368
+ this.pools.sign.material.uniforms.uDaylight.value = this._daylight;
369
+ for (const pool of Object.values(this.pools)) pool.flush();
370
+ }
371
+
372
+ // ---- internals -----------------------------------------------------------
373
+ _writeMatrices(r) {
374
+ const k = r.yScale;
375
+ for (const p of r.parts) {
376
+ _p.set(p.x, p.y * k, p.z);
377
+ _e.set(p.rx, p.ry, p.rz);
378
+ _q.setFromEuler(_e);
379
+ _s.set(p.sx, Math.max(p.sy * k, 0.0001), p.sz);
380
+ _m4.compose(_p, _q, _s);
381
+ p.pool.setMatrix(p.idx, _m4);
382
+ }
383
+ }
384
+
385
+ /** Solid/sign part; ground-anchored, vertically squashed by setRise. */
386
+ _part(r, poolName, color, x, y, z, sx, sy, sz, ry = 0, rx = 0, rz = 0, extra = null) {
387
+ const pool = this.pools[poolName];
388
+ const idx = pool.alloc();
389
+ pool.setColor(idx, color);
390
+ if (extra) for (const [name, v] of Object.entries(extra)) pool.setExtra(name, idx, v);
391
+ r.parts.push({ pool, idx, x, y, z, sx, sy, sz, rx, ry, rz });
392
+ }
393
+
394
+ /** One window quad at its FINAL spot (never squashed; aOn gates per floor). */
395
+ _window(r, rng, x, y, z, ry, wW, wH, floor, yFrac) {
396
+ const pool = this.pools.win;
397
+ const idx = pool.alloc();
398
+ _p.set(x, y, z);
399
+ _e.set(0, ry, 0);
400
+ _q.setFromEuler(_e);
401
+ _s.set(wW, wH, 1);
402
+ _m4.compose(_p, _q, _s);
403
+ pool.setMatrix(idx, _m4);
404
+ pool.setExtra("aHash", idx, rng() < 0.15 ? 2.0 : rng());
405
+ pool.setExtra("aOn", idx, 1);
406
+ pool.setExtra("aBoost", idx, 0);
407
+ r.windows.push({ idx, floor, yFrac });
408
+ }
409
+
410
+ /** Window grids on the 4 facades of a box spanning [floor0, floor0+floors). */
411
+ _windowsBox(r, rng, xc, zc, W, D, floor0, floors, totalFloors,
412
+ { density = 1, wW = 0.62, wH = 1.05, skip = 0.1 } = {}) {
413
+ density *= this.quality.instances ?? 1;
414
+ const faces = [
415
+ [0, D / 2 + 0.03, 0, W], // +z
416
+ [0, -(D / 2 + 0.03), Math.PI, W], // -z
417
+ [W / 2 + 0.03, 0, Math.PI / 2, D], // +x
418
+ [-(W / 2 + 0.03), 0, -Math.PI / 2, D], // -x
419
+ ];
420
+ for (let fl = 0; fl < floors; fl++) {
421
+ const y = (floor0 + fl) * FLOOR_H + FLOOR_H * 0.52;
422
+ const yFrac = (floor0 + fl + 0.5) / Math.max(totalFloors, 1);
423
+ for (const [ox, oz, ry, len] of faces) {
424
+ const n = Math.max(1, Math.round((len / 1.8) * density));
425
+ for (let i = 0; i < n; i++) {
426
+ if (rng() < skip) continue;
427
+ const u = ((i + 0.5) / n - 0.5) * (len - 0.9);
428
+ const x = ry === 0 || ry === Math.PI ? xc + u : xc + ox;
429
+ const z = ry === 0 || ry === Math.PI ? zc + oz : zc + u;
430
+ this._window(r, rng, x, y, z, ry, wW, wH, floor0 + fl, yFrac);
431
+ }
432
+ }
433
+ }
434
+ }
435
+
436
+ /** Tree: trunk + 1-2 stacked canopy cones; casts shadow, gets a small blob. */
437
+ _tree(r, rng, x, z, scale = 1) {
438
+ const h = (1.6 + rng() * 1.3) * scale;
439
+ _c.set("#7A5A3C").offsetHSL(0, 0, (rng() * 2 - 1) * 0.04);
440
+ this._part(r, "cyl", _c, x, 0, z, 0.22 * scale, h * 0.45, 0.22 * scale);
441
+ _c.set(PALETTE.park).offsetHSL((rng() * 2 - 1) * 0.03, 0, (rng() * 2 - 1) * 0.06);
442
+ const cw = (1.3 + rng() * 0.7) * scale;
443
+ this._part(r, "cone", _c, x, h * 0.35, z, cw, h * 0.85, cw);
444
+ if (rng() < 0.5) this._part(r, "cone", _c, x, h * 0.75, z, cw * 0.7, h * 0.55, cw * 0.7);
445
+ if (this.city && this.city.ground && this.city.ground.addBlob) {
446
+ // park trees share the building record lifetime; released in remove()
447
+ const blob = this.city.ground.addBlob(x, z, cw * 1.1, 0.16);
448
+ r.parts.push({ pool: { release: () => blob.release(), setMatrix() {} }, idx: 0, x, y: 0, z, sx: 0, sy: 0, sz: 0, rx: 0, ry: 0, rz: 0, _blob: true });
449
+ }
450
+ }
451
+
452
+ _bench(r, rng, x, z, ry) {
453
+ _c.set("#8A6B4F").offsetHSL(0, 0, (rng() * 2 - 1) * 0.05);
454
+ this._part(r, "box", _c, x, 0.28, z, 1.1, 0.1, 0.4, ry);
455
+ this._part(r, "box", _c, x, 0, z, 1.0, 0.3, 0.32, ry);
456
+ }
457
+
458
+ _fountain(r, rng, x, z, scale = 1) {
459
+ _c.set(PALETTE.sidewalk).offsetHSL(0, 0, -0.04);
460
+ this._part(r, "cyl", _c, x, 0, z, 3.2 * scale, 0.5, 3.2 * scale);
461
+ _c.set(PALETTE.water).offsetHSL(0, 0, 0.06);
462
+ this._part(r, "cyl", _c, x, 0.3, z, 2.7 * scale, 0.28, 2.7 * scale);
463
+ _c.set(PALETTE.sidewalk);
464
+ this._part(r, "cyl", _c, x, 0.4, z, 0.5 * scale, 1.1 * scale, 0.5 * scale);
465
+ }
466
+ }
467
+
468
+ // ---- per-kind silhouettes ---------------------------------------------------
469
+ // Each builder fills rec.parts/rec.windows via b._part/b._windowsBox. All parts
470
+ // are positioned in WORLD space around (rec.x, rec.z); y = ground offset.
471
+ // Signature pieces per the Visual Bible: water towers on apartments, AC
472
+ // clusters, cafe awning + tables + neon, sawtooth + smokestack factory,
473
+ // hospital cross, church steeple, stadium masts, town_hall dome.
474
+
475
+ function bodyAndFlatRoof(b, r, facade, roof, { W = r.W, D = r.D, H = r.height, x = r.x, z = r.z } = {}) {
476
+ b._part(r, "box", facade, x, 0, z, W, H, D);
477
+ b._part(r, "box", roof, x, H, z, W + 0.25, 0.22, D + 0.25);
478
+ }
479
+
480
+ function acBoxes(b, r, rng, n, roofY, W, D) {
481
+ _c.set("#B9B4A8");
482
+ for (let i = 0; i < n; i++) {
483
+ b._part(r, "box", _c, r.x + (rng() - 0.5) * W * 0.55, roofY + 0.2, r.z + (rng() - 0.5) * D * 0.55,
484
+ 0.7 + rng() * 0.5, 0.45, 0.6 + rng() * 0.4, rng() * Math.PI);
485
+ }
486
+ }
487
+
488
+ function neonSign(b, r, rng, x, y, z, ry, w, h) {
489
+ _c.set(rng() < 0.5 ? EMISSIVE.neonTeal : EMISSIVE.neonPink);
490
+ b._part(r, "sign", _c, x, y, z, w, h, 1, ry, 0, 0, { aDay: 0.3, aNight: 1.9 });
491
+ }
492
+
493
+ function doorQuad(b, r, x, y, z, ry, w, h) {
494
+ _c.set("#4A4038");
495
+ b._part(r, "sign", _c, x, y, z, w, h, 1, ry, 0, 0, { aDay: 1.0, aNight: 0.7 });
496
+ }
497
+
498
+ const KIND_BUILDERS = {
499
+ house(b, r, rng, facade, roof) {
500
+ const H = r.height;
501
+ b._part(r, "box", facade, r.x, 0, r.z, r.W, H, r.D);
502
+ const ry = rng() < 0.5 ? 0 : Math.PI / 2;
503
+ b._part(r, "gable", roof, r.x, H, r.z, (ry ? r.D : r.W) + 0.4, 1.3, (ry ? r.W : r.D) + 0.4, ry);
504
+ _c.set("#6E5546");
505
+ b._part(r, "box", _c, r.x + r.W * 0.28, H + 0.5, r.z - r.D * 0.2, 0.4, 1.3, 0.4); // chimney
506
+ doorQuad(b, r, r.x, 1.05, r.z + r.D / 2 + 0.03, 0, 0.9, 2.1);
507
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 0, r.floors, r.floors, { density: 0.8 });
508
+ if (rng() < 0.4) b._tree(r, rng, r.x - r.W * 0.62 - 0.6, r.z + r.D * 0.4, 0.7);
509
+ },
510
+
511
+ townhouse(b, r, rng, facade, roof) {
512
+ const W = r.W * 0.88, H = r.height;
513
+ b._part(r, "box", facade, r.x, 0, r.z, W, H, r.D);
514
+ b._part(r, "gable", roof, r.x, H, r.z, r.D + 0.4, 1.5, W + 0.3, Math.PI / 2);
515
+ _c.set("#6E5546");
516
+ b._part(r, "box", _c, r.x - W * 0.25, H + 0.7, r.z, 0.35, 1.4, 0.35);
517
+ _c.copy(facade).offsetHSL(0, 0, -0.12);
518
+ b._part(r, "box", _c, r.x, 0, r.z + r.D / 2 + 0.3, 1.3, 0.45, 0.8); // stoop
519
+ doorQuad(b, r, r.x, 1.1, r.z + r.D / 2 + 0.03, 0, 0.85, 2.1);
520
+ b._windowsBox(r, rng, r.x, r.z, W, r.D, 0, r.floors, r.floors, { density: 0.8, wH: 1.25 });
521
+ },
522
+
523
+ apartments(b, r, rng, facade, roof) {
524
+ const H = r.height;
525
+ bodyAndFlatRoof(b, r, facade, roof);
526
+ // water tower: 4 legs + tank + cone cap
527
+ const tx = r.x + r.W * 0.22, tz = r.z - r.D * 0.22;
528
+ _c.set("#5C5247");
529
+ for (const [lx, lz] of [[-0.5, -0.5], [0.5, -0.5], [-0.5, 0.5], [0.5, 0.5]]) {
530
+ b._part(r, "box", _c, tx + lx * 0.9, H, tz + lz * 0.9, 0.14, 1.1, 0.14);
531
+ }
532
+ _c.set("#8A6B4F").offsetHSL(0, 0, (rng() - 0.5) * 0.06);
533
+ b._part(r, "cyl", _c, tx, H + 1.1, tz, 1.7, 1.5, 1.7);
534
+ _c.offsetHSL(0, 0, -0.1);
535
+ b._part(r, "cone", _c, tx, H + 2.6, tz, 1.9, 0.8, 1.9);
536
+ acBoxes(b, r, rng, 2, H, r.W * 0.7, r.D * 0.7);
537
+ // fire-escape strip: vertical rail + per-floor landings on one facade
538
+ _c.set("#3F454F");
539
+ const fx = r.x - r.W * 0.28, fz = r.z + r.D / 2 + 0.18;
540
+ b._part(r, "box", _c, fx, 0.4, fz, 0.1, H - 1, 0.1);
541
+ for (let i = 1; i < r.floors; i++) b._part(r, "box", _c, fx, i * FLOOR_H - 0.1, fz, 1.7, 0.09, 0.5);
542
+ doorQuad(b, r, r.x + r.W * 0.2, 1.1, r.z + r.D / 2 + 0.03, 0, 1.1, 2.2);
543
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 0, r.floors, r.floors);
544
+ },
545
+
546
+ cafe(b, r, rng, facade, roof) {
547
+ const H = r.height;
548
+ bodyAndFlatRoof(b, r, facade, roof);
549
+ acBoxes(b, r, rng, 1, H, r.W * 0.6, r.D * 0.6);
550
+ const front = r.z + r.D / 2;
551
+ _c.set(["#E07856", "#7FB69E", "#F2CC8F"][Math.floor(rng() * 3)]);
552
+ b._part(r, "box", _c, r.x, H * 0.72, front + 0.55, r.W * 0.95, 0.08, 1.3, 0, -0.42); // awning
553
+ doorQuad(b, r, r.x - r.W * 0.22, 1.05, front + 0.03, 0, 0.85, 2.1);
554
+ // big warm display window + neon sign above the awning
555
+ _c.set("#FFE9C2");
556
+ b._part(r, "sign", _c, r.x + r.W * 0.18, 1.5, front + 0.03, r.W * 0.45, 1.5, 1, 0, 0, 0, { aDay: 0.55, aNight: 1.4 });
557
+ neonSign(b, r, rng, r.x, H - 0.45, front + 0.06, 0, r.W * 0.7, 0.55);
558
+ // sidewalk tables under the awning
559
+ _c.set(PALETTE.sidewalk);
560
+ const nt = 2 + Math.floor(rng() * 2);
561
+ for (let i = 0; i < nt; i++) {
562
+ b._part(r, "cyl", _c, r.x + (rng() - 0.5) * r.W * 0.8, 0, front + 1.0 + rng() * 0.5, 0.5, 0.72, 0.5);
563
+ }
564
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 0, 1, 1, { density: 0.6 });
565
+ },
566
+
567
+ shop(b, r, rng, facade, roof) {
568
+ const H = r.height;
569
+ bodyAndFlatRoof(b, r, facade, roof);
570
+ const front = r.z + r.D / 2;
571
+ if (rng() < 0.6) {
572
+ _c.set(["#8FB8D8", "#E07856", "#A9B98A"][Math.floor(rng() * 3)]);
573
+ b._part(r, "box", _c, r.x, 2.5, front + 0.5, r.W * 0.9, 0.08, 1.1, 0, -0.4);
574
+ }
575
+ _c.set("#FFE9C2");
576
+ b._part(r, "sign", _c, r.x - r.W * 0.12, 1.45, front + 0.03, r.W * 0.55, 1.4, 1, 0, 0, 0, { aDay: 0.55, aNight: 1.35 });
577
+ doorQuad(b, r, r.x + r.W * 0.3, 1.0, front + 0.03, 0, 0.8, 2.0);
578
+ // corner sign pole with a neon blade sign
579
+ _c.set("#3F454F");
580
+ const px = r.x + r.W / 2 + 0.25, pz = front - 0.3;
581
+ b._part(r, "box", _c, px, 0, pz, 0.16, H + 1.4, 0.16);
582
+ neonSign(b, r, rng, px, H + 0.5, pz, Math.PI / 2, 1.4, 0.8);
583
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 1, Math.max(r.floors - 1, 0), r.floors, { density: 0.8 });
584
+ },
585
+
586
+ market(b, r, rng, facade, roof) {
587
+ const H = r.height;
588
+ bodyAndFlatRoof(b, r, facade, roof);
589
+ // stall awnings march down the long (z) side; crates by the entrance
590
+ const sx = r.x + r.W / 2;
591
+ for (let i = 0; i < 3; i++) {
592
+ _c.set(["#E07856", "#F2CC8F", "#7FB69E"][i % 3]).offsetHSL(0, 0, (rng() - 0.5) * 0.06);
593
+ b._part(r, "box", _c, sx + 0.5, H * 0.62, r.z + (i - 1) * (r.D / 3.2), 1.2, 0.08, r.D / 3.6, 0, 0, 0.42);
594
+ }
595
+ _c.set("#A98B5F");
596
+ for (let i = 0; i < 3; i++) {
597
+ b._part(r, "box", _c, r.x - r.W * 0.3 + rng() * 0.8, 0, r.z + r.D / 2 + 0.6 + rng() * 0.4, 0.55, 0.55, 0.55, rng());
598
+ }
599
+ doorQuad(b, r, r.x, 1.25, r.z + r.D / 2 + 0.03, 0, 2.2, 2.5);
600
+ neonSign(b, r, rng, r.x, H - 0.4, r.z + r.D / 2 + 0.06, 0, r.W * 0.55, 0.5);
601
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 0, 1, 1, { density: 0.5 });
602
+ },
603
+
604
+ bank(b, r, rng, facade, roof) {
605
+ const H = r.height;
606
+ _c.copy(facade).offsetHSL(0, 0, -0.08);
607
+ b._part(r, "box", _c, r.x, 0, r.z, r.W + 0.6, 0.4, r.D + 0.9); // stepped base
608
+ b._part(r, "box", facade, r.x, 0.4, r.z, r.W, H - 0.4, r.D);
609
+ b._part(r, "box", roof, r.x, H, r.z, r.W + 0.35, 0.3, r.D + 0.35); // cornice
610
+ const front = r.z + r.D / 2;
611
+ for (let i = 0; i < 4; i++) {
612
+ const cx = r.x + ((i / 3) - 0.5) * r.W * 0.7;
613
+ b._part(r, "cyl", facade, cx, 0.4, front + 0.45, 0.42, H * 0.78, 0.42);
614
+ }
615
+ _c.copy(facade).offsetHSL(0, 0, -0.05);
616
+ b._part(r, "gable", _c, r.x, H * 0.82, front + 0.3, r.W * 0.82, 1.1, 1.1); // pediment
617
+ doorQuad(b, r, r.x, 1.3, front + 0.03, 0, 1.2, 2.6);
618
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 1, Math.max(r.floors - 1, 0), r.floors, { density: 0.7, wH: 1.3 });
619
+ },
620
+
621
+ office(b, r, rng, facade, roof) {
622
+ const H = r.height;
623
+ bodyAndFlatRoof(b, r, facade, roof);
624
+ _c.copy(facade).offsetHSL(0, 0, -0.18);
625
+ b._part(r, "box", _c, r.x, 0, r.z, r.W + 0.08, FLOOR_H * 0.9, r.D + 0.08); // lobby band
626
+ acBoxes(b, r, rng, 2, H, r.W * 0.6, r.D * 0.6);
627
+ doorQuad(b, r, r.x, 1.15, r.z + r.D / 2 + 0.08, 0, 1.3, 2.3);
628
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 1, r.floors - 1, r.floors, { density: 1.1, wW: 0.7 });
629
+ },
630
+
631
+ tower(b, r, rng, facade, roof) {
632
+ const f1 = Math.max(2, Math.round(r.floors * 0.55));
633
+ const f2 = Math.max(1, Math.round(r.floors * 0.3));
634
+ const f3 = Math.max(0, r.floors - f1 - f2);
635
+ const tiers = [[0, f1, 1], [f1, f2, 0.78], f3 > 0 ? [f1 + f2, f3, 0.6] : null].filter(Boolean);
636
+ for (const [floor0, n, scale] of tiers) {
637
+ const W = r.W * scale, D = r.D * scale, y0 = floor0 * FLOOR_H, h = n * FLOOR_H;
638
+ b._part(r, "box", facade, r.x, y0, r.z, W, h, D);
639
+ b._part(r, "box", roof, r.x, y0 + h, r.z, W + 0.2, 0.22, D + 0.2);
640
+ b._windowsBox(r, rng, r.x, r.z, W, D, floor0, n, r.floors, { density: 1.1, wW: 0.7 });
641
+ }
642
+ _c.set("#3F454F");
643
+ b._part(r, "box", _c, r.x, r.height, r.z, 0.18, 2.6, 0.18); // roof mast
644
+ _c.set(EMISSIVE.signalRed);
645
+ b._part(r, "sign", _c, r.x, r.height + 2.3, r.z + 0.12, 0.3, 0.3, 1, 0, 0, 0, { aDay: 0.5, aNight: 2.2 });
646
+ doorQuad(b, r, r.x, 1.3, r.z + r.D / 2 + 0.03, 0, 1.6, 2.6);
647
+ },
648
+
649
+ school(b, r, rng, facade, roof) {
650
+ const H = r.height;
651
+ b._part(r, "box", facade, r.x, 0, r.z, r.W, H, r.D);
652
+ b._part(r, "gable", roof, r.x, H, r.z, r.W + 0.4, 1.6, r.D + 0.4);
653
+ // flagpole + little flag by the entrance
654
+ _c.set("#C9C4B8");
655
+ const px = r.x - r.W * 0.32, pz = r.z + r.D / 2 + 0.8;
656
+ b._part(r, "box", _c, px, 0, pz, 0.1, 5.2, 0.1);
657
+ _c.set(PALETTE.accent);
658
+ b._part(r, "sign", _c, px + 0.55, 4.6, pz, 1.0, 0.6, 1, Math.PI / 2, 0, 0, { aDay: 1.0, aNight: 0.8 });
659
+ doorQuad(b, r, r.x, 1.25, r.z + r.D / 2 + 0.03, 0, 1.8, 2.5);
660
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 0, r.floors, r.floors, { density: 1.1, wW: 0.75 });
661
+ },
662
+
663
+ hospital(b, r, rng, facade, roof) {
664
+ const H = r.height;
665
+ bodyAndFlatRoof(b, r, facade, roof);
666
+ acBoxes(b, r, rng, 3, H, r.W * 0.6, r.D * 0.6);
667
+ // emissive red cross above the entrance (reads in daylight too)
668
+ const front = r.z + r.D / 2 + 0.06;
669
+ _c.set(EMISSIVE.signalRed);
670
+ b._part(r, "sign", _c, r.x, H - 1.6, front, 0.6, 1.8, 1, 0, 0, 0, { aDay: 1.0, aNight: 2.2 });
671
+ b._part(r, "sign", _c, r.x, H - 1.0, front, 1.8, 0.6, 1, 0, 0, 0, { aDay: 1.0, aNight: 2.2 });
672
+ _c.copy(facade).offsetHSL(0, 0, -0.1);
673
+ b._part(r, "box", _c, r.x, 0, r.z + r.D / 2 + 0.6, 3.4, 2.8, 1.4); // entrance canopy block
674
+ doorQuad(b, r, r.x, 1.2, r.z + r.D / 2 + 1.34, 0, 2.2, 2.4);
675
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 0, r.floors, r.floors, { density: 0.9 });
676
+ },
677
+
678
+ fire_station(b, r, rng, facade, roof) {
679
+ const H = r.height;
680
+ bodyAndFlatRoof(b, r, facade, roof);
681
+ const front = r.z + r.D / 2 + 0.04;
682
+ _c.set("#8C857A");
683
+ for (const sx of [-0.25, 0.25]) {
684
+ b._part(r, "sign", _c, r.x + sx * r.W, 1.4, front, r.W * 0.36, 2.8, 1, 0, 0, 0, { aDay: 1.0, aNight: 1.3 });
685
+ }
686
+ // hose tower with an amber beacon
687
+ _c.copy(facade).offsetHSL(0, 0, -0.05);
688
+ const tx = r.x - r.W * 0.32, tz = r.z - r.D * 0.25;
689
+ b._part(r, "box", _c, tx, 0, tz, 1.5, H + 2.4, 1.5);
690
+ b._part(r, "box", roof, tx, H + 2.4, tz, 1.7, 0.2, 1.7);
691
+ _c.set(EMISSIVE.signalAmber);
692
+ b._part(r, "sign", _c, tx, H + 1.7, tz + 0.78, 0.5, 0.4, 1, 0, 0, 0, { aDay: 0.7, aNight: 2.0 });
693
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 1, Math.max(r.floors - 1, 0), r.floors, { density: 0.8 });
694
+ },
695
+
696
+ warehouse(b, r, rng, facade, roof) {
697
+ const H = Math.max(r.height, 3.6);
698
+ r.height = H + 1.4;
699
+ b._part(r, "box", facade, r.x, 0, r.z, r.W, H, r.D);
700
+ b._part(r, "gable", roof, r.x, H, r.z, r.W + 0.4, 1.4, r.D + 0.4);
701
+ _c.copy(facade).offsetHSL(0, 0, -0.12);
702
+ b._part(r, "box", _c, r.x, 0, r.z + r.D / 2 + 0.55, r.W * 0.7, 0.7, 1.1); // loading dock
703
+ _c.set("#5C5247");
704
+ for (const sx of [-0.22, 0.22]) {
705
+ b._part(r, "sign", _c, r.x + sx * r.W, 1.5, r.z + r.D / 2 + 0.03, r.W * 0.3, 2.6, 1, 0, 0, 0, { aDay: 1.0, aNight: 1.1 });
706
+ }
707
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 0, 1, 1, { density: 0.4, wH: 0.7 });
708
+ },
709
+
710
+ factory(b, r, rng, facade, roof) {
711
+ const H = r.height;
712
+ b._part(r, "box", facade, r.x, 0, r.z, r.W, H, r.D);
713
+ // sawtooth roofline: a row of narrow gable prisms along x
714
+ const n = 4;
715
+ for (let i = 0; i < n; i++) {
716
+ b._part(r, "gable", roof, r.x + ((i + 0.5) / n - 0.5) * r.W, H, r.z, r.D + 0.3, 1.2, r.W / n, Math.PI / 2);
717
+ }
718
+ // smokestack with a dark band
719
+ const sx = r.x - r.W * 0.38, sz = r.z - r.D * 0.3;
720
+ _c.set("#9A6B5A").offsetHSL(0, 0, (rng() - 0.5) * 0.05);
721
+ b._part(r, "cyl", _c, sx, 0, sz, 1.1, H + 6.5, 1.1);
722
+ _c.set("#3F454F");
723
+ b._part(r, "cyl", _c, sx, H + 5.6, sz, 1.16, 0.7, 1.16);
724
+ doorQuad(b, r, r.x + r.W * 0.25, 1.3, r.z + r.D / 2 + 0.03, 0, 2.0, 2.6);
725
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 0, 1, 1, { density: 0.5, wH: 0.8 });
726
+ },
727
+
728
+ park(b, r, rng, facade, roof) {
729
+ // no walls: trees (2-5 per cell), benches, central fountain
730
+ r.height = 3;
731
+ const w = Math.round((r.W + MARGIN) / CELL), d = Math.round((r.D + MARGIN) / CELL);
732
+ const x0 = r.x - (w * CELL) / 2, z0 = r.z - (d * CELL) / 2;
733
+ const density = b.quality.instances ?? 1;
734
+ for (let cz = 0; cz < d; cz++) {
735
+ for (let cx = 0; cx < w; cx++) {
736
+ const n = Math.max(1, Math.round((2 + Math.floor(rng() * 4)) * density));
737
+ for (let i = 0; i < n; i++) {
738
+ const tx = x0 + (cx + 0.18 + rng() * 0.64) * CELL;
739
+ const tz = z0 + (cz + 0.18 + rng() * 0.64) * CELL;
740
+ if (Math.abs(tx - r.x) < 2.6 && Math.abs(tz - r.z) < 2.6) continue; // keep the fountain clear
741
+ b._tree(r, rng, tx, tz, 0.8 + rng() * 0.5);
742
+ }
743
+ }
744
+ }
745
+ b._fountain(r, rng, r.x, r.z, 0.8);
746
+ for (let i = 0; i < 3; i++) {
747
+ const ang = rng() * Math.PI * 2;
748
+ b._bench(r, rng, r.x + Math.cos(ang) * 3.6, r.z + Math.sin(ang) * 3.6, ang + Math.PI / 2);
749
+ }
750
+ },
751
+
752
+ plaza(b, r, rng, facade, roof) {
753
+ r.height = 2.5;
754
+ b._fountain(r, rng, r.x, r.z, 0.55);
755
+ b._bench(r, rng, r.x - 1.3, r.z + 1.1, 0.6);
756
+ b._bench(r, rng, r.x + 1.3, r.z - 1.0, -2.2);
757
+ b._tree(r, rng, r.x + 1.2, r.z + 1.2, 0.65);
758
+ _c.set(PALETTE.sidewalk).offsetHSL(0, 0, -0.06);
759
+ for (const [px, pz] of [[-1.3, -1.3], [1.3, 1.3], [-1.3, 1.3], [1.3, -1.3]]) {
760
+ b._part(r, "box", _c, r.x + px, 0, r.z + pz, 0.5, 0.5, 0.5);
761
+ }
762
+ },
763
+
764
+ stadium(b, r, rng, facade, roof) {
765
+ const wallH = 4.5;
766
+ r.height = wallH + 5.5;
767
+ _c.copy(facade).offsetHSL(0, 0, -0.04);
768
+ b._part(r, "cyl", _c, r.x, 0, r.z, r.W * 0.98, wallH, r.D * 0.8); // elliptical bowl
769
+ _c.copy(roof).offsetHSL(0, 0, -0.06);
770
+ b._part(r, "cyl", _c, r.x, wallH, r.z, r.W * 0.86, 0.25, r.D * 0.68); // rim
771
+ _c.set(PALETTE.park).offsetHSL(0, 0.08, 0.03);
772
+ b._part(r, "cyl", _c, r.x, wallH - 0.5, r.z, r.W * 0.78, 0.9, r.D * 0.62); // the pitch
773
+ _c.set("#3F454F");
774
+ for (const [mx, mz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
775
+ const px = r.x + mx * r.W * 0.42, pz = r.z + mz * r.D * 0.36;
776
+ b._part(r, "box", _c, px, 0, pz, 0.28, wallH + 5.5, 0.28);
777
+ _c2.set(EMISSIVE.headlight);
778
+ b._part(r, "sign", _c2, px - mx * 0.5, wallH + 4.6, pz, 1.1, 0.6, 1, mx > 0 ? -Math.PI / 2 : Math.PI / 2,
779
+ 0, 0, { aDay: 0.5, aNight: 2.0 });
780
+ _c.set("#3F454F");
781
+ }
782
+ doorQuad(b, r, r.x, 1.4, r.z + r.D * 0.8 / 2 + 0.03, 0, 2.6, 2.8);
783
+ },
784
+
785
+ church(b, r, rng, facade, roof) {
786
+ const H = Math.max(r.height, 4.2);
787
+ b._part(r, "box", facade, r.x, 0, r.z, r.W, H, r.D);
788
+ b._part(r, "gable", roof, r.x, H, r.z, r.W + 0.4, 2.0, r.D + 0.4);
789
+ // steeple on the entry end: base + spire + cross
790
+ const sx = r.x - r.W * 0.32;
791
+ b._part(r, "box", facade, sx, 0, r.z, 1.6, H + 3.2, 1.6);
792
+ b._part(r, "gable", roof, sx, H + 3.2, r.z, 1.8, 2.4, 1.8);
793
+ _c.set("#6E5546");
794
+ b._part(r, "box", _c, sx, H + 5.5, r.z, 0.12, 1.1, 0.12);
795
+ b._part(r, "box", _c, sx, H + 6.1, r.z, 0.6, 0.12, 0.12);
796
+ r.height = H + 6.6;
797
+ doorQuad(b, r, sx, 1.3, r.z + r.D / 2 + 0.03, 0, 1.2, 2.6);
798
+ _c.set(EMISSIVE.window);
799
+ b._part(r, "sign", _c, r.x + r.W * 0.2, H * 0.6, r.z + r.D / 2 + 0.03, 0.7, 1.6, 1, 0, 0, 0, { aDay: 0.5, aNight: 1.5 });
800
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 0, 1, 1, { density: 0.5, wH: 1.4, wW: 0.5 });
801
+ },
802
+
803
+ town_hall(b, r, rng, facade, roof) {
804
+ const H = r.height;
805
+ _c.copy(facade).offsetHSL(0, 0, -0.08);
806
+ b._part(r, "box", _c, r.x, 0, r.z + r.D / 2 + 0.5, r.W * 0.6, 0.35, 1.4); // steps
807
+ b._part(r, "box", _c, r.x, 0, r.z + r.D / 2 + 0.2, r.W * 0.7, 0.6, 0.9);
808
+ b._part(r, "box", facade, r.x, 0, r.z, r.W, H, r.D);
809
+ b._part(r, "box", roof, r.x, H, r.z, r.W + 0.3, 0.3, r.D + 0.3);
810
+ const front = r.z + r.D / 2;
811
+ for (let i = 0; i < 4; i++) {
812
+ const cx = r.x + ((i / 3) - 0.5) * r.W * 0.55;
813
+ b._part(r, "cyl", facade, cx, 0.6, front + 0.5, 0.4, H - 0.6, 0.4);
814
+ }
815
+ _c.copy(facade).offsetHSL(0, 0, -0.05);
816
+ b._part(r, "gable", _c, r.x, H * 0.88, front + 0.35, r.W * 0.68, 1.0, 1.1); // pediment
817
+ _c.set("#7FB69E").offsetHSL(0, 0, -0.02);
818
+ b._part(r, "dome", _c, r.x, H + 0.3, r.z, 3.4, 4.4, 3.4);
819
+ _c.set("#C9C4B8");
820
+ b._part(r, "box", _c, r.x + r.W * 0.36, H, r.z - r.D * 0.3, 0.1, 3.4, 0.1); // flagpole
821
+ _c.set(PALETTE.accent);
822
+ b._part(r, "sign", _c, r.x + r.W * 0.36 + 0.5, H + 2.9, r.z - r.D * 0.3, 0.9, 0.55, 1, Math.PI / 2, 0, 0, { aDay: 1.0, aNight: 0.9 });
823
+ r.height = H + 2.5;
824
+ doorQuad(b, r, r.x, 1.4, front + 0.03, 0, 1.5, 2.8);
825
+ b._windowsBox(r, rng, r.x, r.z, r.W, r.D, 0, r.floors, r.floors, { density: 0.9, wH: 1.3 });
826
+ },
827
+ };
web/city/camera.js ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — the orbit rig over the diorama. Eased spherical targets (the
2
+ // godseed camera idiom): left-drag pans by grabbing the ground plane,
3
+ // right-drag / two-finger orbits, wheel zooms toward the cursor. gaze() eases
4
+ // over to frame a world point on world_delta; userHold pauses auto-motion
5
+ // whenever a hand is on the camera. landing() plays the contract's T0->T10 s
6
+ // choreography (low dolly behind Main St traffic -> crane to the 38 deg god
7
+ // view -> 0.5 deg/s drift); any input cancels it. update(dt) is sim-time only.
8
+ import * as THREE from "three";
9
+ import { GRID, CELL } from "./constants.js";
10
+ import { DEG, clamp, lerp, easeInOut } from "./util.js";
11
+
12
+ const PLOT_HALF = (GRID * CELL) / 2; // 128 m
13
+ const BOUND = PLOT_HALF * 1.2; // pan/gaze bounds: city plot +20%
14
+ const PITCH_MIN = 15 * DEG, PITCH_MAX = 80 * DEG;
15
+ const H_MIN = 8, H_MAX = 160; // camera height clamp, m
16
+ const DIST_MIN = 12, DIST_MAX = 340;
17
+ const EASE = 3.0; // exp smoothing rate (1 - e^(-dt*EASE))
18
+ const HOLD_S = 6; // user-control override after any input
19
+ const DRIFT = 0.5 * DEG; // post-landing drift, rad/s
20
+
21
+ // landing: T0-3.5 dolly forward low over Main St, T3.5-7 crane to god view,
22
+ // T7+ drift. God pose: 38 deg pitch, downtown left-third, ~86 m height.
23
+ const DOLLY_END = 3.5, CRANE_END = 7, DOLLY_M = 22;
24
+ const GOD = { look: new THREE.Vector3(10, 0, -4), yaw: 0.55, pitch: 38 * DEG, dist: 140 };
25
+
26
+ const _v = new THREE.Vector3();
27
+ const _a = new THREE.Vector3();
28
+ const _b = new THREE.Vector3();
29
+ const _ndc = new THREE.Vector2();
30
+ const _ray = new THREE.Raycaster();
31
+ const _plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
32
+
33
+ export class CameraRig {
34
+ constructor(camera, dom) {
35
+ this.camera = camera;
36
+ this.dom = dom;
37
+ this.look = new THREE.Vector3();
38
+ this.lookT = new THREE.Vector3();
39
+ this.yaw = 0; this.yawT = 0;
40
+ this.pitch = 0.6; this.pitchT = 0.6;
41
+ this.dist = 120; this.distT = 120;
42
+ this.userHold = 0;
43
+ this.gazeHold = 0;
44
+ this._drift = false; // set when the landing settles; cleared on input
45
+ this._landing = null;
46
+ this._adopt();
47
+ this._bind(dom);
48
+ }
49
+
50
+ /** Derive spherical state from wherever the camera currently is. */
51
+ _adopt() {
52
+ const dir = this.camera.getWorldDirection(_v);
53
+ const t = dir.y < -0.05 ? this.camera.position.y / -dir.y : 60;
54
+ this.look.copy(this.camera.position).addScaledVector(dir, clamp(t, 10, 380));
55
+ this.look.y = 0;
56
+ _a.copy(this.camera.position).sub(this.look);
57
+ this.dist = Math.max(DIST_MIN, _a.length());
58
+ this.pitch = Math.asin(clamp(_a.y / this.dist, -1, 1));
59
+ this.yaw = Math.atan2(_a.x, _a.z);
60
+ this.lookT.copy(this.look);
61
+ this.yawT = this.yaw;
62
+ this.pitchT = this.pitch;
63
+ this.distT = this.dist;
64
+ }
65
+
66
+ _apply() {
67
+ const cp = Math.cos(this.pitch), sp = Math.sin(this.pitch);
68
+ this.camera.position.set(
69
+ this.look.x + this.dist * cp * Math.sin(this.yaw),
70
+ this.look.y + this.dist * sp,
71
+ this.look.z + this.dist * cp * Math.cos(this.yaw)
72
+ );
73
+ this.camera.lookAt(this.look);
74
+ }
75
+
76
+ _clampLook(v) {
77
+ v.x = clamp(v.x, -BOUND, BOUND);
78
+ v.z = clamp(v.z, -BOUND, BOUND);
79
+ v.y = 0;
80
+ }
81
+
82
+ /** Ray through (clientX, clientY) intersected with the ground plane. */
83
+ _groundPoint(cx, cy, out) {
84
+ const r = this.dom.getBoundingClientRect();
85
+ _ndc.set(((cx - r.left) / r.width) * 2 - 1, -((cy - r.top) / r.height) * 2 + 1);
86
+ _ray.setFromCamera(_ndc, this.camera);
87
+ return _ray.ray.intersectPlane(_plane, out);
88
+ }
89
+
90
+ /** Any input: pause auto-motion, cancel landing + drift. */
91
+ _userInput() {
92
+ this.userHold = HOLD_S;
93
+ this.gazeHold = 0;
94
+ this._drift = false;
95
+ if (this._landing) {
96
+ this._landing = null;
97
+ this._adopt();
98
+ }
99
+ }
100
+
101
+ _bind(dom) {
102
+ if (!dom) return;
103
+ const pointers = new Map(); // pointerId -> {x, y}
104
+ let mode = null; // "pan" | "orbit" | "touch"
105
+ const grab = new THREE.Vector3();
106
+ let hasGrab = false;
107
+ let pinch = 0, midX = 0, midY = 0;
108
+
109
+ const touchState = () => {
110
+ const [p, q] = [...pointers.values()];
111
+ pinch = Math.hypot(p.x - q.x, p.y - q.y);
112
+ midX = (p.x + q.x) / 2;
113
+ midY = (p.y + q.y) / 2;
114
+ };
115
+
116
+ dom.addEventListener("pointerdown", (e) => {
117
+ this._userInput();
118
+ dom.setPointerCapture?.(e.pointerId);
119
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
120
+ if (pointers.size === 2) {
121
+ mode = "touch";
122
+ hasGrab = false;
123
+ touchState();
124
+ } else if (e.button === 2 || e.button === 1) {
125
+ mode = "orbit";
126
+ } else {
127
+ mode = "pan";
128
+ hasGrab = !!this._groundPoint(e.clientX, e.clientY, grab);
129
+ }
130
+ });
131
+
132
+ dom.addEventListener("pointermove", (e) => {
133
+ const p = pointers.get(e.pointerId);
134
+ if (!p) return;
135
+ const dx = e.clientX - p.x, dy = e.clientY - p.y;
136
+ p.x = e.clientX; p.y = e.clientY;
137
+ this.userHold = HOLD_S;
138
+ if (mode === "touch" && pointers.size === 2) {
139
+ const pm = pinch, mx = midX, my = midY;
140
+ touchState();
141
+ this.yawT -= (midX - mx) * 0.005;
142
+ this.pitchT = clamp(this.pitchT + (midY - my) * 0.004, PITCH_MIN, PITCH_MAX);
143
+ if (pinch > 1) this.distT = clamp(this.distT * (pm / pinch), DIST_MIN, DIST_MAX);
144
+ } else if (mode === "orbit") {
145
+ this.yawT -= dx * 0.005;
146
+ this.pitchT = clamp(this.pitchT + dy * 0.004, PITCH_MIN, PITCH_MAX);
147
+ } else if (mode === "pan" && hasGrab) {
148
+ // keep the grabbed ground point under the cursor (1:1 pan)
149
+ if (this._groundPoint(e.clientX, e.clientY, _v)) {
150
+ _a.copy(grab).sub(_v);
151
+ _a.y = 0;
152
+ this.look.add(_a);
153
+ this._clampLook(this.look);
154
+ this.lookT.copy(this.look);
155
+ this._apply();
156
+ }
157
+ }
158
+ });
159
+
160
+ const end = (e) => {
161
+ pointers.delete(e.pointerId);
162
+ if (pointers.size === 0) mode = null;
163
+ else if (pointers.size === 1) {
164
+ mode = "pan";
165
+ const [p] = [...pointers.values()];
166
+ hasGrab = !!this._groundPoint(p.x, p.y, grab);
167
+ }
168
+ };
169
+ dom.addEventListener("pointerup", end);
170
+ dom.addEventListener("pointercancel", end);
171
+ dom.addEventListener("contextmenu", (e) => e.preventDefault());
172
+
173
+ dom.addEventListener("wheel", (e) => {
174
+ e.preventDefault();
175
+ this._userInput();
176
+ const f = clamp(1 + e.deltaY * 0.0011, 0.55, 1.6);
177
+ const at = this._groundPoint(e.clientX, e.clientY, _v);
178
+ this.distT = clamp(this.distT * f, DIST_MIN, DIST_MAX);
179
+ if (at) {
180
+ this.lookT.lerp(_v, 1 - f); // zoom toward / away from the cursor
181
+ this._clampLook(this.lookT);
182
+ }
183
+ }, { passive: false });
184
+ }
185
+
186
+ /** Ease over to frame a world point (called on world_delta). Refused while
187
+ * a hand is on the camera or the landing choreography is playing. */
188
+ gaze(point, { hold = 5 } = {}) {
189
+ if (this.userHold > 0 || this._landing) return;
190
+ if (!point) { this.gazeHold = 0; return; }
191
+ this.lookT.set(point.x, 0, point.z);
192
+ this._clampLook(this.lookT);
193
+ this.distT = clamp(this.distT, 36, 90); // close enough that a build reads
194
+ this.gazeHold = hold;
195
+ }
196
+
197
+ /** Start the T0->T10 s landing choreography from the current camera pose. */
198
+ landing() {
199
+ this.userHold = 0;
200
+ this.gazeHold = 0;
201
+ this._drift = false;
202
+ this._landing = { t: 0, p0: this.camera.position.clone() };
203
+ }
204
+
205
+ _updateLanding(dt) {
206
+ const L = this._landing;
207
+ L.t += dt;
208
+ if (L.t < DOLLY_END) {
209
+ // constant-speed dolly forward along the avenue; the look offset matches
210
+ // scene.js's boot lookAt exactly so T0 (the screenshot frame) has no pop
211
+ const u = L.t / DOLLY_END;
212
+ this.camera.position.set(L.p0.x, L.p0.y, L.p0.z - DOLLY_M * u);
213
+ this.look.set(L.p0.x, 3, this.camera.position.z - 70);
214
+ this.camera.lookAt(this.look);
215
+ } else if (L.t < CRANE_END) {
216
+ // single eased crane up + pull back to the god view
217
+ const k = easeInOut((L.t - DOLLY_END) / (CRANE_END - DOLLY_END));
218
+ _a.set(L.p0.x, L.p0.y, L.p0.z - DOLLY_M);
219
+ const cp = Math.cos(GOD.pitch), sp = Math.sin(GOD.pitch);
220
+ _b.set(
221
+ GOD.look.x + GOD.dist * cp * Math.sin(GOD.yaw),
222
+ GOD.dist * sp,
223
+ GOD.look.z + GOD.dist * cp * Math.cos(GOD.yaw)
224
+ );
225
+ this.camera.position.lerpVectors(_a, _b, k);
226
+ this.camera.position.y += Math.sin(k * Math.PI) * 6; // crane bulge
227
+ _v.set(L.p0.x, 3, _a.z - 70);
228
+ this.look.lerpVectors(_v, GOD.look, k);
229
+ this.camera.lookAt(this.look);
230
+ } else {
231
+ // settle into orbit state; the T7+ drift runs in the normal update path
232
+ this._landing = null;
233
+ this.look.copy(GOD.look);
234
+ this.lookT.copy(GOD.look);
235
+ this.yaw = this.yawT = GOD.yaw;
236
+ this.pitch = this.pitchT = GOD.pitch;
237
+ this.dist = this.distT = GOD.dist;
238
+ this._drift = true;
239
+ this._apply();
240
+ }
241
+ }
242
+
243
+ /** Screen-space y in [0,1] of the gaze target — feeds post.setFocusY. */
244
+ focusScreenY() {
245
+ _v.copy(this.look).project(this.camera);
246
+ return clamp(_v.y * 0.5 + 0.5, 0, 1);
247
+ }
248
+
249
+ /** Camera height (m) — main.js lerps tilt-shift amount over 60 -> 25 m. */
250
+ height() {
251
+ return this.camera.position.y;
252
+ }
253
+
254
+ /** Convenience: the contract's 60 -> 25 m tilt-shift amount lerp. */
255
+ tiltShiftAmount() {
256
+ return clamp((this.height() - 25) / 35, 0, 1);
257
+ }
258
+
259
+ /** Current gaze target (FPV spawns near this: fpv.enter(rig.lookPoint())). */
260
+ lookPoint() {
261
+ return this.look.clone();
262
+ }
263
+
264
+ update(dt) {
265
+ if (this._landing) {
266
+ this._updateLanding(dt);
267
+ return;
268
+ }
269
+ if (this.userHold > 0) this.userHold -= dt;
270
+ if (this.gazeHold > 0) this.gazeHold -= dt;
271
+ else if (this._drift && this.userHold <= 0) this.yawT += DRIFT * dt;
272
+
273
+ this.pitchT = clamp(this.pitchT, PITCH_MIN, PITCH_MAX);
274
+ const sp = Math.sin(this.pitchT);
275
+ this.distT = clamp(this.distT,
276
+ Math.max(DIST_MIN, H_MIN / sp), Math.min(DIST_MAX, H_MAX / sp));
277
+ this._clampLook(this.lookT);
278
+
279
+ const k = 1 - Math.exp(-dt * EASE);
280
+ this.yaw = lerp(this.yaw, this.yawT, k);
281
+ this.pitch = lerp(this.pitch, this.pitchT, k);
282
+ this.dist = lerp(this.dist, this.distT, k * 0.8);
283
+ this.look.lerp(this.lookT, k * 0.9);
284
+ this._apply();
285
+ }
286
+ }
web/city/citizens.js ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — citizens. Instanced capsule+head pedestrians on sidewalk loops:
2
+ // each walks a BFS route along road cells between a seeded home and a seeded
3
+ // destination (attract-weighted for leisure walkers, jobs-weighted for
4
+ // commuters), offset onto the sidewalk edge. Position is a PURE function of
5
+ // absolute simTime (the godseed life.js idiom — there-and-back wave with dwell
6
+ // plateaus), so replay/seek/shared sync are exact. Daypart spawn-weighting
7
+ // (cafes/parks midday + dusk) picks WHICH pool entries are visible, never
8
+ // where anyone stands. Hidden beyond 180 m of the camera; small quality
9
+ // halves the visible count.
10
+ import * as THREE from "three";
11
+ import { GRID, CELL, CITIZENS_PER_POP, CITIZENS_MIN, CITIZENS_MAX } from "./constants.js";
12
+ import { clamp, cellKey, dayHours } from "./util.js";
13
+ import { mulberry32 } from "./rng.js";
14
+ import { FACADES } from "./palette.js";
15
+ import { ROAD_Y } from "./roads.js";
16
+ import { buildingsFromEvents, buildProgress, computePopulation, pathPoint } from "./traffic.js";
17
+
18
+ const HALF = GRID / 2;
19
+ const POOL = CITIZENS_MAX;
20
+ const HIDE_DIST_SQ = 180 * 180;
21
+ const LEGS = ["#3E4A5C", "#6B5D4F", "#7A8294", "#4F5D54", "#54485E"].map((h) => new THREE.Color(h));
22
+ const SKIN = ["#F0C8A0", "#C89878", "#8D5524", "#FFDBB4", "#A86B3C"].map((h) => new THREE.Color(h));
23
+ const SHIRTS = FACADES.map((h) => new THREE.Color(h));
24
+
25
+ const _m = new THREE.Matrix4();
26
+ const _q = new THREE.Quaternion();
27
+ const _e = new THREE.Euler();
28
+ const _p = new THREE.Vector3();
29
+ const _s = new THREE.Vector3();
30
+ const ZERO = new THREE.Matrix4().makeScale(0, 0, 0);
31
+ const _pos = { x: 0, y: 0, z: 0 };
32
+ const _tan = { x: 0, z: 0 };
33
+
34
+ export class Citizens {
35
+ constructor(scene, quality, city = null) {
36
+ this.scene = scene;
37
+ this.quality = quality;
38
+ this.city = city;
39
+ this.camera = null; // setCamera() enables the 180 m hide
40
+ this.group = new THREE.Group();
41
+ this.group.matrixAutoUpdate = false;
42
+ scene.add(this.group);
43
+
44
+ const mk = (geo) => {
45
+ const mat = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0, envMapIntensity: 0.25 });
46
+ const m = new THREE.InstancedMesh(geo, mat, POOL);
47
+ m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
48
+ m.count = POOL;
49
+ m.frustumCulled = false;
50
+ this.group.add(m);
51
+ return m;
52
+ };
53
+ // origin at the feet; legs box + torso capsule (shirt) + head sphere
54
+ this.legs = mk(new THREE.BoxGeometry(0.3, 0.62, 0.24).translate(0, 0.31, 0));
55
+ this.torso = mk(new THREE.CapsuleGeometry(0.17, 0.36, 3, 8).translate(0, 0.85, 0));
56
+ this.head = mk(new THREE.SphereGeometry(0.13, 8, 7).translate(0, 1.27, 0));
57
+ for (let i = 0; i < POOL; i++) {
58
+ this.legs.setMatrixAt(i, ZERO);
59
+ this.torso.setMatrixAt(i, ZERO);
60
+ this.head.setMatrixAt(i, ZERO);
61
+ }
62
+
63
+ this._defs = new Array(POOL).fill(null);
64
+ this._leisureOrder = [];
65
+ this._workOrder = [];
66
+ this._eventsSeen = -1;
67
+ this._versionSeen = -1;
68
+ this._popAcc = 1e9;
69
+ this._pop = 0;
70
+ this._blobs = null;
71
+ }
72
+
73
+ setCamera(camera) {
74
+ this.camera = camera;
75
+ }
76
+
77
+ // ---- pool rebuild (events changed: homes/dests/roads may differ) ----------
78
+ _rebuild(simTime) {
79
+ const city = this.city;
80
+ this._eventsSeen = city.events.length;
81
+ this._versionSeen = city.roadVersion;
82
+
83
+ // road graph + per-cell walk surface (bridge decks walkable for peds)
84
+ const surf = new Map();
85
+ for (const [key, rec] of city.roadCells) surf.set(key, rec.bridge ? ROAD_Y.deck : ROAD_Y.road);
86
+ for (const [key] of city.roadCells) {
87
+ if (surf.get(key) !== ROAD_Y.road) continue;
88
+ const [cx, cz] = key.split(",").map(Number);
89
+ for (const [dx, dz] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) {
90
+ if (surf.get(cellKey(cx + dx, cz + dz)) === ROAD_Y.deck) { surf.set(key, ROAD_Y.ramp); break; }
91
+ }
92
+ }
93
+
94
+ const buildings = buildingsFromEvents(city.events);
95
+ const homes = [], leisure = [], work = [];
96
+ for (const b of buildings) {
97
+ if (buildProgress(b, simTime) < 1) continue;
98
+ if (b.def.residents > 0) homes.push(b);
99
+ if (b.def.attract > 0) leisure.push(b);
100
+ if (b.def.jobs > 0) work.push(b);
101
+ }
102
+
103
+ const roulette = (rng, list, weightOf) => {
104
+ let total = 0;
105
+ for (const b of list) total += weightOf(b);
106
+ if (total <= 0) return null;
107
+ let r = rng() * total;
108
+ for (const b of list) {
109
+ r -= weightOf(b);
110
+ if (r <= 0) return b;
111
+ }
112
+ return list[list.length - 1];
113
+ };
114
+
115
+ for (let i = 0; i < POOL; i++) {
116
+ const rng = mulberry32((0x517ec1 + Math.imul(i, 0x9e3779b9)) >>> 0);
117
+ const isLeisure = rng() < 0.55;
118
+ const home = roulette(rng, homes, (b) => b.def.residents);
119
+ const destList = isLeisure && leisure.length ? leisure : work;
120
+ const dest = roulette(rng, destList, (b) => (isLeisure ? b.def.attract : b.def.jobs));
121
+ let def = null;
122
+ if (home && dest) {
123
+ const a = this._gate(home, surf);
124
+ const b = this._gate(dest, surf);
125
+ if (a && b && !(a[0] === b[0] && a[1] === b[1])) {
126
+ const cellsPath = this._bfs(a, b, surf);
127
+ if (cellsPath && cellsPath.length > 1) {
128
+ def = this._makeDef(i, rng, isLeisure, cellsPath, surf);
129
+ }
130
+ }
131
+ }
132
+ this._defs[i] = def;
133
+ this.legs.setColorAt(i, LEGS[Math.floor((def ? def.r1 : 0) * LEGS.length) % LEGS.length]);
134
+ this.torso.setColorAt(i, SHIRTS[Math.floor((def ? def.r2 : 0) * SHIRTS.length) % SHIRTS.length]);
135
+ this.head.setColorAt(i, SKIN[Math.floor((def ? def.r3 : 0) * SKIN.length) % SKIN.length]);
136
+ }
137
+ this.legs.instanceColor.needsUpdate = true;
138
+ this.torso.instanceColor.needsUpdate = true;
139
+ this.head.instanceColor.needsUpdate = true;
140
+
141
+ // visibility orderings: leisure walkers first vs commuters first
142
+ const lei = [], wrk = [];
143
+ for (let i = 0; i < POOL; i++) {
144
+ if (!this._defs[i]) continue;
145
+ (this._defs[i].leisure ? lei : wrk).push(i);
146
+ }
147
+ this._leisureOrder = lei.concat(wrk);
148
+ this._workOrder = wrk.concat(lei);
149
+ }
150
+
151
+ _makeDef(i, rng, leisure, cellsPath, surf) {
152
+ // sidewalk polyline: cell centers pushed to a fixed side of the road
153
+ const side = rng() < 0.5 ? 1 : -1;
154
+ const off = (1.45 + rng() * 0.45) * side;
155
+ const n = cellsPath.length;
156
+ const pts = new Float32Array(n * 3);
157
+ for (let k = 0; k < n; k++) {
158
+ const [cx, cz] = cellsPath[k];
159
+ const [pcx, pcz] = cellsPath[Math.max(k - 1, 0)];
160
+ const [ncx, ncz] = cellsPath[Math.min(k + 1, n - 1)];
161
+ let tx = ncx - pcx, tz = ncz - pcz;
162
+ const tl = Math.hypot(tx, tz) || 1;
163
+ tx /= tl; tz /= tl;
164
+ pts[k * 3] = cx * CELL + CELL / 2 + (-tz) * off;
165
+ pts[k * 3 + 1] = surf.get(cellKey(cx, cz)) ?? ROAD_Y.road;
166
+ pts[k * 3 + 2] = cz * CELL + CELL / 2 + tx * off;
167
+ }
168
+ const speed = 1.25 + rng() * 0.5; // m/s
169
+ const tWalk = ((n - 1) * CELL) / speed;
170
+ const tDwell = 4 + rng() * 8;
171
+ const period = 2 * (tWalk + tDwell);
172
+ return {
173
+ leisure, pts, n, tWalk, tDwell, period,
174
+ phase: rng() * period,
175
+ bobPhase: rng() * 6.28,
176
+ scale: 0.92 + rng() * 0.16,
177
+ r1: rng(), r2: rng(), r3: rng(),
178
+ };
179
+ }
180
+
181
+ _gate(b, surf) {
182
+ for (let r = 1; r <= 8; r++) {
183
+ for (let z = b.cz - r; z < b.cz + b.d + r; z++) {
184
+ for (let x = b.cx - r; x < b.cx + b.w + r; x++) {
185
+ const ring = Math.max(
186
+ Math.max(b.cx - x, x - (b.cx + b.w - 1)),
187
+ Math.max(b.cz - z, z - (b.cz + b.d - 1)));
188
+ if (ring !== r || x < -HALF || x >= HALF || z < -HALF || z >= HALF) continue;
189
+ if (surf.has(cellKey(x, z))) return [x, z];
190
+ }
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+
196
+ _bfs(a, b, surf) {
197
+ const start = cellKey(a[0], a[1]), goal = cellKey(b[0], b[1]);
198
+ const came = new Map([[start, null]]);
199
+ let frontier = [a];
200
+ while (frontier.length) {
201
+ const next = [];
202
+ for (const [cx, cz] of frontier) {
203
+ for (const [dx, dz] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) {
204
+ const nx = cx + dx, nz = cz + dz;
205
+ const key = cellKey(nx, nz);
206
+ if (!surf.has(key) || came.has(key)) continue;
207
+ came.set(key, cellKey(cx, cz));
208
+ if (key === goal) {
209
+ const path = [];
210
+ for (let k = key; k; k = came.get(k)) path.push(k.split(",").map(Number));
211
+ return path.reverse();
212
+ }
213
+ next.push([nx, nz]);
214
+ }
215
+ }
216
+ frontier = next;
217
+ }
218
+ return null;
219
+ }
220
+
221
+ // ---- per-frame -------------------------------------------------------------
222
+ update(dt, simTime) {
223
+ const city = this.city;
224
+ if (!city) return;
225
+ if (city.events.length !== this._eventsSeen || city.roadVersion !== this._versionSeen) {
226
+ this._rebuild(simTime);
227
+ }
228
+ this._popAcc += dt;
229
+ if (this._popAcc >= 1) {
230
+ this._popAcc = 0;
231
+ this._pop = computePopulation(city.events, simTime);
232
+ }
233
+ if (!this._blobs && city.ground && city.ground.addBlob) {
234
+ this._blobs = [];
235
+ for (let i = 0; i < POOL; i++) this._blobs.push(city.ground.addBlob(0, 0, 0, 0));
236
+ }
237
+
238
+ let count = clamp(Math.ceil(this._pop / CITIZENS_PER_POP), CITIZENS_MIN, CITIZENS_MAX);
239
+ if (this.quality && this.quality.small) count = Math.ceil(count / 2);
240
+
241
+ // daypart spawn-weighting: cafes/parks crowd midday and dusk
242
+ const h = dayHours(simTime);
243
+ const leisureTime = (h >= 10.5 && h <= 14.5) || (h >= 16.5 && h <= 20.5);
244
+ const order = leisureTime ? this._leisureOrder : this._workOrder;
245
+
246
+ const camX = this.camera ? this.camera.position.x : 0;
247
+ const camZ = this.camera ? this.camera.position.z : 0;
248
+ const shown = new Uint8Array(POOL);
249
+ let placed = 0;
250
+ for (const i of order) {
251
+ if (placed >= count) break;
252
+ const def = this._defs[i];
253
+ if (!def || shown[i]) continue;
254
+ shown[i] = 1;
255
+ placed++;
256
+ // there-and-back wave with dwell plateaus — pure fn of absolute simTime
257
+ const w = (((simTime + def.phase) % def.period) + def.period) % def.period;
258
+ let u, dir, walking;
259
+ if (w < def.tWalk) { u = (w / def.tWalk) * (def.n - 1); dir = 1; walking = true; }
260
+ else if (w < def.tWalk + def.tDwell) { u = def.n - 1; dir = 1; walking = false; }
261
+ else if (w < 2 * def.tWalk + def.tDwell) {
262
+ u = (1 - (w - def.tWalk - def.tDwell) / def.tWalk) * (def.n - 1);
263
+ dir = -1; walking = true;
264
+ } else { u = 0; dir = -1; walking = false; }
265
+ pathPoint(def.pts, def.n, u, _pos, _tan);
266
+ if (this.camera) {
267
+ const ddx = _pos.x - camX, ddz = _pos.z - camZ;
268
+ if (ddx * ddx + ddz * ddz > HIDE_DIST_SQ) {
269
+ this._hide(i);
270
+ continue;
271
+ }
272
+ }
273
+ const yaw = Math.atan2(_tan.x * dir, _tan.z * dir);
274
+ const bob = (walking ? 0.035 : 0.008) * Math.sin(2 * Math.PI * 1.5 * simTime + def.bobPhase);
275
+ _e.set(0, yaw, 0);
276
+ _q.setFromEuler(_e);
277
+ _p.set(_pos.x, _pos.y + bob, _pos.z);
278
+ _s.setScalar(def.scale);
279
+ _m.compose(_p, _q, _s);
280
+ this.legs.setMatrixAt(i, _m);
281
+ this.torso.setMatrixAt(i, _m);
282
+ this.head.setMatrixAt(i, _m);
283
+ if (this._blobs) this._blobs[i].set(_pos.x, _pos.z, 0.7, 0.16);
284
+ }
285
+ for (let i = 0; i < POOL; i++) if (!shown[i]) this._hide(i);
286
+ this.legs.instanceMatrix.needsUpdate = true;
287
+ this.torso.instanceMatrix.needsUpdate = true;
288
+ this.head.instanceMatrix.needsUpdate = true;
289
+ }
290
+
291
+ _hide(i) {
292
+ this.legs.setMatrixAt(i, ZERO);
293
+ this.torso.setMatrixAt(i, ZERO);
294
+ this.head.setMatrixAt(i, ZERO);
295
+ if (this._blobs) this._blobs[i].set(0, 0, 0, 0);
296
+ }
297
+ }
web/city/constants.js ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // GENERATED by tools/gen_web.py from engine/constants.py — do not edit.
2
+ // Values are the ARCHITECTURE.md contract; regenerate, never hand-tune.
3
+
4
+ export const GRID = 64;
5
+ export const CELL = 4;
6
+ export const COORD_MIN = -32;
7
+ export const COORD_MAX = 31;
8
+ export const CITY_EPOCH_S = 1781222400;
9
+ export const DAY_S = 240;
10
+
11
+ export function riverCols(cz) {
12
+ return cz <= -5 ? [7, 8] : cz <= 3 ? [8, 9] : [9, 10];
13
+ }
14
+
15
+ export const BUILDINGS = {
16
+ "house": {
17
+ "w": 1,
18
+ "d": 1,
19
+ "floors": [
20
+ 1,
21
+ 2
22
+ ],
23
+ "residents": 4,
24
+ "jobs": 0,
25
+ "attract": 0,
26
+ "duration": 20,
27
+ "duration_s": 20
28
+ },
29
+ "townhouse": {
30
+ "w": 1,
31
+ "d": 1,
32
+ "floors": [
33
+ 2,
34
+ 3
35
+ ],
36
+ "residents": 6,
37
+ "jobs": 0,
38
+ "attract": 0,
39
+ "duration": 20,
40
+ "duration_s": 20
41
+ },
42
+ "apartments": {
43
+ "w": 2,
44
+ "d": 2,
45
+ "floors": [
46
+ 4,
47
+ 7
48
+ ],
49
+ "residents": 16,
50
+ "jobs": 0,
51
+ "attract": 0,
52
+ "duration": 45,
53
+ "duration_s": 45
54
+ },
55
+ "cafe": {
56
+ "w": 1,
57
+ "d": 1,
58
+ "floors": [
59
+ 1,
60
+ 1
61
+ ],
62
+ "residents": 0,
63
+ "jobs": 3,
64
+ "attract": 5,
65
+ "duration": 20,
66
+ "duration_s": 20
67
+ },
68
+ "shop": {
69
+ "w": 1,
70
+ "d": 1,
71
+ "floors": [
72
+ 1,
73
+ 2
74
+ ],
75
+ "residents": 0,
76
+ "jobs": 4,
77
+ "attract": 4,
78
+ "duration": 20,
79
+ "duration_s": 20
80
+ },
81
+ "market": {
82
+ "w": 1,
83
+ "d": 2,
84
+ "floors": [
85
+ 1,
86
+ 1
87
+ ],
88
+ "residents": 0,
89
+ "jobs": 8,
90
+ "attract": 6,
91
+ "duration": 30,
92
+ "duration_s": 30
93
+ },
94
+ "bank": {
95
+ "w": 2,
96
+ "d": 1,
97
+ "floors": [
98
+ 2,
99
+ 4
100
+ ],
101
+ "residents": 0,
102
+ "jobs": 12,
103
+ "attract": 2,
104
+ "duration": 30,
105
+ "duration_s": 30
106
+ },
107
+ "office": {
108
+ "w": 1,
109
+ "d": 1,
110
+ "floors": [
111
+ 3,
112
+ 8
113
+ ],
114
+ "residents": 0,
115
+ "jobs": 40,
116
+ "attract": 1,
117
+ "duration": 30,
118
+ "duration_s": 30
119
+ },
120
+ "tower": {
121
+ "w": 2,
122
+ "d": 2,
123
+ "floors": [
124
+ 8,
125
+ 16
126
+ ],
127
+ "residents": 0,
128
+ "jobs": 60,
129
+ "attract": 2,
130
+ "duration": 45,
131
+ "duration_s": 45
132
+ },
133
+ "school": {
134
+ "w": 2,
135
+ "d": 2,
136
+ "floors": [
137
+ 1,
138
+ 2
139
+ ],
140
+ "residents": 0,
141
+ "jobs": 10,
142
+ "attract": 3,
143
+ "duration": 30,
144
+ "duration_s": 30
145
+ },
146
+ "hospital": {
147
+ "w": 3,
148
+ "d": 3,
149
+ "floors": [
150
+ 3,
151
+ 6
152
+ ],
153
+ "residents": 0,
154
+ "jobs": 25,
155
+ "attract": 2,
156
+ "duration": 45,
157
+ "duration_s": 45
158
+ },
159
+ "fire_station": {
160
+ "w": 2,
161
+ "d": 1,
162
+ "floors": [
163
+ 2,
164
+ 2
165
+ ],
166
+ "residents": 0,
167
+ "jobs": 8,
168
+ "attract": 1,
169
+ "duration": 30,
170
+ "duration_s": 30
171
+ },
172
+ "warehouse": {
173
+ "w": 2,
174
+ "d": 2,
175
+ "floors": [
176
+ 1,
177
+ 1
178
+ ],
179
+ "residents": 0,
180
+ "jobs": 10,
181
+ "attract": 0,
182
+ "duration": 30,
183
+ "duration_s": 30
184
+ },
185
+ "factory": {
186
+ "w": 3,
187
+ "d": 2,
188
+ "floors": [
189
+ 1,
190
+ 2
191
+ ],
192
+ "residents": 0,
193
+ "jobs": 18,
194
+ "attract": 0,
195
+ "duration": 45,
196
+ "duration_s": 45
197
+ },
198
+ "park": {
199
+ "w": 2,
200
+ "d": 2,
201
+ "floors": [
202
+ 0,
203
+ 0
204
+ ],
205
+ "residents": 0,
206
+ "jobs": 0,
207
+ "attract": 8,
208
+ "duration": 20,
209
+ "duration_s": 20
210
+ },
211
+ "plaza": {
212
+ "w": 1,
213
+ "d": 1,
214
+ "floors": [
215
+ 0,
216
+ 0
217
+ ],
218
+ "residents": 0,
219
+ "jobs": 0,
220
+ "attract": 6,
221
+ "duration": 20,
222
+ "duration_s": 20
223
+ },
224
+ "stadium": {
225
+ "w": 4,
226
+ "d": 4,
227
+ "floors": [
228
+ 1,
229
+ 1
230
+ ],
231
+ "residents": 0,
232
+ "jobs": 15,
233
+ "attract": 9,
234
+ "duration": 45,
235
+ "duration_s": 45
236
+ },
237
+ "church": {
238
+ "w": 2,
239
+ "d": 1,
240
+ "floors": [
241
+ 1,
242
+ 1
243
+ ],
244
+ "residents": 0,
245
+ "jobs": 2,
246
+ "attract": 3,
247
+ "duration": 30,
248
+ "duration_s": 30
249
+ },
250
+ "town_hall": {
251
+ "w": 2,
252
+ "d": 2,
253
+ "floors": [
254
+ 2,
255
+ 2
256
+ ],
257
+ "residents": 0,
258
+ "jobs": 6,
259
+ "attract": 4,
260
+ "duration": 45,
261
+ "duration_s": 45
262
+ }
263
+ };
264
+
265
+ export const ROAD_CLASSES = {
266
+ "street": {
267
+ "capacity": 2,
268
+ "speed": 1.0
269
+ },
270
+ "avenue": {
271
+ "capacity": 6,
272
+ "speed": 1.6
273
+ }
274
+ };
275
+ export const ROAD_DURATION_S = 4;
276
+ export const DURATIONS = {
277
+ "house": 20,
278
+ "townhouse": 20,
279
+ "apartments": 45,
280
+ "cafe": 20,
281
+ "shop": 20,
282
+ "market": 30,
283
+ "bank": 30,
284
+ "office": 30,
285
+ "tower": 45,
286
+ "school": 30,
287
+ "hospital": 45,
288
+ "fire_station": 30,
289
+ "warehouse": 30,
290
+ "factory": 45,
291
+ "park": 20,
292
+ "plaza": 20,
293
+ "stadium": 45,
294
+ "church": 30,
295
+ "town_hall": 45
296
+ };
297
+ export const SYNONYMS = {
298
+ "skyscraper": "tower",
299
+ "highrise": "tower",
300
+ "high_rise": "tower",
301
+ "apartment": "apartments",
302
+ "apartment_building": "apartments",
303
+ "condo": "apartments",
304
+ "hotel": "apartments",
305
+ "coffee_shop": "cafe",
306
+ "coffee": "cafe",
307
+ "diner": "cafe",
308
+ "restaurant": "cafe",
309
+ "bakery": "cafe",
310
+ "store": "shop",
311
+ "boutique": "shop",
312
+ "pharmacy": "shop",
313
+ "gym": "shop",
314
+ "mall": "market",
315
+ "grocery": "market",
316
+ "supermarket": "market",
317
+ "police_station": "fire_station",
318
+ "police": "fire_station",
319
+ "firehouse": "fire_station",
320
+ "library": "town_hall",
321
+ "museum": "town_hall",
322
+ "city_hall": "town_hall",
323
+ "temple": "church",
324
+ "mosque": "church",
325
+ "chapel": "church",
326
+ "garden": "park",
327
+ "playground": "park",
328
+ "fountain": "plaza",
329
+ "square": "plaza",
330
+ "arena": "stadium",
331
+ "clinic": "hospital",
332
+ "plant": "factory",
333
+ "mill": "factory",
334
+ "home": "house",
335
+ "cottage": "house",
336
+ "cabin": "house"
337
+ };
338
+ export const STREET_NAMES = [
339
+ "Oak St",
340
+ "Maple Ave",
341
+ "Cedar Ln",
342
+ "Birch St",
343
+ "Willow Way",
344
+ "Juniper St",
345
+ "Laurel Ave",
346
+ "Magnolia Blvd",
347
+ "Poplar St",
348
+ "Chestnut St",
349
+ "Sycamore Ave",
350
+ "Alder Row",
351
+ "Hazel St",
352
+ "Linden Ave",
353
+ "Aspen Ct",
354
+ "Rowan St",
355
+ "Holly Ave",
356
+ "Ivy Ln",
357
+ "Clover St",
358
+ "Fern Way",
359
+ "Meadow Ln",
360
+ "Orchard St",
361
+ "Garden Ave",
362
+ "Harbor St",
363
+ "Mill Rd",
364
+ "Canal St",
365
+ "Foundry St",
366
+ "Station Rd",
367
+ "Depot Ln",
368
+ "Prospect Ave",
369
+ "Summit St",
370
+ "Vista Way",
371
+ "Sunrise Blvd",
372
+ "Larkspur St",
373
+ "Primrose Ave",
374
+ "Bluebell Ln",
375
+ "Copper St",
376
+ "Granite Ave",
377
+ "Beacon St",
378
+ "Quay Rd"
379
+ ];
380
+
381
+ // traffic (mirrors engine/traffic.py — the Python side owns the facts)
382
+ export const RUSH_HOURS = [
383
+ 8.0,
384
+ 18.0
385
+ ];
386
+ export const RUSH_SIGMA = 1.2;
387
+ export const RUSH_BASE = 0.35;
388
+ export const RUSH_AMP = 0.65;
389
+ export const CAR_RATE = 0.8;
390
+ export const CAR_MIN = 8;
391
+ export const CAR_MAX = 120;
392
+ export const CITIZENS_PER_POP = 5;
393
+ export const CITIZENS_MIN = 6;
394
+ export const CITIZENS_MAX = 80;
395
+ export const EMA_TAU_S = 5.0;
396
+ export const TRIP_ATTRACT_FACTOR = 2.0;
397
+ export const TRIP_DIST_EXP = 1.5;
398
+ export const DEMAND_PER_COMMUTER = 0.5;
399
+ export const CONGESTION_COST_FACTOR = 2.0;
400
+ export const TRAFFIC_TOP_CELLS = 15;
401
+ export const JAM_RATIO = 1.0;
402
+ export const GROWTH_RADIUS_MIN = 6;
web/city/construction.js ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — construction FX: dirt lot -> orange scaffold pop -> (crane on
2
+ // >=4 floors) -> floor-by-floor rise -> scaffold drop + sparkle + name label.
3
+ // Everything is a pure function of absolute simTime: progress =
4
+ // clamp((CITY_EPOCH_S + simTime - ev.t) / DURATION(kind), 0, 1), so late
5
+ // joiners, seek() and timelapse all replay the exact same build.
6
+ //
7
+ // Convention with buildings.js (which owns every building mesh): this module
8
+ // only computes the staged rise and calls buildings.setRise(id, yScale,
9
+ // floorsShown) + buildings.setFinishGlow(id, k, intensity). Sites self-remove
10
+ // ~6.5 s after completion; re-seeking BACKWARD across a finished build needs a
11
+ // city.reset() replay (sites are re-added from the event list).
12
+ // lay_road paint-in is roads.js's job; this module ignores road events.
13
+ import * as THREE from "three";
14
+ import { mulberry32 } from "./rng.js";
15
+ import { CITY_EPOCH_S, CELL, BUILDINGS } from "./constants.js";
16
+ import { PALETTE, EMISSIVE } from "./palette.js";
17
+ import { clamp, easeOutBack } from "./util.js";
18
+
19
+ const P_DIRT = 0.12, P_SCAF = 0.30, P_TOP = 0.90; // contract stage boundaries
20
+ const GREEN = { park: true, plaza: true }; // no scaffold/crane/barriers
21
+ const FINISH_S = 6.2; // label fade window after completion
22
+ const SPARKS = 14;
23
+
24
+ const _m4 = new THREE.Matrix4();
25
+ const _p = new THREE.Vector3();
26
+ const _q = new THREE.Quaternion();
27
+ const _s = new THREE.Vector3();
28
+ const _c = new THREE.Color();
29
+
30
+ // ---- shared geometries/materials (never disposed per-site) -----------------
31
+ function scaffoldGeo() {
32
+ // unit frame [-0.5,0.5] x [0,1] x [-0.5,0.5]: box edges + rings + diagonals
33
+ const v = [];
34
+ const seg = (a, b) => v.push(...a, ...b);
35
+ const c = (x, y, z) => [x, y, z];
36
+ for (const y of [0, 1 / 3, 2 / 3, 1]) {
37
+ seg(c(-0.5, y, -0.5), c(0.5, y, -0.5));
38
+ seg(c(0.5, y, -0.5), c(0.5, y, 0.5));
39
+ seg(c(0.5, y, 0.5), c(-0.5, y, 0.5));
40
+ seg(c(-0.5, y, 0.5), c(-0.5, y, -0.5));
41
+ }
42
+ for (const [x, z] of [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]) {
43
+ seg(c(x, 0, z), c(x, 1, z));
44
+ }
45
+ seg(c(-0.5, 0, -0.5), c(0.5, 1 / 3, -0.5)); // a few cross braces
46
+ seg(c(0.5, 1 / 3, 0.5), c(-0.5, 2 / 3, 0.5));
47
+ seg(c(-0.5, 2 / 3, -0.5), c(-0.5, 1, 0.5));
48
+ const geo = new THREE.BufferGeometry();
49
+ geo.setAttribute("position", new THREE.Float32BufferAttribute(v, 3));
50
+ return geo;
51
+ }
52
+
53
+ const GEO = {
54
+ scaffold: scaffoldGeo(),
55
+ box: new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0),
56
+ barrier: new THREE.BoxGeometry(0.42, 0.38, 0.42).translate(0, 0.19, 0),
57
+ dirt: new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2),
58
+ ring: new THREE.RingGeometry(0.72, 1, 28).rotateX(-Math.PI / 2),
59
+ spark: new THREE.OctahedronGeometry(0.13, 0),
60
+ };
61
+ const CRANE_STEEL = new THREE.MeshStandardMaterial({
62
+ color: new THREE.Color(EMISSIVE.construction).multiplyScalar(0.85),
63
+ roughness: 0.6, metalness: 0.3, envMapIntensity: 0.25,
64
+ });
65
+ const CRANE_DARK = new THREE.MeshStandardMaterial({
66
+ color: "#3F454F", roughness: 0.7, metalness: 0.2, envMapIntensity: 0.25,
67
+ });
68
+
69
+ export class Construction {
70
+ constructor(scene, quality, city = null) {
71
+ this.scene = scene;
72
+ this.quality = quality || { instances: 1 };
73
+ this.city = city;
74
+ this.sites = new Map();
75
+ this.simTime = null; // last update() sim clock; null until the first tick
76
+ }
77
+
78
+ get _buildings() {
79
+ return this.city ? this.city.buildings : null;
80
+ }
81
+
82
+ /** world.js skeleton calls addFeature(f, opts); keep both names. */
83
+ addFeature(f) { return this.add(f); }
84
+
85
+ add(f) {
86
+ if (!f || f.tool !== "place_building" || !f.args || this.sites.has(f.id)) return;
87
+ const a = f.args;
88
+ const spec = BUILDINGS[a.kind] || BUILDINGS.house;
89
+ const kind = BUILDINGS[a.kind] ? a.kind : "house";
90
+ const dur = spec.duration;
91
+ if (this.simTime != null && CITY_EPOCH_S + this.simTime - f.t > dur + FINISH_S) return;
92
+
93
+ const w = a.w ?? spec.w, d = a.d ?? spec.d;
94
+ const floors = Math.max(1, clamp(a.floors ?? Math.round((spec.floors[0] + spec.floors[1]) / 2),
95
+ spec.floors[0], spec.floors[1]));
96
+ const x = a.cx * CELL + (w * CELL) / 2, z = a.cz * CELL + (d * CELL) / 2;
97
+ const W = w * CELL, D = d * CELL;
98
+ const info = this._buildings && this._buildings.getInfo(f.id);
99
+ const height = info ? info.height : Math.max(floors * 3, 3);
100
+ const shownCap = info ? info.floors : floors; // park/plaza have 0 window floors
101
+ const rng = mulberry32(((f.seed ?? 1) ^ 0x5bd1e995) >>> 0);
102
+ const green = !!GREEN[kind];
103
+
104
+ const group = new THREE.Group();
105
+ group.position.set(x, 0, z);
106
+ this.scene.add(group);
107
+
108
+ const site = {
109
+ f, id: f.id, kind, dur, green, floors, shownCap, W, D, height, x, z, group,
110
+ mats: [], glowDone: false, labelMade: false, label: null,
111
+ };
112
+
113
+ // dirt lot decal (slightly beyond the footprint)
114
+ site.dirtMat = new THREE.MeshStandardMaterial({
115
+ color: new THREE.Color(PALETTE.dirt).offsetHSL(0, 0, -0.05),
116
+ roughness: 1, metalness: 0, transparent: true, opacity: 0,
117
+ depthWrite: false, envMapIntensity: 0.25,
118
+ });
119
+ site.dirt = new THREE.Mesh(GEO.dirt, site.dirtMat);
120
+ site.dirt.scale.set(W * 1.3, 1, D * 1.3);
121
+ site.dirt.position.y = 0.015;
122
+ site.dirt.renderOrder = 1;
123
+ group.add(site.dirt);
124
+ site.mats.push(site.dirtMat);
125
+
126
+ // dust ring (re-triggered at every stage/floor transition)
127
+ site.ringMat = new THREE.MeshBasicMaterial({
128
+ color: new THREE.Color(PALETTE.dirt).offsetHSL(0, 0, 0.1), transparent: true,
129
+ opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false,
130
+ });
131
+ site.ring = new THREE.Mesh(GEO.ring, site.ringMat);
132
+ site.ring.position.y = 0.04;
133
+ site.ring.visible = false;
134
+ group.add(site.ring);
135
+ site.mats.push(site.ringMat);
136
+
137
+ // transition times (s after ev.t) that kick a dust ring
138
+ site.transitions = green
139
+ ? [0, dur * 0.2, dur * 0.85, dur]
140
+ : [0, dur * P_DIRT, dur * P_SCAF,
141
+ ...Array.from({ length: floors }, (_, i) => dur * (P_SCAF + (P_TOP - P_SCAF) * ((i + 1) / floors))),
142
+ dur];
143
+
144
+ if (!green) {
145
+ site.scafMat = new THREE.LineBasicMaterial({
146
+ color: EMISSIVE.construction, transparent: true, opacity: 0.9,
147
+ });
148
+ site.scaffold = new THREE.LineSegments(GEO.scaffold, site.scafMat);
149
+ site.scaffold.scale.set(W - 0.1, 0.0001, D - 0.1);
150
+ group.add(site.scaffold);
151
+ site.mats.push(site.scafMat);
152
+
153
+ // barrier cubes, two alternating amber blink phases
154
+ site.blinkA = new THREE.MeshBasicMaterial({ color: EMISSIVE.signalAmber });
155
+ site.blinkB = new THREE.MeshBasicMaterial({ color: EMISSIVE.signalAmber });
156
+ site.mats.push(site.blinkA, site.blinkB);
157
+ site.barriers = new THREE.Group();
158
+ const bx = W / 2 + 0.7, bz = D / 2 + 0.7;
159
+ const spots = [[-bx, -bz], [bx, -bz], [-bx, bz], [bx, bz]];
160
+ if (w > 1 || d > 1) spots.push([0, -bz], [0, bz], [-bx, 0], [bx, 0]);
161
+ spots.forEach(([px, pz], i) => {
162
+ const m = new THREE.Mesh(GEO.barrier, i % 2 ? site.blinkA : site.blinkB);
163
+ m.position.set(px + (rng() - 0.5) * 0.3, 0, pz + (rng() - 0.5) * 0.3);
164
+ m.rotation.y = rng() * Math.PI;
165
+ site.barriers.add(m);
166
+ });
167
+ site.barriers.scale.setScalar(0.0001);
168
+ group.add(site.barriers);
169
+
170
+ if (floors >= 4) site.crane = this._makeCrane(site, rng);
171
+ }
172
+
173
+ // finish sparkle burst (seeded, despawns; pure fn of age)
174
+ site.sparkMat = new THREE.MeshBasicMaterial({
175
+ color: EMISSIVE.streetlamp, transparent: true, opacity: 1,
176
+ blending: THREE.AdditiveBlending, depthWrite: false,
177
+ });
178
+ site.sparks = new THREE.InstancedMesh(GEO.spark, site.sparkMat, SPARKS);
179
+ site.sparks.count = 0;
180
+ site.sparks.frustumCulled = false;
181
+ group.add(site.sparks);
182
+ site.mats.push(site.sparkMat);
183
+ site.sparkSeeds = Array.from({ length: SPARKS }, () => ({
184
+ az: rng() * Math.PI * 2,
185
+ r0: 0.4 + rng() * 0.8,
186
+ vr: 2.2 + rng() * 2.6,
187
+ vy: 3.2 + rng() * 3.4,
188
+ size: 0.7 + rng() * 0.8,
189
+ }));
190
+
191
+ // mask the finished building until the rise begins
192
+ if (this._buildings) this._buildings.setRise(f.id, 0, 0);
193
+ this.sites.set(f.id, site);
194
+ }
195
+
196
+ _makeCrane(site, rng) {
197
+ const mastH = site.height + 6;
198
+ const jibL = Math.max(site.W, site.D) * 0.9 + 3;
199
+ const g = new THREE.Group();
200
+ const corner = [[1, 1], [1, -1], [-1, 1], [-1, -1]][Math.floor(rng() * 4)];
201
+ g.position.set(corner[0] * (site.W / 2 + 1.6), 0, corner[1] * (site.D / 2 + 1.6));
202
+ g.scale.setScalar(0.0001);
203
+ const mast = new THREE.Mesh(GEO.box, CRANE_STEEL);
204
+ mast.scale.set(0.5, mastH, 0.5);
205
+ g.add(mast);
206
+ const base = new THREE.Mesh(GEO.box, CRANE_DARK);
207
+ base.scale.set(1.6, 0.5, 1.6);
208
+ g.add(base);
209
+ const jib = new THREE.Group();
210
+ jib.position.y = mastH;
211
+ g.add(jib);
212
+ const cab = new THREE.Mesh(GEO.box, CRANE_DARK);
213
+ cab.scale.set(0.9, 0.9, 0.9);
214
+ cab.position.y = -0.9;
215
+ jib.add(cab);
216
+ const arm = new THREE.Mesh(GEO.box, CRANE_STEEL);
217
+ arm.scale.set(jibL, 0.32, 0.32);
218
+ arm.position.x = jibL / 2;
219
+ arm.position.y = -0.32;
220
+ jib.add(arm);
221
+ const counter = new THREE.Mesh(GEO.box, CRANE_STEEL);
222
+ counter.scale.set(jibL * 0.35, 0.32, 0.32);
223
+ counter.position.x = -jibL * 0.175;
224
+ counter.position.y = -0.32;
225
+ jib.add(counter);
226
+ const weight = new THREE.Mesh(GEO.box, CRANE_DARK);
227
+ weight.scale.set(0.7, 1.1, 1.1);
228
+ weight.position.set(-jibL * 0.33, -1.1, 0);
229
+ jib.add(weight);
230
+ const cable = new THREE.Mesh(GEO.box, CRANE_DARK);
231
+ cable.scale.set(0.05, 1, 0.05);
232
+ jib.add(cable);
233
+ const hook = new THREE.Mesh(GEO.box, CRANE_STEEL);
234
+ hook.scale.set(0.4, 0.4, 0.4);
235
+ jib.add(hook);
236
+ site.group.add(g);
237
+ return { group: g, jib, cable, hook, mastH, jibR: jibL * 0.72, phase: rng() * Math.PI * 2 };
238
+ }
239
+
240
+ update(dt, simTime) {
241
+ this.simTime = simTime;
242
+ const now = CITY_EPOCH_S + simTime;
243
+ const B = this._buildings;
244
+ for (const site of [...this.sites.values()]) {
245
+ const age = now - site.f.t;
246
+ if (age > site.dur + FINISH_S + 0.3) {
247
+ this._finalize(site);
248
+ continue;
249
+ }
250
+ const p = clamp(age / site.dur, 0, 1);
251
+ const post = Math.max(0, age - site.dur); // seconds past completion
252
+ const fade = clamp(1 - post / 0.9, 0, 1); // scaffold/crane/barrier out
253
+
254
+ // ---- staged rise ----
255
+ let yScale = 0, floorsShown = 0, scafK = 0;
256
+ if (site.green) {
257
+ yScale = p <= 0.2 ? 0 : clamp(easeOutBack((p - 0.2) / 0.65), 0, 1.06);
258
+ if (p > 0.85) yScale = 1;
259
+ floorsShown = p > 0.85 ? site.floors : 0;
260
+ } else if (p <= P_DIRT) {
261
+ // dirt + dust only
262
+ } else if (p <= P_SCAF) {
263
+ scafK = easeOutBack((p - P_DIRT) / (P_SCAF - P_DIRT));
264
+ } else if (p <= P_TOP) {
265
+ scafK = 1;
266
+ const fF = ((p - P_SCAF) / (P_TOP - P_SCAF)) * site.floors;
267
+ const done = Math.min(Math.floor(fF), site.floors);
268
+ yScale = Math.min((done + easeOutBack(fF - done)) / site.floors, 1.06);
269
+ floorsShown = done;
270
+ } else {
271
+ scafK = 1;
272
+ yScale = 1;
273
+ floorsShown = site.floors;
274
+ }
275
+ if (B) B.setRise(site.id, yScale, Math.min(floorsShown, site.shownCap));
276
+
277
+ // ---- dirt + dust ring ----
278
+ site.dirtMat.opacity = 0.85 * (age >= site.dur ? clamp(1 - post / 2.5, 0, 1) : 1);
279
+ let ringK = -1;
280
+ for (let i = site.transitions.length - 1; i >= 0; i--) {
281
+ const t = site.transitions[i];
282
+ if (age >= t && age - t < 0.9) { ringK = (age - t) / 0.9; break; }
283
+ if (t < age - 0.9) break;
284
+ }
285
+ site.ring.visible = ringK >= 0;
286
+ if (ringK >= 0) {
287
+ const fp = Math.max(site.W, site.D);
288
+ site.ring.scale.setScalar(fp * (0.5 + 0.55 * ringK));
289
+ site.ringMat.opacity = (1 - ringK) * (1 - ringK) * 0.55;
290
+ }
291
+
292
+ // ---- scaffold / barriers / crane ----
293
+ if (!site.green) {
294
+ const scafY = Math.max(site.height + 0.5, 1) * scafK * Math.max(fade, 0.0001);
295
+ site.scaffold.scale.y = Math.max(scafY, 0.0001);
296
+ site.scafMat.opacity = 0.9 * fade;
297
+ site.scaffold.visible = scafK > 0 && fade > 0;
298
+ site.barriers.scale.setScalar(Math.max(scafK * fade, 0.0001));
299
+ const blink = Math.pow(Math.max(0, Math.sin(simTime * 5)), 2);
300
+ _c.set(EMISSIVE.signalAmber).multiplyScalar(0.25 + 1.7 * blink);
301
+ site.blinkA.color.copy(_c);
302
+ _c.set(EMISSIVE.signalAmber).multiplyScalar(0.25 + 1.7 * (1 - blink));
303
+ site.blinkB.color.copy(_c);
304
+ if (site.crane) {
305
+ const cr = site.crane;
306
+ const inK = easeOutBack(clamp((p - P_DIRT) / 0.06, 0, 1));
307
+ cr.group.scale.setScalar(Math.max(inK * fade, 0.0001));
308
+ cr.jib.rotation.y = cr.phase + 0.15 * simTime;
309
+ const hookY = clamp(yScale, 0, 1) * site.height + 1.5;
310
+ const drop = Math.max(cr.mastH - 1 - hookY, 0.5);
311
+ cr.cable.position.set(cr.jibR, -0.45 - drop, 0);
312
+ cr.cable.scale.y = drop;
313
+ cr.hook.position.set(cr.jibR, -0.45 - drop, 0);
314
+ }
315
+ }
316
+
317
+ // ---- finish beat: window sweep, sparkle, floating label ----
318
+ if (!site.glowDone && B) {
319
+ if (p > P_TOP && age < site.dur) {
320
+ B.setFinishGlow(site.id, (p - P_TOP) / (1 - P_TOP), 1);
321
+ } else if (age >= site.dur) {
322
+ const intensity = clamp(1 - post / 3, 0, 1);
323
+ B.setFinishGlow(site.id, 1, intensity);
324
+ if (intensity <= 0) site.glowDone = true;
325
+ }
326
+ }
327
+ if (age >= site.dur) {
328
+ this._sparkles(site, post);
329
+ this._label(site, post);
330
+ }
331
+ }
332
+ }
333
+
334
+ _sparkles(site, t) {
335
+ if (t > 1.4) { site.sparks.count = 0; return; }
336
+ site.sparks.count = SPARKS;
337
+ const k = t / 1.4;
338
+ site.sparkMat.opacity = 1 - k;
339
+ _q.identity();
340
+ for (let i = 0; i < SPARKS; i++) {
341
+ const sd = site.sparkSeeds[i];
342
+ const rr = sd.r0 + sd.vr * t;
343
+ _p.set(Math.cos(sd.az) * rr, Math.max(site.height + sd.vy * t - 4.5 * t * t, 0.1), Math.sin(sd.az) * rr);
344
+ _s.setScalar(Math.max(sd.size * (1 - k), 0.001));
345
+ _m4.compose(_p, _q, _s);
346
+ site.sparks.setMatrixAt(i, _m4);
347
+ }
348
+ site.sparks.instanceMatrix.needsUpdate = true;
349
+ }
350
+
351
+ _label(site, t) {
352
+ if (!site.labelMade) {
353
+ site.labelMade = true;
354
+ const a = site.f.args;
355
+ const name = a.name || site.kind.replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
356
+ const m = /^w_0*(\d+)$/.exec(site.f.wish_id || "");
357
+ const text = m ? `${name} — built for visitor #${m[1]}` : name;
358
+ const canvas = document.createElement("canvas");
359
+ canvas.width = 1024;
360
+ canvas.height = 128;
361
+ const ctx = canvas.getContext("2d");
362
+ ctx.font = "600 46px 'Bricolage Grotesque', 'Schibsted Grotesk', sans-serif";
363
+ ctx.textAlign = "center";
364
+ ctx.textBaseline = "middle";
365
+ ctx.lineWidth = 8;
366
+ ctx.strokeStyle = "rgba(42,38,32,0.85)";
367
+ ctx.fillStyle = "#FCF9F2";
368
+ ctx.strokeText(text, 512, 66, 1000);
369
+ ctx.fillText(text, 512, 66, 1000);
370
+ const tex = new THREE.CanvasTexture(canvas);
371
+ const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
372
+ const sprite = new THREE.Sprite(mat);
373
+ sprite.scale.set(18, 2.25, 1);
374
+ sprite.renderOrder = 10;
375
+ site.group.add(sprite);
376
+ site.label = { sprite, mat, tex };
377
+ site.mats.push(mat);
378
+ }
379
+ const L = site.label;
380
+ L.sprite.position.y = site.height + 2.4 + 0.45 * t;
381
+ L.mat.opacity = Math.min(t / 0.35, 1) * clamp(1 - (t - 4.5) / 1.5, 0, 1);
382
+ L.sprite.visible = L.mat.opacity > 0.01;
383
+ }
384
+
385
+ _finalize(site) {
386
+ const B = this._buildings;
387
+ if (B) {
388
+ B.setRise(site.id, 1, site.shownCap);
389
+ B.setFinishGlow(site.id, 1, 0);
390
+ }
391
+ this._dispose(site);
392
+ this.sites.delete(site.id);
393
+ }
394
+
395
+ _dispose(site) {
396
+ this.scene.remove(site.group);
397
+ if (site.sparks) site.sparks.dispose();
398
+ if (site.label) site.label.tex.dispose();
399
+ for (const m of site.mats) m.dispose();
400
+ }
401
+
402
+ reset() {
403
+ for (const site of this.sites.values()) this._dispose(site);
404
+ this.sites.clear();
405
+ }
406
+ }
web/city/fpv.js ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — "Walk the streets". Pointer-lock first person: WASD + Shift
2
+ // (5 / 9 m/s, exponential accel), eye 1.65 m, integrated in the fixed-step
3
+ // via scene.js's setFPV rig-swap hook. Collision is occupancy-grid only —
4
+ // a r=0.35 m circle axis-slid against ground.isWalkable, clamped to the plot
5
+ // minus a 2-cell margin (soft velocity damp + body.fpv-edge vignette hint).
6
+ // Tilt-shift goes to 0 on enter (DoF in first person is nauseating) and is
7
+ // restored from the rig's height lerp on exit. Exit is pointerlockchange
8
+ // (releasing the lock), never a keydown trap.
9
+ import * as THREE from "three";
10
+ import { PointerLockControls } from "three/addons/controls/PointerLockControls.js";
11
+ import { GRID, CELL } from "./constants.js";
12
+ import { clamp, cellToWorld, worldToCell, cellKey } from "./util.js";
13
+
14
+ const EYE = 1.65;
15
+ const WALK = 5, RUN = 9; // m/s (contract)
16
+ const ACCEL = 9; // exp accel/decel rate
17
+ const R = 0.35; // player collision circle, m
18
+ const PLOT_HALF = (GRID * CELL) / 2;
19
+ const BOUND = PLOT_HALF - 2 * CELL; // plot minus 2-cell margin
20
+ const SOFT = 6; // soft-edge damp band, m
21
+ const SPAWN_SEARCH = 16; // Chebyshev rings searched for a road cell
22
+ const FADE_MS = 300;
23
+
24
+ /** main.js hides the Walk affordance on touch devices (v0: no mobile FPV). */
25
+ export const isTouchDevice = () =>
26
+ "ontouchstart" in window || (navigator.maxTouchPoints || 0) > 0;
27
+
28
+ const _dir = new THREE.Vector3();
29
+ const _euler = new THREE.Euler(0, 0, 0, "YXZ");
30
+
31
+ export class FPV {
32
+ constructor(camera, dom, ground, onExit) {
33
+ this.camera = camera;
34
+ this.dom = dom;
35
+ this.ground = ground;
36
+ this.onExit = onExit;
37
+ this.active = false;
38
+ this.vel = new THREE.Vector2(); // world-space (x, z) velocity
39
+ this.keys = new Set();
40
+ this._app = null;
41
+ this._rig = null;
42
+ this._roads = null; // optional roadCells Map (setRoadLookup)
43
+ this._edge = false;
44
+
45
+ this.controls = new PointerLockControls(camera, dom);
46
+ this.controls.minPolarAngle = 0.12;
47
+ this.controls.maxPolarAngle = Math.PI - 0.25;
48
+ this.controls.addEventListener("unlock", () => this.exit());
49
+
50
+ document.addEventListener("keydown", (e) => {
51
+ if (this.active) this.keys.add(e.code);
52
+ });
53
+ document.addEventListener("keyup", (e) => this.keys.delete(e.code));
54
+ }
55
+
56
+ /** Wire the F-key toggle + app/rig refs. Call once from main.js:
57
+ * fpv.bindKeys(app, rig). Enter spawns near rig.lookPoint(). */
58
+ bindKeys(app, rig) {
59
+ this._app = app;
60
+ this._rig = rig;
61
+ window.addEventListener("keydown", (e) => {
62
+ if (e.code !== "KeyF" || isTouchDevice()) return;
63
+ const t = e.target;
64
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
65
+ if (this.active) this.exit();
66
+ else this.enter(rig.lookPoint());
67
+ });
68
+ return this;
69
+ }
70
+
71
+ /** Prefer spawning on real road cells (pass world.roadCells); without it the
72
+ * spawn search falls back to the ground occupancy mirror. */
73
+ setRoadLookup(roadCells) {
74
+ this._roads = roadCells;
75
+ }
76
+
77
+ _isRoad(cx, cz) {
78
+ const key = cellKey(cx, cz);
79
+ if (this._roads) return this._roads.has(key);
80
+ return this.ground._occ?.get(key) === "road"; // occupancy mirror fallback
81
+ }
82
+
83
+ /** Nearest walkable road cell to a world point (rings outward), falling
84
+ * back to any walkable cell. Returns a clamped world [x, z]. */
85
+ _spawn(p) {
86
+ const [scx, scz] = worldToCell(p?.x ?? 0, p?.z ?? 0);
87
+ let road = null, walk = null;
88
+ for (let r = 0; r <= SPAWN_SEARCH && !road; r++) {
89
+ for (let cz = scz - r; cz <= scz + r && !road; cz++) {
90
+ for (let cx = scx - r; cx <= scx + r; cx++) {
91
+ if (Math.max(Math.abs(cx - scx), Math.abs(cz - scz)) !== r) continue;
92
+ if (!this.ground.isWalkable(cx, cz)) continue;
93
+ if (!walk) walk = [cx, cz];
94
+ if (this._isRoad(cx, cz)) { road = [cx, cz]; break; }
95
+ }
96
+ }
97
+ }
98
+ const [cx, cz] = road || walk || [scx, scz];
99
+ const [x, z] = cellToWorld(cx, cz);
100
+ return [clamp(x, -BOUND + 1, BOUND - 1), clamp(z, -BOUND + 1, BOUND - 1)];
101
+ }
102
+
103
+ /** Drop to the street near spawnNear ({x, z} or Vector3). Must be called
104
+ * from a user gesture (pointer lock requirement). */
105
+ enter(spawnNear) {
106
+ if (this.active) return;
107
+ this.active = true;
108
+ const [x, z] = this._spawn(spawnNear);
109
+ // level the view: keep yaw, zero pitch, street-level eye
110
+ _euler.setFromQuaternion(this.camera.quaternion, "YXZ");
111
+ _euler.x = 0;
112
+ _euler.z = 0;
113
+ this.camera.quaternion.setFromEuler(_euler);
114
+ this.camera.position.set(x, EYE + this.ground.heightAt(x, z), z);
115
+ this.vel.set(0, 0);
116
+ this.keys.clear();
117
+ document.body.classList.add("fpv-fade");
118
+ setTimeout(() => {
119
+ document.body.classList.remove("fpv-fade");
120
+ if (this.active) document.body.classList.add("fpv-on");
121
+ }, FADE_MS);
122
+ this._app?.setFPV(this); // scene.js rig-swap hook
123
+ this._app?.post?.setTiltShift(0); // hard requirement: no DoF in FPV
124
+ this.controls.lock(); // sync, inside the user gesture
125
+ }
126
+
127
+ /** Leave FPV (pointer unlock, F toggle, or programmatic). Restores the orbit
128
+ * rig via onExit(playerPos) — main.js gazes the rig over the player. */
129
+ exit() {
130
+ if (!this.active) return;
131
+ this.active = false;
132
+ if (this.controls.isLocked) this.controls.unlock();
133
+ const pos = this.camera.position.clone();
134
+ document.body.classList.add("fpv-fade");
135
+ document.body.classList.remove("fpv-on");
136
+ if (this._edge) {
137
+ this._edge = false;
138
+ document.body.classList.remove("fpv-edge");
139
+ }
140
+ setTimeout(() => document.body.classList.remove("fpv-fade"), FADE_MS);
141
+ this._app?.setFPV(null);
142
+ if (this._app?.post && this._rig) {
143
+ this._app.post.setTiltShift(this._rig.tiltShiftAmount());
144
+ }
145
+ this.onExit?.(pos);
146
+ }
147
+
148
+ /** True if the r=0.35 circle, swept over [x0,x1]x[z0,z1], overlaps any
149
+ * non-walkable cell. */
150
+ _blocked(x0, x1, z0, z1) {
151
+ const [ax, az] = worldToCell(Math.min(x0, x1) - R, Math.min(z0, z1) - R);
152
+ const [bx, bz] = worldToCell(Math.max(x0, x1) + R, Math.max(z0, z1) + R);
153
+ for (let cz = az; cz <= bz; cz++) {
154
+ for (let cx = ax; cx <= bx; cx++) {
155
+ if (!this.ground.isWalkable(cx, cz)) return true;
156
+ }
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /** Called from the scene fixed-step (setFPV hook); input-driven, dt only. */
162
+ update(dt) {
163
+ if (!this.active) return;
164
+ const k = this.keys;
165
+ let fx = 0, fz = 0; // strafe right / forward intent
166
+ if (k.has("KeyW") || k.has("ArrowUp")) fz += 1;
167
+ if (k.has("KeyS") || k.has("ArrowDown")) fz -= 1;
168
+ if (k.has("KeyD") || k.has("ArrowRight")) fx += 1;
169
+ if (k.has("KeyA") || k.has("ArrowLeft")) fx -= 1;
170
+ const speed = k.has("ShiftLeft") || k.has("ShiftRight") ? RUN : WALK;
171
+
172
+ // camera-yaw basis flattened to the ground plane
173
+ this.camera.getWorldDirection(_dir);
174
+ _dir.y = 0;
175
+ if (_dir.lengthSq() < 1e-6) _dir.set(0, 0, -1);
176
+ _dir.normalize();
177
+ let tx = 0, tz = 0;
178
+ if (fx || fz) {
179
+ const n = 1 / Math.hypot(fx, fz);
180
+ tx = (_dir.x * fz + -_dir.z * fx) * n * speed;
181
+ tz = (_dir.z * fz + _dir.x * fx) * n * speed;
182
+ }
183
+ const a = 1 - Math.exp(-dt * ACCEL);
184
+ this.vel.x += (tx - this.vel.x) * a;
185
+ this.vel.y += (tz - this.vel.y) * a;
186
+
187
+ // soft edge: damp the outward component approaching the plot margin
188
+ const p = this.camera.position;
189
+ if (this.vel.x * Math.sign(p.x) > 0) {
190
+ this.vel.x *= clamp((BOUND - Math.abs(p.x)) / SOFT, 0, 1);
191
+ }
192
+ if (this.vel.y * Math.sign(p.z) > 0) {
193
+ this.vel.y *= clamp((BOUND - Math.abs(p.z)) / SOFT, 0, 1);
194
+ }
195
+
196
+ // axis-slide: test X then Z displacement independently, cancel only the
197
+ // blocked axis (sliding along building walls)
198
+ let nx = p.x + this.vel.x * dt;
199
+ if (this._blocked(p.x, nx, p.z, p.z)) {
200
+ nx = p.x;
201
+ this.vel.x = 0;
202
+ }
203
+ let nz = p.z + this.vel.y * dt;
204
+ if (this._blocked(nx, nx, p.z, nz)) {
205
+ nz = p.z;
206
+ this.vel.y = 0;
207
+ }
208
+ nx = clamp(nx, -BOUND, BOUND);
209
+ nz = clamp(nz, -BOUND, BOUND);
210
+ p.set(nx, EYE + this.ground.heightAt(nx, nz), nz);
211
+
212
+ const edge = Math.max(Math.abs(nx), Math.abs(nz)) > BOUND - SOFT;
213
+ if (edge !== this._edge) {
214
+ this._edge = edge;
215
+ document.body.classList.toggle("fpv-edge", edge);
216
+ }
217
+ }
218
+ }
web/city/ground.js ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — the table the miniature city stands on: chunked vertex-colored
2
+ // lawn, the recessed river, per-cell tinting (park/dirt/road undercoat), one
3
+ // shared contact-shadow blob InstancedMesh, and the occupancy mirror that FPV
4
+ // collision + citizens walk against. Roads themselves are drawn by roads.js;
5
+ // ground only undercoats their cells so lawn never peeks through.
6
+ import * as THREE from "three";
7
+ import { mulberry32 } from "./rng.js";
8
+ import { GRID, CELL, riverCols } from "./constants.js";
9
+ import { cellKey } from "./util.js";
10
+ import { PALETTE } from "./palette.js";
11
+
12
+ const HALF = GRID / 2;
13
+ const CHUNK = 16; // cells per chunk side -> 4x4 chunks
14
+ const WATER_Y = -0.15;
15
+ const BLOB_Y = 0.035; // above road quads (y=0.02), below curbs
16
+ const BLOB_MAX = 2048;
17
+
18
+ // type -> [hex, lightness jitter amplitude]
19
+ const TINTS = {
20
+ lawn: [PALETTE.lawn, 0.03],
21
+ park: [PALETTE.park, 0.02],
22
+ dirt: [PALETTE.dirt, 0.015],
23
+ road: [PALETTE.asphalt, 0],
24
+ plaza: [PALETTE.sidewalk, 0.01],
25
+ };
26
+
27
+ const _m4 = new THREE.Matrix4();
28
+ const _color = new THREE.Color();
29
+
30
+ export class Ground {
31
+ constructor(scene, quality) {
32
+ this.scene = scene;
33
+ this.quality = quality;
34
+ this.group = new THREE.Group();
35
+ this.group.matrixAutoUpdate = false;
36
+ scene.add(this.group);
37
+
38
+ this._occ = null; // occupancy Map mirror (set by City)
39
+ this._roadKeys = new Set();
40
+ this._cells = new Map(); // cellKey -> { attr, offset } for tintCell
41
+ this._water = new Set();
42
+ for (let cz = -HALF; cz < HALF; cz++) {
43
+ for (const cx of riverCols(cz)) this._water.add(cellKey(cx, cz));
44
+ }
45
+
46
+ this._buildChunks();
47
+ this._buildWater();
48
+ this._buildBlobs();
49
+ }
50
+
51
+ // ---- lawn chunks -------------------------------------------------------
52
+ _buildChunks() {
53
+ const mat = new THREE.MeshStandardMaterial({
54
+ vertexColors: true, roughness: 0.95, metalness: 0, envMapIntensity: 0.25,
55
+ });
56
+ for (let cj = 0; cj < GRID / CHUNK; cj++) {
57
+ for (let ci = 0; ci < GRID / CHUNK; ci++) {
58
+ const pos = [], col = [], nrm = [], idx = [];
59
+ for (let lz = 0; lz < CHUNK; lz++) {
60
+ for (let lx = 0; lx < CHUNK; lx++) {
61
+ const cx = -HALF + ci * CHUNK + lx;
62
+ const cz = -HALF + cj * CHUNK + lz;
63
+ if (this._water.has(cellKey(cx, cz))) continue;
64
+ const base = pos.length / 3;
65
+ const x0 = cx * CELL, z0 = cz * CELL;
66
+ pos.push(x0, 0, z0, x0 + CELL, 0, z0, x0, 0, z0 + CELL, x0 + CELL, 0, z0 + CELL);
67
+ this._cellColor(cx, cz, "lawn", _color);
68
+ for (let k = 0; k < 4; k++) {
69
+ col.push(_color.r, _color.g, _color.b);
70
+ nrm.push(0, 1, 0);
71
+ }
72
+ idx.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
73
+ this._cells.set(cellKey(cx, cz), { offset: base });
74
+ }
75
+ }
76
+ const geo = new THREE.BufferGeometry();
77
+ geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
78
+ geo.setAttribute("normal", new THREE.Float32BufferAttribute(nrm, 3));
79
+ geo.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
80
+ geo.setIndex(idx);
81
+ const mesh = new THREE.Mesh(geo, mat);
82
+ mesh.receiveShadow = true;
83
+ mesh.matrixAutoUpdate = false;
84
+ this.group.add(mesh);
85
+ // patch the cell records of this chunk with its color attribute
86
+ const attr = geo.attributes.color;
87
+ for (let lz = 0; lz < CHUNK; lz++) {
88
+ for (let lx = 0; lx < CHUNK; lx++) {
89
+ const key = cellKey(-HALF + ci * CHUNK + lx, -HALF + cj * CHUNK + lz);
90
+ const rec = this._cells.get(key);
91
+ if (rec && !rec.attr) rec.attr = attr;
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ /** Seeded per-cell color: base tint hex + lightness jitter from (cx,cz). */
99
+ _cellColor(cx, cz, type, out = new THREE.Color()) {
100
+ const [hex, jit] = TINTS[type] || TINTS.lawn;
101
+ out.set(hex);
102
+ if (jit > 0) {
103
+ const rng = mulberry32((((cx + HALF) * 73856093) ^ ((cz + HALF) * 19349663)) >>> 0);
104
+ out.offsetHSL(0, 0, (rng() * 2 - 1) * jit);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** Recolor one cell. type: lawn | park | dirt | road | plaza. */
110
+ tintCell(cx, cz, type) {
111
+ const rec = this._cells.get(cellKey(cx, cz));
112
+ if (!rec || !rec.attr) return;
113
+ this._cellColor(cx, cz, type, _color);
114
+ for (let k = 0; k < 4; k++) rec.attr.setXYZ(rec.offset + k, _color.r, _color.g, _color.b);
115
+ rec.attr.needsUpdate = true;
116
+ }
117
+
118
+ /** Undercoat road cells in asphalt so lawn never peeks under roads.js quads.
119
+ * Accepts the City roadCells Map (or any iterable of cell keys); append-only. */
120
+ setRoadCells(roadCells) {
121
+ const keys = roadCells && roadCells.keys ? roadCells.keys() : roadCells || [];
122
+ for (const key of keys) {
123
+ if (this._roadKeys.has(key)) continue;
124
+ this._roadKeys.add(key);
125
+ const [cx, cz] = key.split(",").map(Number);
126
+ this.tintCell(cx, cz, "road");
127
+ }
128
+ }
129
+
130
+ // ---- river ---------------------------------------------------------------
131
+ _buildWater() {
132
+ const pos = [], col = [], idx = [];
133
+ const water = new THREE.Color(PALETTE.water);
134
+ const v = new THREE.Color();
135
+ const dark = (x, centerX) => {
136
+ // darken 12% at the river centerline, fading out across one cell
137
+ const t = Math.max(0, 1 - Math.abs(x - centerX) / CELL);
138
+ v.copy(water).multiplyScalar(1 - 0.12 * t);
139
+ return v;
140
+ };
141
+ const bankPos = [], bankCol = [], bankIdx = [];
142
+ const bank = new THREE.Color(PALETTE.dirt).multiplyScalar(0.72);
143
+ for (let cz = -HALF; cz < HALF; cz++) {
144
+ const cols = riverCols(cz);
145
+ const centerX = (cols[0] + 1) * CELL;
146
+ for (const cx of cols) {
147
+ const base = pos.length / 3;
148
+ const x0 = cx * CELL, z0 = cz * CELL;
149
+ const corners = [[x0, z0], [x0 + CELL, z0], [x0, z0 + CELL], [x0 + CELL, z0 + CELL]];
150
+ for (const [x, z] of corners) {
151
+ pos.push(x, WATER_Y, z);
152
+ const c = dark(x, centerX);
153
+ col.push(c.r, c.g, c.b);
154
+ }
155
+ idx.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
156
+ // dirt bank wall on each edge facing non-water (off-grid counts as water)
157
+ const edges = [
158
+ [0, -1, x0, z0, x0 + CELL, z0],
159
+ [0, 1, x0, z0 + CELL, x0 + CELL, z0 + CELL],
160
+ [-1, 0, x0, z0, x0, z0 + CELL],
161
+ [1, 0, x0 + CELL, z0, x0 + CELL, z0 + CELL],
162
+ ];
163
+ for (const [dx, dz, ax, az, bx, bz] of edges) {
164
+ const ncx = cx + dx, ncz = cz + dz;
165
+ const offGrid = ncx < -HALF || ncx >= HALF || ncz < -HALF || ncz >= HALF;
166
+ if (offGrid || this._water.has(cellKey(ncx, ncz))) continue;
167
+ const b = bankPos.length / 3;
168
+ bankPos.push(ax, 0, az, bx, 0, bz, ax, WATER_Y, az, bx, WATER_Y, bz);
169
+ for (let k = 0; k < 4; k++) bankCol.push(bank.r, bank.g, bank.b);
170
+ bankIdx.push(b, b + 2, b + 1, b + 1, b + 2, b + 3);
171
+ }
172
+ }
173
+ }
174
+
175
+ const geo = new THREE.BufferGeometry();
176
+ geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
177
+ geo.setAttribute("aTint", new THREE.Float32BufferAttribute(col, 3));
178
+ geo.setIndex(idx);
179
+ this._waterMat = new THREE.ShaderMaterial({
180
+ fog: true,
181
+ uniforms: THREE.UniformsUtils.merge([THREE.UniformsLib.fog, { uTime: { value: 0 } }]),
182
+ vertexShader: /* glsl */ `
183
+ attribute vec3 aTint;
184
+ varying vec3 vColor;
185
+ varying vec3 vWp;
186
+ #include <common>
187
+ #include <fog_pars_vertex>
188
+ void main() {
189
+ vColor = aTint;
190
+ vec4 wp = modelMatrix * vec4(position, 1.0);
191
+ vWp = wp.xyz;
192
+ vec4 mvPosition = viewMatrix * wp;
193
+ gl_Position = projectionMatrix * mvPosition;
194
+ #include <fog_vertex>
195
+ }`,
196
+ fragmentShader: /* glsl */ `
197
+ #include <common>
198
+ #include <fog_pars_fragment>
199
+ uniform float uTime;
200
+ varying vec3 vColor;
201
+ varying vec3 vWp;
202
+ void main() {
203
+ float w = 1.0 + 0.05 * sin(uTime * 0.9 + vWp.x * 0.23 + vWp.z * 0.17);
204
+ gl_FragColor = vec4(vColor * w, 1.0);
205
+ #include <fog_fragment>
206
+ }`,
207
+ });
208
+ this.water = new THREE.Mesh(geo, this._waterMat);
209
+ this.water.matrixAutoUpdate = false;
210
+ this.group.add(this.water);
211
+
212
+ const bGeo = new THREE.BufferGeometry();
213
+ bGeo.setAttribute("position", new THREE.Float32BufferAttribute(bankPos, 3));
214
+ bGeo.setAttribute("color", new THREE.Float32BufferAttribute(bankCol, 3));
215
+ bGeo.setIndex(bankIdx);
216
+ bGeo.computeVertexNormals();
217
+ const banks = new THREE.Mesh(bGeo, new THREE.MeshStandardMaterial({
218
+ vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide, envMapIntensity: 0.25,
219
+ }));
220
+ banks.matrixAutoUpdate = false;
221
+ this.group.add(banks);
222
+ }
223
+
224
+ // ---- contact-shadow blobs (fake AO under buildings/trees/cars) -----------
225
+ _buildBlobs() {
226
+ const canvas = document.createElement("canvas");
227
+ canvas.width = canvas.height = 128;
228
+ const ctx = canvas.getContext("2d");
229
+ const g = ctx.createRadialGradient(64, 64, 4, 64, 64, 64);
230
+ g.addColorStop(0, "rgba(0,0,0,0.9)");
231
+ g.addColorStop(0.55, "rgba(0,0,0,0.42)");
232
+ g.addColorStop(1, "rgba(0,0,0,0)");
233
+ ctx.fillStyle = g;
234
+ ctx.fillRect(0, 0, 128, 128);
235
+ const tex = new THREE.CanvasTexture(canvas);
236
+
237
+ const geo = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2);
238
+ this._blobOpacity = new THREE.InstancedBufferAttribute(new Float32Array(BLOB_MAX), 1);
239
+ this._blobOpacity.setUsage(THREE.DynamicDrawUsage);
240
+ geo.setAttribute("aOpacity", this._blobOpacity);
241
+ const mat = new THREE.ShaderMaterial({
242
+ transparent: true, depthWrite: false,
243
+ uniforms: { tMap: { value: tex } },
244
+ vertexShader: /* glsl */ `
245
+ attribute float aOpacity;
246
+ varying vec2 vUv;
247
+ varying float vOp;
248
+ void main() {
249
+ vUv = uv;
250
+ vOp = aOpacity;
251
+ #ifdef USE_INSTANCING
252
+ vec4 wp = modelMatrix * instanceMatrix * vec4(position, 1.0);
253
+ #else
254
+ vec4 wp = modelMatrix * vec4(position, 1.0);
255
+ #endif
256
+ gl_Position = projectionMatrix * viewMatrix * wp;
257
+ }`,
258
+ fragmentShader: /* glsl */ `
259
+ uniform sampler2D tMap;
260
+ varying vec2 vUv;
261
+ varying float vOp;
262
+ void main() {
263
+ float a = texture2D(tMap, vUv).a * vOp;
264
+ if (a < 0.004) discard;
265
+ gl_FragColor = vec4(0.09, 0.075, 0.06, a);
266
+ }`,
267
+ });
268
+ this.blobs = new THREE.InstancedMesh(geo, mat, BLOB_MAX);
269
+ this.blobs.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
270
+ this.blobs.count = 0;
271
+ this.blobs.frustumCulled = false;
272
+ this.blobs.renderOrder = 2;
273
+ this.group.add(this.blobs);
274
+ this._blobFree = [];
275
+ this._blobTop = 0;
276
+ }
277
+
278
+ /**
279
+ * Allocate one contact-shadow quad. scale = world units across; buildings use
280
+ * footprint*1.25 @ opacity 0.22, trees ~0.5*canopy, cars/citizens their own.
281
+ * Returns { index, set(x,z,scale,opacity), release() }.
282
+ */
283
+ addBlob(x, z, scale, opacity) {
284
+ const i = this._blobFree.length ? this._blobFree.pop() : this._blobTop < BLOB_MAX ? this._blobTop++ : -1;
285
+ if (i < 0) return { index: -1, set() {}, release() {} }; // pool exhausted: silent no-op
286
+ this.blobs.count = this._blobTop;
287
+ const write = (x, z, scale, opacity) => {
288
+ _m4.makeScale(scale, 1, scale).setPosition(x, BLOB_Y, z);
289
+ this.blobs.setMatrixAt(i, _m4);
290
+ this.blobs.instanceMatrix.needsUpdate = true;
291
+ this._blobOpacity.setX(i, opacity);
292
+ this._blobOpacity.needsUpdate = true;
293
+ };
294
+ write(x, z, scale, opacity);
295
+ return {
296
+ index: i,
297
+ set: write,
298
+ release: () => {
299
+ write(0, 0, 0, 0);
300
+ this._blobFree.push(i);
301
+ },
302
+ };
303
+ }
304
+
305
+ // ---- occupancy mirror -----------------------------------------------------
306
+ /** Mirror the City's occupancy Map (cellKey -> water|road|building|park|plaza). */
307
+ setOccupancy(map) {
308
+ this._occ = map;
309
+ }
310
+
311
+ /** Roads/sidewalk/park/plaza/empty are walkable; buildings and water are not.
312
+ * Bridge cells carry "road" occupancy, so they pass. */
313
+ isWalkable(cx, cz) {
314
+ if (cx < -HALF || cx >= HALF || cz < -HALF || cz >= HALF) return false;
315
+ const v = this._occ ? this._occ.get(cellKey(cx, cz)) : undefined;
316
+ return v !== "building" && v !== "water";
317
+ }
318
+
319
+ /** Flat city: the walkable plane is y=0 everywhere. */
320
+ heightAt() {
321
+ return 0;
322
+ }
323
+
324
+ /** Repaint everything lawn (City.reset re-applies roads/parks afterwards). */
325
+ reset() {
326
+ this._roadKeys.clear();
327
+ for (const key of this._cells.keys()) {
328
+ const [cx, cz] = key.split(",").map(Number);
329
+ this.tintCell(cx, cz, "lawn");
330
+ }
331
+ }
332
+
333
+ update(dt, simTime) {
334
+ this._waterMat.uniforms.uTime.value = simTime;
335
+ }
336
+ }
web/city/palette.js ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — the Visual Bible palette ("Golden Hour Toybox"). Every hex the
2
+ // renderer uses lives here; constants.js holds only engine-mirrored values.
3
+
4
+ export const PALETTE = {
5
+ lawn: "#8FC474", // ±3% per-cell lightness jitter via vertex colors
6
+ park: "#6FB257",
7
+ dirt: "#CFB78F", // construction dirt lot
8
+ water: "#58AFC7", // darken 12% toward river center
9
+ sidewalk: "#D6D2C4", // 0.06 u curb lip
10
+ asphalt: "#565B66", // warm dark gray, never black
11
+ marking: "#F5F1E6", // lane markings warm-white
12
+ centerline: "#EFC75E", // avenue double yellow
13
+ civic: "#F8F4EA", // bank / town_hall / hospital / church facades
14
+ accent: "#E07856", // terracotta UI primary
15
+ };
16
+
17
+ // 8 facade hexes; per-instance seeded jitter: ±6° hue, ±5% lightness.
18
+ export const FACADES = [
19
+ "#F4E9D0", // cream
20
+ "#E07856", // terracotta
21
+ "#F2CC8F", // butter
22
+ "#EC9AA2", // coral
23
+ "#8FB8D8", // powder blue
24
+ "#7FB69E", // dusty teal
25
+ "#A9B98A", // sage
26
+ "#BD6B6E", // brick
27
+ ];
28
+ export const CIVIC_KINDS = ["bank", "town_hall", "hospital", "church"];
29
+
30
+ // kind -> roof hex (clay houses/church, slate apartments/school,
31
+ // charcoal offices/towers, gravel flat commercial). park/plaza have no roof.
32
+ export const ROOFS = {
33
+ house: "#C06B4F", townhouse: "#C06B4F", church: "#C06B4F",
34
+ apartments: "#5C6470", school: "#5C6470",
35
+ office: "#3F454F", tower: "#3F454F",
36
+ cafe: "#D9D5CB", shop: "#D9D5CB", market: "#D9D5CB", bank: "#D9D5CB",
37
+ hospital: "#D9D5CB", fire_station: "#D9D5CB", warehouse: "#D9D5CB",
38
+ factory: "#D9D5CB", stadium: "#D9D5CB", town_hall: "#D9D5CB",
39
+ };
40
+
41
+ // Night money colors (emissive only — bloom threshold keeps day pastels matte).
42
+ export const EMISSIVE = {
43
+ window: "#FFC97A",
44
+ streetlamp: "#FFD9A0",
45
+ headlight: "#FFF6DC",
46
+ taillight: "#FF5340",
47
+ signalRed: "#FF5340",
48
+ signalAmber: "#FFB13D",
49
+ signalGreen: "#43D17C",
50
+ neonTeal: "#45E0C0",
51
+ neonPink: "#FF6FA6",
52
+ construction: "#FF8A3D",
53
+ congestion: "#E84F3D", // jammed-road additive underglow
54
+ };
55
+
56
+ export const SKY = {
57
+ day: { zenith: "#7BC1E8", horizon: "#FFE9C2" },
58
+ night: { zenith: "#141C36", horizon: "#2C3760" }, // indigo, never black
59
+ };
60
+
61
+ // Fog color = the horizon color of the current sky blend.
62
+ export const FOG = {
63
+ day: { near: 140, far: 420 },
64
+ night: { near: 100, far: 360 },
65
+ };
66
+
67
+ // Sun (the ONE shadow-casting DirectionalLight) keyframes for sky.js.
68
+ export const SUN = {
69
+ golden: { color: "#FFCE96", intensity: 2.4, elev: 28, az: 235 },
70
+ noon: { color: "#FFE3BB", intensity: 2.8, elev: 62 },
71
+ dusk: { color: "#FF9E62", intensity: 1.2 },
72
+ moon: { color: "#8FA8D8", intensity: 0.15 },
73
+ };
74
+
75
+ export const HEMI = {
76
+ day: { sky: "#BCD7EA", ground: "#C7B18E", intensity: 0.6 },
77
+ night: { sky: "#2B3450", ground: "#161B28", intensity: 0.3 },
78
+ };
web/city/replay.js ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // GODSEED — wish performance scripts. Converts a WishTrace (+ its features)
2
+ // into a timed event list, and runs it against sim time. Drives both the
3
+ // ?mock=1 looping demo feed and Genesis Log replays — same liturgy either way.
4
+
5
+ const READ_WORD = 0.16; // seconds per reading word (the god reads slowly)
6
+ const THINK_WORD = 0.085; // seconds per thought word
7
+ const CALL_SETTLE = 3.1; // camera move + world delta animation
8
+ const GAP = 0.55;
9
+
10
+ function words(text) {
11
+ return String(text ?? "").split(/(\s+)/).filter((w) => w.length);
12
+ }
13
+
14
+ /**
15
+ * Build a timed event script from a WishTrace-shaped object.
16
+ * trace: {wish_id, text, reading, turns:[{thought, call|null, observation}], epitaph}
17
+ * features: ordered Feature[] minted by this wish (one per accepted call).
18
+ * Events: {at, type:'started'|'token'|'phase'|'call'|'delta'|'granted', ...}
19
+ */
20
+ export function buildWishScript(trace, features) {
21
+ const ev = [];
22
+ let t = 0;
23
+ let fi = 0;
24
+ ev.push({ at: t, type: "started", wish_id: trace.wish_id, text: trace.text });
25
+ t += 0.8;
26
+
27
+ ev.push({ at: t, type: "phase", phase: "reading" });
28
+ for (const w of words(trace.reading)) {
29
+ ev.push({ at: t, type: "token", text: w });
30
+ if (w.trim()) t += READ_WORD * (0.7 + Math.min(w.length, 9) * 0.06);
31
+ }
32
+ t += GAP * 1.6;
33
+
34
+ for (const turn of trace.turns ?? []) {
35
+ ev.push({ at: t, type: "phase", phase: "turn" });
36
+ for (const w of words(turn.thought)) {
37
+ ev.push({ at: t, type: "token", text: w });
38
+ if (w.trim()) t += THINK_WORD * (0.7 + Math.min(w.length, 9) * 0.06);
39
+ }
40
+ t += GAP;
41
+ if (turn.call) {
42
+ const rejected = String(turn.observation ?? "").startsWith("rejected");
43
+ ev.push({ at: t, type: "call", call: turn.call });
44
+ if (!rejected && fi < features.length) {
45
+ ev.push({ at: t + 0.4, type: "delta", feature: features[fi++] });
46
+ t += CALL_SETTLE;
47
+ } else {
48
+ t += 0.9;
49
+ }
50
+ if (turn.observation) {
51
+ ev.push({ at: t, type: "token", text: `\n› ${turn.observation}\n` });
52
+ t += GAP;
53
+ }
54
+ }
55
+ }
56
+
57
+ t += 0.6;
58
+ ev.push({ at: t, type: "granted", wish_id: trace.wish_id, epitaph: trace.epitaph });
59
+ return { events: ev, duration: t + 0.5 };
60
+ }
61
+
62
+ /** Runs an event script against monotonically increasing sim time. */
63
+ export class ScriptRunner {
64
+ constructor(handlers = {}) {
65
+ this.handlers = handlers;
66
+ this.events = null;
67
+ this.i = 0;
68
+ this.t0 = 0;
69
+ }
70
+
71
+ start(script, now) {
72
+ this.events = script.events;
73
+ this.duration = script.duration;
74
+ this.i = 0;
75
+ this.t0 = now;
76
+ }
77
+
78
+ get active() {
79
+ return !!this.events && this.i < this.events.length;
80
+ }
81
+
82
+ get done() {
83
+ return !!this.events && this.i >= this.events.length;
84
+ }
85
+
86
+ cancel() {
87
+ this.events = null;
88
+ }
89
+
90
+ update(now) {
91
+ if (!this.events) return;
92
+ const local = now - this.t0;
93
+ while (this.i < this.events.length && this.events[this.i].at <= local) {
94
+ const e = this.events[this.i++];
95
+ this.handlers[e.type]?.(e);
96
+ }
97
+ }
98
+ }
web/city/rng.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // GODSEED — deterministic randomness.
2
+ // Every feature carries an engine-assigned seed; the renderer is a pure
3
+ // function of (feature list, seeds). mulberry32 here MUST stay byte-stable.
4
+
5
+ /** mulberry32 PRNG — returns a function yielding floats in [0,1). */
6
+ export function mulberry32(seed) {
7
+ let a = seed >>> 0;
8
+ return function () {
9
+ a |= 0;
10
+ a = (a + 0x6d2b79f5) | 0;
11
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
12
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
13
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
14
+ };
15
+ }
16
+
17
+ /** Integer lattice hash → [0,1). Stable across calls; used by value noise. */
18
+ function hash3i(x, y, z, seed) {
19
+ let h = (x * 374761393 + y * 668265263 + z * 2147483647 + seed * 144665) | 0;
20
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
21
+ h ^= h >>> 16;
22
+ return (h >>> 0) / 4294967296;
23
+ }
24
+
25
+ function smooth(t) {
26
+ return t * t * (3 - 2 * t);
27
+ }
28
+
29
+ /** 3D value noise in [0,1], seeded. */
30
+ export function valueNoise3(x, y, z, seed) {
31
+ const ix = Math.floor(x), iy = Math.floor(y), iz = Math.floor(z);
32
+ const fx = smooth(x - ix), fy = smooth(y - iy), fz = smooth(z - iz);
33
+ const c000 = hash3i(ix, iy, iz, seed), c100 = hash3i(ix + 1, iy, iz, seed);
34
+ const c010 = hash3i(ix, iy + 1, iz, seed), c110 = hash3i(ix + 1, iy + 1, iz, seed);
35
+ const c001 = hash3i(ix, iy, iz + 1, seed), c101 = hash3i(ix + 1, iy, iz + 1, seed);
36
+ const c011 = hash3i(ix, iy + 1, iz + 1, seed), c111 = hash3i(ix + 1, iy + 1, iz + 1, seed);
37
+ const x00 = c000 + (c100 - c000) * fx;
38
+ const x10 = c010 + (c110 - c010) * fx;
39
+ const x01 = c001 + (c101 - c001) * fx;
40
+ const x11 = c011 + (c111 - c011) * fx;
41
+ const y0 = x00 + (x10 - x00) * fy;
42
+ const y1 = x01 + (x11 - x01) * fy;
43
+ return y0 + (y1 - y0) * fz;
44
+ }
45
+
46
+ /** Fractal value noise in [0,1], seeded; `oct` octaves. */
47
+ export function fbm3(x, y, z, seed, oct = 3) {
48
+ let sum = 0, amp = 0.5, f = 1, norm = 0;
49
+ for (let i = 0; i < oct; i++) {
50
+ sum += valueNoise3(x * f, y * f, z * f, seed + i * 1013) * amp;
51
+ norm += amp;
52
+ amp *= 0.5;
53
+ f *= 2.07;
54
+ }
55
+ return sum / norm;
56
+ }
web/city/roads.js ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — roads. Flat quads y=0.02 in ONE InstancedMesh with per-instance
2
+ // UV offsets into a procedurally painted 512x512 canvas atlas (8 tiles; dashes,
3
+ // stop lines, crosswalks and 6px soft dark edges baked — zero extra geometry).
4
+ // Bridges get a raised deck (y=0.5) + railings + sloped ramp quads on approach
5
+ // cells. Sidewalk curb strips appear on road cells that face zoned cells, and
6
+ // instanced lamp posts (pole + emissive head + additive light-pool quad at
7
+ // night) stand on every 3rd road cell. revealRoad() sweeps new cells in over
8
+ // 4 s along laid order. Everything rebuilds idempotently from the City maps.
9
+ import * as THREE from "three";
10
+ import { mulberry32 } from "./rng.js";
11
+ import { CELL } from "./constants.js";
12
+ import { cellKey, clamp, easeOutBack } from "./util.js";
13
+ import { PALETTE, EMISSIVE } from "./palette.js";
14
+
15
+ // Road surface heights; traffic.js drives car y from these (keep in sync).
16
+ export const ROAD_Y = { road: 0.02, ramp: 0.27, deck: 0.52 };
17
+
18
+ const QMAX = 768; // road quads + plaza paving + ramps (contract: road cells <= 600)
19
+ const CURB_MAX = 1024;
20
+ const LAMP_MAX = 256;
21
+ const DECK_MAX = 48;
22
+ const RAIL_MAX = 96;
23
+ const REVEAL_S = 4; // total sweep
24
+ const REVEAL_POP = 0.9; // per-cell scale-in inside the sweep
25
+
26
+ const TILES = { street: 0, avenue: 1, cross: 2, walk: 3, tee: 4, dead: 5, dirt: 6, plaza: 7 };
27
+ const uvOf = (k) => [(k % 4) * 0.25, k < 4 ? 0.5 : 0]; // CanvasTexture flipY: row 0 = top half
28
+ const DIRS = [[0, -1, 1], [1, 0, 2], [0, 1, 4], [-1, 0, 8]]; // dx, dz, mask bit (N E S W)
29
+ const RAMP_PITCH = Math.atan2(ROAD_Y.deck - ROAD_Y.road, CELL);
30
+
31
+ const _m4 = new THREE.Matrix4();
32
+ const _q = new THREE.Quaternion();
33
+ const _e = new THREE.Euler();
34
+ const _s = new THREE.Vector3();
35
+ const _p = new THREE.Vector3();
36
+
37
+ // ---- atlas ------------------------------------------------------------------
38
+ function paintAtlas() {
39
+ const cv = document.createElement("canvas");
40
+ cv.width = cv.height = 512;
41
+ const x = cv.getContext("2d");
42
+ const rng = mulberry32(0x70ad5);
43
+ const T = 128;
44
+ const at = (k) => [(k % 4) * T, k < 4 ? 0 : T];
45
+
46
+ const base = (k, hex = PALETTE.asphalt) => {
47
+ const [ox, oy] = at(k);
48
+ x.fillStyle = hex;
49
+ x.fillRect(ox, oy, T, T);
50
+ for (let i = 0; i < 90; i++) {
51
+ x.fillStyle = rng() < 0.5 ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.07)";
52
+ x.fillRect(ox + rng() * (T - 2), oy + rng() * (T - 2), 1 + rng() * 2, 1 + rng() * 2);
53
+ }
54
+ };
55
+ // 6px-class soft dark edge baked on a CLOSED side (fake AO; n = tile top)
56
+ const edge = (k, side) => {
57
+ const [ox, oy] = at(k);
58
+ const E = 8;
59
+ const g =
60
+ side === "n" ? x.createLinearGradient(0, oy, 0, oy + E)
61
+ : side === "s" ? x.createLinearGradient(0, oy + T, 0, oy + T - E)
62
+ : side === "w" ? x.createLinearGradient(ox, 0, ox + E, 0)
63
+ : x.createLinearGradient(ox + T, 0, ox + T - E, 0);
64
+ g.addColorStop(0, "rgba(20,18,16,0.42)");
65
+ g.addColorStop(1, "rgba(20,18,16,0)");
66
+ x.fillStyle = g;
67
+ if (side === "n") x.fillRect(ox, oy, T, E);
68
+ else if (side === "s") x.fillRect(ox, oy + T - E, T, E);
69
+ else if (side === "w") x.fillRect(ox, oy, E, T);
70
+ else x.fillRect(ox + T - E, oy, E, T);
71
+ };
72
+ const dashesV = (k, cx, w = 3, y0 = 6, y1 = T) => {
73
+ const [ox, oy] = at(k);
74
+ x.fillStyle = PALETTE.marking;
75
+ for (let y = y0; y < y1 - 6; y += 22) x.fillRect(ox + cx - w / 2, oy + y, w, 12);
76
+ };
77
+ const lineV = (k, cx, color, w = 3) => {
78
+ const [ox, oy] = at(k);
79
+ x.fillStyle = color;
80
+ x.fillRect(ox + cx - w / 2, oy + 4, w, T - 8);
81
+ };
82
+
83
+ base(TILES.street); edge(TILES.street, "w"); edge(TILES.street, "e");
84
+ dashesV(TILES.street, 64);
85
+
86
+ base(TILES.avenue); edge(TILES.avenue, "w"); edge(TILES.avenue, "e");
87
+ lineV(TILES.avenue, 60, PALETTE.centerline); lineV(TILES.avenue, 68, PALETTE.centerline);
88
+ lineV(TILES.avenue, 9, PALETTE.marking, 2); lineV(TILES.avenue, 119, PALETTE.marking, 2);
89
+ dashesV(TILES.avenue, 34, 2); dashesV(TILES.avenue, 94, 2);
90
+
91
+ base(TILES.cross);
92
+ { const [ox, oy] = at(TILES.cross); // manhole for charm
93
+ x.fillStyle = "rgba(0,0,0,0.18)"; x.beginPath();
94
+ x.arc(ox + 84, oy + 46, 6, 0, Math.PI * 2); x.fill(); }
95
+
96
+ base(TILES.walk); edge(TILES.walk, "w"); edge(TILES.walk, "e");
97
+ { const [ox, oy] = at(TILES.walk); // zebra toward tile top + stop line
98
+ x.fillStyle = PALETTE.marking;
99
+ for (let i = 0; i < 7; i++) x.fillRect(ox + 14 + i * 15, oy + 8, 9, 22);
100
+ x.fillRect(ox + 66, oy + 38, 54, 5); } // stop line, right lane (heading north)
101
+ dashesV(TILES.walk, 64, 3, 52);
102
+
103
+ base(TILES.tee); edge(TILES.tee, "w"); // closed side = W at rot 0
104
+ dashesV(TILES.tee, 64);
105
+ { const [ox, oy] = at(TILES.tee);
106
+ x.fillStyle = PALETTE.marking;
107
+ for (let x0 = 70; x0 < T - 8; x0 += 22) x.fillRect(ox + x0, oy + 62, 12, 3); }
108
+
109
+ base(TILES.dead); edge(TILES.dead, "n"); edge(TILES.dead, "w"); edge(TILES.dead, "e");
110
+ { const [ox, oy] = at(TILES.dead); // parking bays along the right side
111
+ x.fillStyle = PALETTE.marking;
112
+ x.fillRect(ox + 96, oy + 10, 2, 108);
113
+ for (let y = 10; y <= 118; y += 18) x.fillRect(ox + 96, oy + y, 30, 2); }
114
+
115
+ base(TILES.dirt, PALETTE.dirt);
116
+ for (const s of ["n", "s", "w", "e"]) edge(TILES.dirt, s);
117
+ { const [ox, oy] = at(TILES.dirt);
118
+ for (let i = 0; i < 60; i++) {
119
+ x.fillStyle = "rgba(90,70,45,0.16)";
120
+ x.fillRect(ox + rng() * (T - 3), oy + rng() * (T - 3), 2 + rng() * 2, 2 + rng() * 2);
121
+ } }
122
+
123
+ base(TILES.plaza, PALETTE.sidewalk);
124
+ { const [ox, oy] = at(TILES.plaza); // paving grid
125
+ x.fillStyle = "rgba(0,0,0,0.10)";
126
+ for (let i = 32; i < T; i += 32) { x.fillRect(ox + i, oy, 1, T); x.fillRect(ox, oy + i, T, 1); } }
127
+
128
+ const tex = new THREE.CanvasTexture(cv);
129
+ tex.colorSpace = THREE.SRGBColorSpace;
130
+ tex.anisotropy = 4;
131
+ return tex;
132
+ }
133
+
134
+ // tile + Y rotation from the 4-neighbor road mask (N1 E2 S4 W8).
135
+ // interMask marks neighbor dirs that are intersections (for crosswalk approach).
136
+ function tileFor(mask, klass, interMask) {
137
+ const straight = klass === "avenue" ? TILES.avenue : TILES.street;
138
+ const H = Math.PI / 2;
139
+ switch (mask) {
140
+ case 15: return [TILES.cross, 0];
141
+ case 7: return [TILES.tee, 0]; // missing W -> closed W
142
+ case 11: return [TILES.tee, H]; // missing S
143
+ case 13: return [TILES.tee, Math.PI]; // missing E
144
+ case 14: return [TILES.tee, -H]; // missing N
145
+ case 5: // N-S straight
146
+ if (interMask & 1) return [TILES.walk, 0];
147
+ if (interMask & 4) return [TILES.walk, Math.PI];
148
+ return [straight, 0];
149
+ case 10: // E-W straight
150
+ if (interMask & 2) return [TILES.walk, -H];
151
+ if (interMask & 8) return [TILES.walk, H];
152
+ return [straight, H];
153
+ case 1: return [TILES.dead, Math.PI]; // open N
154
+ case 2: return [TILES.dead, H]; // open E
155
+ case 4: return [TILES.dead, 0]; // open S
156
+ case 8: return [TILES.dead, -H]; // open W
157
+ default: return mask ? [TILES.cross, 0] : [straight, 0]; // corners + orphan
158
+ }
159
+ }
160
+
161
+ export class Roads {
162
+ constructor(scene, quality, city = null) {
163
+ this.scene = scene;
164
+ this.quality = quality;
165
+ this.city = city;
166
+ this.roadCellSet = new Set(); // ground/others may consult this
167
+ this.atlasTexture = paintAtlas();
168
+
169
+ this.group = new THREE.Group();
170
+ this.group.matrixAutoUpdate = false;
171
+ scene.add(this.group);
172
+
173
+ // ---- road/plaza/ramp quads: ONE InstancedMesh, per-instance uv offset --
174
+ const quadGeo = new THREE.PlaneGeometry(CELL, CELL).rotateX(-Math.PI / 2);
175
+ this._uvOffset = new THREE.InstancedBufferAttribute(new Float32Array(QMAX * 2), 2);
176
+ quadGeo.setAttribute("aUvOffset", this._uvOffset);
177
+ const quadMat = new THREE.MeshStandardMaterial({
178
+ map: this.atlasTexture, roughness: 0.96, metalness: 0, envMapIntensity: 0.25,
179
+ });
180
+ quadMat.onBeforeCompile = (sh) => {
181
+ sh.vertexShader = sh.vertexShader
182
+ .replace("#include <common>", "#include <common>\nattribute vec2 aUvOffset;")
183
+ .replace("#include <uv_vertex>", "#include <uv_vertex>\nvMapUv = uv * vec2(0.25, 0.5) + aUvOffset;");
184
+ };
185
+ this.quads = new THREE.InstancedMesh(quadGeo, quadMat, QMAX);
186
+ this.quads.receiveShadow = true;
187
+ this.quads.frustumCulled = false;
188
+ this.quads.count = 0;
189
+ this.group.add(this.quads);
190
+ this._quadInfo = []; // per-instance { x, y, z, yaw, pitch } for reveal recompose
191
+
192
+ // ---- sidewalk curbs ----------------------------------------------------
193
+ const curbGeo = new THREE.BoxGeometry(CELL * 0.98, 0.12, 0.55);
194
+ const curbMat = new THREE.MeshStandardMaterial({
195
+ color: PALETTE.sidewalk, roughness: 0.92, metalness: 0, envMapIntensity: 0.25,
196
+ });
197
+ this.curbs = new THREE.InstancedMesh(curbGeo, curbMat, CURB_MAX);
198
+ this.curbs.receiveShadow = true;
199
+ this.curbs.frustumCulled = false;
200
+ this.curbs.count = 0;
201
+ this.group.add(this.curbs);
202
+
203
+ // ---- bridge decks + railings ------------------------------------------
204
+ const deckGeo = new THREE.BoxGeometry(CELL, ROAD_Y.deck - 0.02, CELL);
205
+ const deckMat = new THREE.MeshStandardMaterial({
206
+ color: "#B9B4A6", roughness: 0.9, metalness: 0, envMapIntensity: 0.25,
207
+ });
208
+ this.decks = new THREE.InstancedMesh(deckGeo, deckMat, DECK_MAX);
209
+ this.decks.frustumCulled = false;
210
+ this.decks.count = 0;
211
+ this.group.add(this.decks);
212
+ const railGeo = new THREE.BoxGeometry(CELL * 1.02, 0.42, 0.1);
213
+ const railMat = new THREE.MeshStandardMaterial({
214
+ color: "#E8E4D8", roughness: 0.85, metalness: 0, envMapIntensity: 0.25,
215
+ });
216
+ this.rails = new THREE.InstancedMesh(railGeo, railMat, RAIL_MAX);
217
+ this.rails.frustumCulled = false;
218
+ this.rails.count = 0;
219
+ this.group.add(this.rails);
220
+
221
+ // ---- lamp posts: pole + emissive head + night light-pool quad ----------
222
+ const poleGeo = new THREE.CylinderGeometry(0.045, 0.06, 2.7, 6).translate(0, 1.35, 0);
223
+ const poleMat = new THREE.MeshStandardMaterial({
224
+ color: "#3E444E", roughness: 0.7, metalness: 0.2, envMapIntensity: 0.25,
225
+ });
226
+ this.lampPoles = new THREE.InstancedMesh(poleGeo, poleMat, LAMP_MAX);
227
+ this.lampPoles.frustumCulled = false;
228
+ this.lampPoles.count = 0;
229
+ this.group.add(this.lampPoles);
230
+ const headGeo = new THREE.BoxGeometry(0.5, 0.09, 0.15).translate(0.22, 2.66, 0);
231
+ this._headMat = new THREE.MeshStandardMaterial({
232
+ color: "#4A505A", roughness: 0.6, metalness: 0.2,
233
+ emissive: new THREE.Color(EMISSIVE.streetlamp), emissiveIntensity: 0,
234
+ });
235
+ this.lampHeads = new THREE.InstancedMesh(headGeo, this._headMat, LAMP_MAX);
236
+ this.lampHeads.frustumCulled = false;
237
+ this.lampHeads.count = 0;
238
+ this.group.add(this.lampHeads);
239
+ const poolGeo = new THREE.PlaneGeometry(3.2, 3.2).rotateX(-Math.PI / 2);
240
+ this._poolMat = new THREE.ShaderMaterial({
241
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
242
+ uniforms: { uDaylight: { value: 1 } },
243
+ vertexShader: /* glsl */ `
244
+ varying vec2 vUv;
245
+ void main() {
246
+ vUv = uv;
247
+ #ifdef USE_INSTANCING
248
+ gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
249
+ #else
250
+ gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
251
+ #endif
252
+ }`,
253
+ fragmentShader: /* glsl */ `
254
+ uniform float uDaylight;
255
+ varying vec2 vUv;
256
+ void main() {
257
+ float night = 1.0 - smoothstep(0.18, 0.5, uDaylight);
258
+ float r = length(vUv - 0.5) * 2.0;
259
+ float g = max(0.0, 1.0 - r);
260
+ gl_FragColor = vec4(vec3(1.0, 0.85, 0.63) * g * g * 0.55 * night, 1.0);
261
+ }`,
262
+ });
263
+ this.lampPools = new THREE.InstancedMesh(poolGeo, this._poolMat, LAMP_MAX);
264
+ this.lampPools.frustumCulled = false;
265
+ this.lampPools.renderOrder = 3;
266
+ this.lampPools.count = 0;
267
+ this.group.add(this.lampPools);
268
+
269
+ this._eventQuads = new Map(); // eventId -> [quad instance indices, laid order]
270
+ this._reveals = [];
271
+ this._daylight = 1;
272
+ this._seenVersion = -1;
273
+ this._seenEvents = -1;
274
+ }
275
+
276
+ _writeQuad(i, s) {
277
+ const r = this._quadInfo[i];
278
+ _e.set(r.pitch, r.yaw, 0, "YXZ");
279
+ _q.setFromEuler(_e);
280
+ _p.set(r.x, r.y, r.z);
281
+ _s.set(s, 1, s * (r.pitch ? 1.01 : 1)); // ramps stretch to meet the deck
282
+ _m4.compose(_p, _q, _s);
283
+ this.quads.setMatrixAt(i, _m4);
284
+ }
285
+
286
+ /**
287
+ * Full rebuild from the City maps. roadCells: Map cellKey -> {klass, name,
288
+ * bridge, eventId}; occupancy (optional): Map cellKey -> type, used for
289
+ * sidewalk curbs next to zoned cells + plaza paving. Idempotent, call on
290
+ * every road OR building mutation (auto-called from update() when a city
291
+ * reference was passed to the constructor).
292
+ */
293
+ rebuild(roadCells, occupancy = null) {
294
+ const recs = new Map(); // key -> { cx, cz, rec }
295
+ this.roadCellSet.clear();
296
+ for (const [key, rec] of roadCells) {
297
+ const [cx, cz] = key.split(",").map(Number);
298
+ recs.set(key, { cx, cz, rec });
299
+ this.roadCellSet.add(key);
300
+ }
301
+ const maskOf = (cx, cz, pred) => {
302
+ let m = 0;
303
+ for (const [dx, dz, bit] of DIRS) {
304
+ const n = recs.get(cellKey(cx + dx, cz + dz));
305
+ if (n && (!pred || pred(n))) m |= bit;
306
+ }
307
+ return m;
308
+ };
309
+
310
+ let qi = 0, ci = 0, di = 0, ri = 0, li = 0;
311
+ this._quadInfo.length = 0;
312
+ this._eventQuads.clear();
313
+ const zoned = (key) => {
314
+ const v = occupancy ? occupancy.get(key) : undefined;
315
+ return v === "building" || v === "park" || v === "plaza";
316
+ };
317
+ const addQuad = (x, y, z, yaw, pitch, tile, eventId) => {
318
+ if (qi >= QMAX) return;
319
+ this._quadInfo[qi] = { x, y, z, yaw, pitch };
320
+ const [u, v] = uvOf(tile);
321
+ this._uvOffset.setXY(qi, u, v);
322
+ this._writeQuad(qi, 1);
323
+ if (eventId) {
324
+ let list = this._eventQuads.get(eventId);
325
+ if (!list) this._eventQuads.set(eventId, (list = []));
326
+ list.push(qi);
327
+ }
328
+ qi++;
329
+ };
330
+
331
+ for (const { cx, cz, rec } of recs.values()) {
332
+ const x = cx * CELL + CELL / 2, z = cz * CELL + CELL / 2;
333
+ const mask = maskOf(cx, cz);
334
+ const interMask = maskOf(cx, cz, (n) => {
335
+ let c = 0;
336
+ for (const [dx, dz] of DIRS) if (recs.has(cellKey(n.cx + dx, n.cz + dz))) c++;
337
+ return c >= 3;
338
+ });
339
+ const [tile, yaw] = tileFor(mask, rec.klass, interMask);
340
+
341
+ let bridgeNbr = null;
342
+ if (!rec.bridge) {
343
+ for (const [dx, dz] of DIRS) {
344
+ const n = recs.get(cellKey(cx + dx, cz + dz));
345
+ if (n && n.rec.bridge) { bridgeNbr = [dx, dz]; break; }
346
+ }
347
+ }
348
+ if (rec.bridge) {
349
+ addQuad(x, ROAD_Y.deck, z, yaw, 0, tile, rec.eventId);
350
+ if (di < DECK_MAX) {
351
+ _m4.identity().setPosition(x, 0.02 + (ROAD_Y.deck - 0.02) / 2 - 0.01, z);
352
+ this.decks.setMatrixAt(di++, _m4);
353
+ }
354
+ // railings flank the travel axis (E/W neighbors -> deck runs east-west)
355
+ const axisEW = (mask & 2) !== 0 || (mask & 8) !== 0;
356
+ for (const side of [-1, 1]) {
357
+ if (ri >= RAIL_MAX) break;
358
+ _e.set(0, axisEW ? 0 : Math.PI / 2, 0);
359
+ _q.setFromEuler(_e);
360
+ const off = (CELL / 2 - 0.08) * side;
361
+ _p.set(x + (axisEW ? 0 : off), ROAD_Y.deck + 0.21, z + (axisEW ? off : 0));
362
+ _m4.compose(_p, _q, _s.set(1, 1, 1));
363
+ this.rails.setMatrixAt(ri++, _m4);
364
+ }
365
+ } else if (bridgeNbr) {
366
+ // sloped ramp quad rising toward the bridge neighbor
367
+ const rampYaw = Math.atan2(-bridgeNbr[0], -bridgeNbr[1]);
368
+ addQuad(x, (ROAD_Y.road + ROAD_Y.deck) / 2, z, rampYaw, RAMP_PITCH, tile, rec.eventId);
369
+ } else {
370
+ addQuad(x, ROAD_Y.road, z, yaw, 0, tile, rec.eventId);
371
+ }
372
+
373
+ // curbs on edges that face zoned cells
374
+ for (const [dx, dz] of DIRS) {
375
+ if (ci >= CURB_MAX || rec.bridge) break;
376
+ if (!zoned(cellKey(cx + dx, cz + dz))) continue;
377
+ const along = dz !== 0 ? 0 : Math.PI / 2; // curb runs perpendicular to dir
378
+ _e.set(0, along, 0);
379
+ _q.setFromEuler(_e);
380
+ _p.set(x + dx * (CELL / 2 - 0.3), 0, z + dz * (CELL / 2 - 0.3));
381
+ _m4.compose(_p, _q, _s.set(1, 1, 1));
382
+ this.curbs.setMatrixAt(ci++, _m4);
383
+ }
384
+
385
+ // lamp posts every 3rd road cell, straights only, alternating sides
386
+ const isStraight = mask === 5 || mask === 10;
387
+ if (isStraight && li < LAMP_MAX && ((cx + cz * 2) % 3 + 3) % 3 === 0 && !rec.bridge) {
388
+ const side = (cx + cz) & 1 ? 1 : -1;
389
+ const ns = mask === 5;
390
+ const lx = ns ? x + side * 1.55 : x;
391
+ const lz = ns ? z : z + side * 1.55;
392
+ const yawL = ns ? (side > 0 ? Math.PI : 0) : (side > 0 ? -Math.PI / 2 : Math.PI / 2);
393
+ _e.set(0, yawL, 0);
394
+ _q.setFromEuler(_e);
395
+ _p.set(lx, 0, lz);
396
+ _m4.compose(_p, _q, _s.set(1, 1, 1));
397
+ this.lampPoles.setMatrixAt(li, _m4);
398
+ this.lampHeads.setMatrixAt(li, _m4);
399
+ // light pool sits under the head, pushed toward the roadway
400
+ const px = ns ? lx - side * 0.55 : lx;
401
+ const pz = ns ? lz : lz - side * 0.55;
402
+ _m4.identity().setPosition(px, 0.045, pz);
403
+ this.lampPools.setMatrixAt(li, _m4);
404
+ li++;
405
+ }
406
+ }
407
+
408
+ // plaza paving quads from occupancy (atlas plaza tile)
409
+ if (occupancy) {
410
+ for (const [key, v] of occupancy) {
411
+ if (v !== "plaza" || this.roadCellSet.has(key)) continue;
412
+ const [cx, cz] = key.split(",").map(Number);
413
+ addQuad(cx * CELL + CELL / 2, ROAD_Y.road, cz * CELL + CELL / 2, 0, 0, TILES.plaza, null);
414
+ }
415
+ }
416
+
417
+ this.quads.count = qi;
418
+ this.curbs.count = ci;
419
+ this.decks.count = di;
420
+ this.rails.count = ri;
421
+ this.lampPoles.count = li;
422
+ this.lampHeads.count = li;
423
+ this.lampPools.count = li;
424
+ this._uvOffset.needsUpdate = true;
425
+ for (const m of [this.quads, this.curbs, this.decks, this.rails,
426
+ this.lampPoles, this.lampHeads, this.lampPools]) m.instanceMatrix.needsUpdate = true;
427
+ // a reveal in flight keeps its cells collapsed across rebuilds
428
+ for (const rv of this._reveals) {
429
+ rv.idxs = this._eventQuads.get(rv.eventId) || [];
430
+ for (const i of rv.idxs) this._writeQuad(i, 0.0001);
431
+ }
432
+ }
433
+
434
+ /** Paint-in hook: sweep the event's cells from scale 0 over 4 s of simTime. */
435
+ revealRoad(eventId, t0) {
436
+ const idxs = this._eventQuads.get(eventId);
437
+ if (!idxs || !idxs.length) return;
438
+ for (const i of idxs) this._writeQuad(i, 0.0001);
439
+ this.quads.instanceMatrix.needsUpdate = true;
440
+ this._reveals.push({ eventId, t0, idxs });
441
+ }
442
+
443
+ /** 0 = night, 1 = day. Lamp heads + light pools react. */
444
+ setDaylight(d) {
445
+ this._daylight = clamp(d, 0, 1);
446
+ this._headMat.emissiveIntensity = (1 - smoothNight(this._daylight)) * 2.4;
447
+ this._poolMat.uniforms.uDaylight.value = this._daylight;
448
+ }
449
+
450
+ update(dt, simTime) {
451
+ if (this.city) {
452
+ if (this.city.roadVersion !== this._seenVersion || this.city.events.length !== this._seenEvents) {
453
+ this._seenVersion = this.city.roadVersion;
454
+ this._seenEvents = this.city.events.length;
455
+ this.rebuild(this.city.roadCells, this.city.occupancy);
456
+ }
457
+ if (typeof this.city.daylightValue === "number") this.setDaylight(this.city.daylightValue);
458
+ }
459
+ if (!this._reveals.length) return;
460
+ for (let r = this._reveals.length - 1; r >= 0; r--) {
461
+ const rv = this._reveals[r];
462
+ const n = Math.max(rv.idxs.length, 1);
463
+ for (let k = 0; k < rv.idxs.length; k++) {
464
+ const start = (k / n) * (REVEAL_S - REVEAL_POP);
465
+ const s = easeOutBack(clamp((simTime - rv.t0 - start) / REVEAL_POP, 0, 1));
466
+ this._writeQuad(rv.idxs[k], Math.max(s, 0.0001));
467
+ }
468
+ if (simTime - rv.t0 >= REVEAL_S) {
469
+ for (const i of rv.idxs) this._writeQuad(i, 1);
470
+ this._reveals.splice(r, 1);
471
+ }
472
+ }
473
+ this.quads.instanceMatrix.needsUpdate = true;
474
+ }
475
+ }
476
+
477
+ // daylight 1 -> 0 night ramp shared by lamp intensity
478
+ function smoothNight(d) {
479
+ const t = clamp((d - 0.18) / (0.5 - 0.18), 0, 1);
480
+ return t * t * (3 - 2 * t);
481
+ }
web/city/scene.js ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — scene bootstrap shared by index (live) and replay/timelapse.
2
+ // Fixed-step simulation decoupled from rAF (headless Chromium throttles rAF —
3
+ // hard-won glassblower learning). Golden Hour Toybox post chain: RenderPass ->
4
+ // UnrealBloomPass (daylight-lerped) -> tilt-shift H -> tilt-shift V (+ grade)
5
+ // -> OutputPass, on an MSAA HalfFloat target. The camera rig is installed by
6
+ // main.js via setRig(); FPV swaps in via setFPV() (rig-swap hook).
7
+ import * as THREE from "three";
8
+ import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
9
+ import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
10
+ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
11
+ import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
12
+ import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
13
+ import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
14
+ import { City } from "./world.js";
15
+ import { SKY, FOG } from "./palette.js";
16
+ import { clamp, lerp } from "./util.js";
17
+
18
+ const STEP = 1 / 60;
19
+
20
+ // bloom day/night keyframes (strength, radius, threshold) — pastel facades must
21
+ // NOT glow in daylight; night windows/neon do the talking.
22
+ const BLOOM_DAY = [0.32, 0.35, 0.85];
23
+ const BLOOM_NIGHT = [0.70, 0.45, 0.62];
24
+ const EXPOSURE_DAY = 1.05;
25
+ const EXPOSURE_NIGHT = 0.92;
26
+
27
+ export function detectQuality() {
28
+ const small = Math.min(window.innerWidth, window.innerHeight) < 700
29
+ || /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent)
30
+ || (navigator.hardwareConcurrency || 8) <= 4;
31
+ return {
32
+ small,
33
+ instances: small ? 0.55 : 1,
34
+ pixelRatio: Math.min(window.devicePixelRatio || 1, small ? 1.7 : 2),
35
+ shadowMapSize: small ? 1024 : 2048,
36
+ msaaSamples: small ? 2 : 4,
37
+ };
38
+ }
39
+
40
+ // Classic separable 9-tap gaussian tilt-shift. Per-fragment radius grows from
41
+ // zero inside the focus band: maxBlur * smoothstep(bandHalf, bandHalf+falloff,
42
+ // abs(vUv.y - focusY)). The V pass folds in the color grade (saturation +8%,
43
+ // warm shadow lift +0.02, vignette 0.25/0.75) so it runs even at amount 0.
44
+ function tiltShiftShader(horizontal) {
45
+ const dir = horizontal ? "vec2(1.0, 0.0)" : "vec2(0.0, 1.0)";
46
+ const grade = horizontal ? "" : /* glsl */ `
47
+ float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
48
+ c = mix(vec3(l), c, 1.08);
49
+ c += vec3(0.020, 0.012, 0.004) * (1.0 - l);
50
+ float d = length(vUv - 0.5);
51
+ c *= 1.0 - 0.25 * smoothstep(0.25, 0.75, d);`;
52
+ return {
53
+ uniforms: {
54
+ tDiffuse: { value: null },
55
+ resolution: { value: new THREE.Vector2(1, 1) }, // device pixels
56
+ focusY: { value: 0.45 },
57
+ amount: { value: 1.0 },
58
+ maxBlur: { value: 2.2 }, // px, scaled by pixelRatio at setup
59
+ },
60
+ vertexShader: /* glsl */ `
61
+ varying vec2 vUv;
62
+ void main() {
63
+ vUv = uv;
64
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
65
+ }`,
66
+ fragmentShader: /* glsl */ `
67
+ uniform sampler2D tDiffuse;
68
+ uniform vec2 resolution;
69
+ uniform float focusY, amount, maxBlur;
70
+ varying vec2 vUv;
71
+ void main() {
72
+ float r = maxBlur * amount * smoothstep(0.13, 0.35, abs(vUv.y - focusY));
73
+ vec2 o = ${dir} * (r / resolution);
74
+ vec3 c = vec3(0.0);
75
+ c += texture2D(tDiffuse, vUv - o * 4.0).rgb * 0.0510;
76
+ c += texture2D(tDiffuse, vUv - o * 3.0).rgb * 0.0918;
77
+ c += texture2D(tDiffuse, vUv - o * 2.0).rgb * 0.1200;
78
+ c += texture2D(tDiffuse, vUv - o * 1.0).rgb * 0.1531;
79
+ c += texture2D(tDiffuse, vUv).rgb * 0.1633;
80
+ c += texture2D(tDiffuse, vUv + o * 1.0).rgb * 0.1531;
81
+ c += texture2D(tDiffuse, vUv + o * 2.0).rgb * 0.1200;
82
+ c += texture2D(tDiffuse, vUv + o * 3.0).rgb * 0.0918;
83
+ c += texture2D(tDiffuse, vUv + o * 4.0).rgb * 0.0510;
84
+ ${grade}
85
+ gl_FragColor = vec4(c, 1.0);
86
+ }`,
87
+ };
88
+ }
89
+
90
+ export function createCityApp(canvas, { interactive = true } = {}) {
91
+ const quality = detectQuality();
92
+ const renderer = new THREE.WebGLRenderer({
93
+ canvas, antialias: false, alpha: false, powerPreference: "high-performance",
94
+ });
95
+ renderer.setPixelRatio(quality.pixelRatio);
96
+ renderer.setSize(window.innerWidth, window.innerHeight);
97
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
98
+ renderer.toneMappingExposure = EXPOSURE_DAY;
99
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
100
+ renderer.shadowMap.enabled = true;
101
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
102
+
103
+ const scene = new THREE.Scene();
104
+ scene.background = new THREE.Color(SKY.day.horizon);
105
+ scene.fog = new THREE.Fog(SKY.day.horizon, FOG.day.near, FOG.day.far); // sky.js retints per daylight
106
+
107
+ // T+0 frame IS the screenshot: low (~9 m) over Main St (cx=0 -> x=2),
108
+ // looking north up the avenue. The landing choreography (camera.js) takes
109
+ // over from this pose; until a rig is installed the frame just holds.
110
+ const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 1500);
111
+ camera.position.set(2, 9, 70);
112
+ camera.lookAt(2, 3, 0);
113
+
114
+ // subtle PMREM environment so pastel facades catch light (no external HDR)
115
+ const pmrem = new THREE.PMREMGenerator(renderer);
116
+ scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.06).texture;
117
+ pmrem.dispose();
118
+
119
+ const world = new City(scene, quality);
120
+
121
+ // ---- post chain on an MSAA HalfFloat target ----------------------------
122
+ const bufSize = renderer.getDrawingBufferSize(new THREE.Vector2());
123
+ const target = new THREE.WebGLRenderTarget(bufSize.x, bufSize.y, {
124
+ samples: quality.msaaSamples, type: THREE.HalfFloatType,
125
+ });
126
+ const composer = new EffectComposer(renderer, target);
127
+ composer.addPass(new RenderPass(scene, camera));
128
+ const bloom = new UnrealBloomPass(
129
+ new THREE.Vector2(window.innerWidth, window.innerHeight), ...BLOOM_DAY
130
+ );
131
+ composer.addPass(bloom);
132
+ const tiltH = new ShaderPass(tiltShiftShader(true));
133
+ const tiltV = new ShaderPass(tiltShiftShader(false));
134
+ for (const p of [tiltH, tiltV]) {
135
+ p.uniforms.maxBlur.value = 2.2 * quality.pixelRatio;
136
+ p.uniforms.resolution.value.copy(bufSize);
137
+ }
138
+ composer.addPass(tiltH);
139
+ composer.addPass(tiltV);
140
+ composer.addPass(new OutputPass());
141
+ composer.setSize(window.innerWidth, window.innerHeight); // normalize pass sizes
142
+
143
+ let daylight = 1;
144
+ const post = {
145
+ /** s in [0,1]: 0 = night, 1 = full day. Lerps bloom + exposure. */
146
+ setDaylight(s) {
147
+ daylight = clamp(s, 0, 1);
148
+ bloom.strength = lerp(BLOOM_NIGHT[0], BLOOM_DAY[0], daylight);
149
+ bloom.radius = lerp(BLOOM_NIGHT[1], BLOOM_DAY[1], daylight);
150
+ bloom.threshold = lerp(BLOOM_NIGHT[2], BLOOM_DAY[2], daylight);
151
+ renderer.toneMappingExposure = lerp(EXPOSURE_NIGHT, EXPOSURE_DAY, daylight);
152
+ },
153
+ /** 1 = full miniature blur (god view), 0 = off (FPV — hard requirement). */
154
+ setTiltShift(amount) {
155
+ const a = clamp(amount, 0, 1);
156
+ tiltH.uniforms.amount.value = a;
157
+ tiltV.uniforms.amount.value = a;
158
+ },
159
+ /** Screen-space y (0 bottom .. 1 top) of the focus band — track orbit target. */
160
+ setFocusY(y) {
161
+ const v = clamp(y, 0, 1);
162
+ tiltH.uniforms.focusY.value = v;
163
+ tiltV.uniforms.focusY.value = v;
164
+ },
165
+ };
166
+
167
+ // ---- fixed-step sim ------------------------------------------------------
168
+ let simTime = 0;
169
+ let acc = 0;
170
+ let timeScale = 1; // main.js slews ±2% toward the server clock; never snaps
171
+ let lastWall = performance.now() / 1000;
172
+ let paused = false;
173
+ let rig = null; // orbit rig (camera.js), installed by main.js
174
+ let fpvController = null; // when set, it owns the camera; rig is skipped
175
+ const tickHooks = [];
176
+
177
+ function step(dt) {
178
+ simTime += dt;
179
+ for (const h of tickHooks) h(simTime, dt);
180
+ world.update(dt, simTime);
181
+ if (fpvController) fpvController.update(dt);
182
+ else if (rig) rig.update(dt);
183
+ }
184
+
185
+ function render() {
186
+ composer.render();
187
+ }
188
+
189
+ function frame() {
190
+ requestAnimationFrame(frame);
191
+ const now = performance.now() / 1000;
192
+ const dt = Math.min(now - lastWall, 0.1);
193
+ lastWall = now;
194
+ if (paused) return;
195
+ acc += dt * timeScale;
196
+ let guard = 0;
197
+ while (acc >= STEP && guard++ < 8) {
198
+ step(STEP);
199
+ acc -= STEP;
200
+ }
201
+ render();
202
+ }
203
+
204
+ function onResize() {
205
+ const w = window.innerWidth, h = window.innerHeight;
206
+ camera.aspect = w / h;
207
+ camera.updateProjectionMatrix();
208
+ renderer.setSize(w, h);
209
+ composer.setSize(w, h);
210
+ bloom.resolution.set(w, h);
211
+ renderer.getDrawingBufferSize(bufSize);
212
+ tiltH.uniforms.resolution.value.copy(bufSize);
213
+ tiltV.uniforms.resolution.value.copy(bufSize);
214
+ }
215
+ window.addEventListener("resize", onResize);
216
+
217
+ return {
218
+ scene, camera, renderer, composer, bloom, world, quality, post, interactive,
219
+ get simTime() { return simTime; },
220
+ get rig() { return rig; },
221
+ get fpv() { return fpvController; },
222
+ /** Install the orbit rig (camera.js). scene.js never imports wave-2 files. */
223
+ setRig(r) { rig = r; },
224
+ /** Rig-swap hook: pass an FPV controller to own the camera, null to exit. */
225
+ setFPV(controllerOrNull) { fpvController = controllerOrNull; },
226
+ onTick(fn) { tickHooks.push(fn); },
227
+ offTick(fn) {
228
+ const i = tickHooks.indexOf(fn);
229
+ if (i >= 0) tickHooks.splice(i, 1);
230
+ },
231
+ start() { paused = false; requestAnimationFrame(frame); },
232
+ pause(v = true) { paused = v; },
233
+ /** ±2% clock slew lives here: scale wall dt feeding the fixed-step loop. */
234
+ setTimeScale(k) { timeScale = clamp(k, 0.5, 2); },
235
+ /** Hard clock set (boot only). City state is a pure fn of simTime — safe. */
236
+ setSimTime(t) { simTime = Math.max(0, t); },
237
+ /** advance the sim by `seconds` in fixed steps, then render once (headless-safe) */
238
+ tick(seconds = STEP) {
239
+ const n = Math.max(1, Math.round(seconds / STEP));
240
+ for (let i = 0; i < n; i++) step(STEP);
241
+ render();
242
+ },
243
+ /** Deterministic seek to an absolute sim time. Jumps (pure-fn motion makes
244
+ * that exact), then runs 1 s of fixed steps so iterative presentation
245
+ * (traffic CA, congestion EMA) settles to its steady state at t. */
246
+ seek(t) {
247
+ paused = true;
248
+ t = Math.max(0, t);
249
+ const lead = Math.min(t, 1);
250
+ simTime = t - lead;
251
+ const n = Math.round(lead / STEP);
252
+ for (let i = 0; i < n; i++) step(STEP);
253
+ render();
254
+ },
255
+ renderOnce: render,
256
+ stats() {
257
+ return {
258
+ simTime: +simTime.toFixed(2),
259
+ calls: renderer.info.render.calls,
260
+ triangles: renderer.info.render.triangles,
261
+ features: world.events.length,
262
+ roadCells: world.roadCells.size,
263
+ daylight: +daylight.toFixed(2),
264
+ camHeight: +camera.position.y.toFixed(1),
265
+ fpv: !!fpvController,
266
+ };
267
+ },
268
+ };
269
+ }
web/city/sky.js ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — sky, sun and the day-night cycle. Owns the ONE shadow-casting
2
+ // DirectionalLight (sun by day, moon by night — both fade to 0 at the swap so
3
+ // it never pops), the hemisphere fill, the gradient sky dome + stars, and
4
+ // scene.fog/background retinting. Everything is a pure function of simTime via
5
+ // daylight()/dayHours() — no wall clock, no unseeded randomness.
6
+ import * as THREE from "three";
7
+ import { mulberry32 } from "./rng.js";
8
+ import { GRID, CELL } from "./constants.js";
9
+ import { SKY, FOG, SUN, HEMI } from "./palette.js";
10
+ import { clamp, lerp, smoothstep, dayHours } from "./util.js";
11
+
12
+ const PLOT_HALF = (GRID * CELL) / 2; // 128 m
13
+ const SUN_DIST = 300; // light sits this far along its direction
14
+ const DOME_R = 640; // inside camera far (1500), outside fog far
15
+ const STAR_R = 615;
16
+ const STAR_COUNT = 420;
17
+
18
+ // Day window: sunrise centered 5.5 h, sunset centered 19.5 h (contract).
19
+ const DAWN = 5.5, DUSK = 19.5, DAY_LEN = DUSK - DAWN, NIGHT_LEN = 24 - DAY_LEN;
20
+ const NOON_ELEV = 62, MOON_ELEV = 44, GOLDEN_ELEV = 28;
21
+
22
+ /** daylight(simTime) in [0,1] — transitions ~1.5 h wide around dawn/dusk. */
23
+ export function daylight(simTime) {
24
+ const h = dayHours(simTime);
25
+ return smoothstep(DAWN - 0.75, DAWN + 0.75, h)
26
+ * (1 - smoothstep(DUSK - 0.75, DUSK + 0.75, h));
27
+ }
28
+
29
+ // compass azimuth (deg from north, cw) + elevation (deg) -> world unit dir
30
+ // toward the light. +X east, -Z north (contract axes).
31
+ function lightDir(azDeg, elevDeg, out) {
32
+ const a = azDeg * Math.PI / 180, e = elevDeg * Math.PI / 180;
33
+ const ce = Math.cos(e);
34
+ return out.set(ce * Math.sin(a), Math.sin(e), -ce * Math.cos(a));
35
+ }
36
+
37
+ const _dir = new THREE.Vector3();
38
+ const _fwd = new THREE.Vector3();
39
+ const _right = new THREE.Vector3();
40
+ const _upv = new THREE.Vector3();
41
+ const _off = new THREE.Vector3();
42
+ const _ca = new THREE.Color();
43
+ const _cb = new THREE.Color();
44
+ const UP = new THREE.Vector3(0, 1, 0);
45
+
46
+ export class Sky {
47
+ constructor(scene, renderer, quality) {
48
+ // tolerate (scene, quality) wiring — renderer is optional
49
+ if (renderer && renderer.shadowMap === undefined && quality === undefined) {
50
+ quality = renderer;
51
+ renderer = null;
52
+ }
53
+ this.scene = scene;
54
+ this.daylight = 1;
55
+ this._mapSize = quality?.shadowMapSize ?? 2048;
56
+
57
+ // keyframe colors (palette is the single source)
58
+ this._golden = new THREE.Color(SUN.golden.color);
59
+ this._noon = new THREE.Color(SUN.noon.color);
60
+ this._dusk = new THREE.Color(SUN.dusk.color);
61
+ this._moon = new THREE.Color(SUN.moon.color);
62
+ this._zenithDay = new THREE.Color(SKY.day.zenith);
63
+ this._zenithNight = new THREE.Color(SKY.night.zenith);
64
+ this._horizonDay = new THREE.Color(SKY.day.horizon);
65
+ this._horizonNight = new THREE.Color(SKY.night.horizon);
66
+ this._hemiSkyDay = new THREE.Color(HEMI.day.sky);
67
+ this._hemiSkyNight = new THREE.Color(HEMI.night.sky);
68
+ this._hemiGndDay = new THREE.Color(HEMI.day.ground);
69
+ this._hemiGndNight = new THREE.Color(HEMI.night.ground);
70
+
71
+ // the ONE shadow-casting DirectionalLight (contract: NO AmbientLight)
72
+ this.sun = new THREE.DirectionalLight(SUN.golden.color, SUN.golden.intensity);
73
+ this.sun.castShadow = true;
74
+ this.sun.shadow.mapSize.set(this._mapSize, this._mapSize);
75
+ this.sun.shadow.bias = -0.00035;
76
+ this.sun.shadow.normalBias = 0.5;
77
+ scene.add(this.sun);
78
+ scene.add(this.sun.target);
79
+
80
+ this.hemi = new THREE.HemisphereLight(HEMI.day.sky, HEMI.day.ground, HEMI.day.intensity);
81
+ scene.add(this.hemi);
82
+
83
+ this._buildDome();
84
+ this._buildStars();
85
+ this.refit({ minX: -PLOT_HALF, maxX: PLOT_HALF, minZ: -PLOT_HALF, maxZ: PLOT_HALF });
86
+ }
87
+
88
+ /** Ortho shadow frustum fit to city bounds +10% (re-fit only on growth).
89
+ * bounds: { minX, maxX, minZ, maxZ } in world meters (Box3 min/max also ok). */
90
+ refit(b) {
91
+ const minX = b.minX ?? b.min?.x ?? -PLOT_HALF;
92
+ const maxX = b.maxX ?? b.max?.x ?? PLOT_HALF;
93
+ const minZ = b.minZ ?? b.min?.z ?? -PLOT_HALF;
94
+ const maxZ = b.maxZ ?? b.max?.z ?? PLOT_HALF;
95
+ this._center = new THREE.Vector3((minX + maxX) / 2, 0, (minZ + maxZ) / 2);
96
+ // bounding circle covers every sun azimuth without per-frame refits;
97
+ // +24 m headroom for tall casters (tower ~48 m)
98
+ this._radius = Math.hypot((maxX - minX) / 2, (maxZ - minZ) / 2) * 1.1 + 24;
99
+ const cam = this.sun.shadow.camera;
100
+ cam.left = -this._radius;
101
+ cam.right = this._radius;
102
+ cam.top = this._radius;
103
+ cam.bottom = -this._radius;
104
+ cam.near = Math.max(1, SUN_DIST - this._radius - 60);
105
+ cam.far = SUN_DIST + this._radius + 60;
106
+ cam.updateProjectionMatrix();
107
+ }
108
+
109
+ _buildDome() {
110
+ const geo = new THREE.SphereGeometry(DOME_R, 32, 16);
111
+ this._domeMat = new THREE.ShaderMaterial({
112
+ side: THREE.BackSide,
113
+ depthWrite: false,
114
+ uniforms: {
115
+ uZenith: { value: this._zenithDay.clone() },
116
+ uHorizon: { value: this._horizonDay.clone() },
117
+ uSunDir: { value: new THREE.Vector3(0, 1, 0) },
118
+ uGlowColor: { value: this._golden.clone() },
119
+ uGlow: { value: 0 },
120
+ },
121
+ vertexShader: /* glsl */ `
122
+ varying vec3 vDir;
123
+ void main() {
124
+ vDir = position;
125
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
126
+ }`,
127
+ fragmentShader: /* glsl */ `
128
+ uniform vec3 uZenith, uHorizon, uSunDir, uGlowColor;
129
+ uniform float uGlow;
130
+ varying vec3 vDir;
131
+ void main() {
132
+ vec3 d = normalize(vDir);
133
+ float t = pow(clamp(d.y, 0.0, 1.0), 0.62);
134
+ vec3 c = mix(uHorizon, uZenith, t);
135
+ // warm bloom around a low sun — the golden-hour horizon
136
+ float g = uGlow * pow(max(dot(d, uSunDir), 0.0), 8.0) * (1.0 - t);
137
+ c += uGlowColor * g;
138
+ gl_FragColor = vec4(c, 1.0);
139
+ }`,
140
+ });
141
+ this.dome = new THREE.Mesh(geo, this._domeMat);
142
+ this.dome.matrixAutoUpdate = false;
143
+ this.dome.frustumCulled = false;
144
+ this.scene.add(this.dome);
145
+ }
146
+
147
+ _buildStars() {
148
+ const rng = mulberry32(20260612);
149
+ const pos = new Float32Array(STAR_COUNT * 3);
150
+ const col = new Float32Array(STAR_COUNT * 3);
151
+ for (let i = 0; i < STAR_COUNT; i++) {
152
+ const a = rng() * Math.PI * 2;
153
+ const y = 0.06 + rng() * 0.94; // upper hemisphere only
154
+ const r = Math.sqrt(Math.max(0, 1 - y * y));
155
+ pos[i * 3] = Math.cos(a) * r * STAR_R;
156
+ pos[i * 3 + 1] = y * STAR_R;
157
+ pos[i * 3 + 2] = Math.sin(a) * r * STAR_R;
158
+ const b = 0.45 + rng() * 0.55;
159
+ col[i * 3] = b;
160
+ col[i * 3 + 1] = b;
161
+ col[i * 3 + 2] = Math.min(1, b * (1 + rng() * 0.18)); // faint blue cast
162
+ }
163
+ const geo = new THREE.BufferGeometry();
164
+ geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
165
+ geo.setAttribute("color", new THREE.BufferAttribute(col, 3));
166
+ this._starMat = new THREE.PointsMaterial({
167
+ size: 1.7, sizeAttenuation: false, vertexColors: true,
168
+ transparent: true, opacity: 0, depthWrite: false, fog: false,
169
+ });
170
+ this.stars = new THREE.Points(geo, this._starMat);
171
+ this.stars.matrixAutoUpdate = false;
172
+ this.stars.frustumCulled = false;
173
+ this.stars.visible = false;
174
+ this.scene.add(this.stars);
175
+ }
176
+
177
+ /** Place the light along dir and texel-snap the frustum center so shadow
178
+ * edges don't shimmer as the frustum translates. */
179
+ _placeSun(azDeg, elevDeg) {
180
+ lightDir(azDeg, elevDeg, _dir);
181
+ const c = this._center;
182
+ this.sun.position.set(
183
+ c.x + _dir.x * SUN_DIST, _dir.y * SUN_DIST, c.z + _dir.z * SUN_DIST
184
+ );
185
+ this.sun.target.position.set(c.x, 0, c.z);
186
+ _fwd.copy(_dir).negate();
187
+ _right.crossVectors(_fwd, UP).normalize();
188
+ _upv.crossVectors(_right, _fwd);
189
+ const texel = (2 * this._radius) / this._mapSize;
190
+ const sx = this.sun.target.position.dot(_right);
191
+ const sy = this.sun.target.position.dot(_upv);
192
+ _off.copy(_right).multiplyScalar(Math.round(sx / texel) * texel - sx)
193
+ .addScaledVector(_upv, Math.round(sy / texel) * texel - sy);
194
+ this.sun.position.add(_off);
195
+ this.sun.target.position.add(_off);
196
+ this.sun.target.updateMatrixWorld();
197
+ }
198
+
199
+ /** Drives sun/moon, hemi, dome, stars, fog. Returns daylight in [0,1]
200
+ * (world.js stores it; main.js feeds app.post.setDaylight). */
201
+ update(dt, simTime) {
202
+ const d = daylight(simTime);
203
+ const h = dayHours(simTime);
204
+ this.daylight = d;
205
+
206
+ let elev, az, glow;
207
+ const isDay = h > DAWN && h < DUSK;
208
+ if (isDay) {
209
+ const u = (h - DAWN) / DAY_LEN;
210
+ elev = NOON_ELEV * Math.sin(Math.PI * u);
211
+ az = 100 + 160 * u; // east -> south -> west; az 235 at evening golden hour
212
+ if (elev < GOLDEN_ELEV) {
213
+ const k = smoothstep(6, GOLDEN_ELEV, elev);
214
+ _ca.lerpColors(this._dusk, this._golden, k);
215
+ this.sun.intensity = lerp(SUN.dusk.intensity, SUN.golden.intensity, k);
216
+ } else {
217
+ const k = smoothstep(GOLDEN_ELEV, NOON_ELEV, elev);
218
+ _ca.lerpColors(this._golden, this._noon, k);
219
+ this.sun.intensity = lerp(SUN.golden.intensity, SUN.noon.intensity, k);
220
+ }
221
+ this.sun.intensity *= smoothstep(0, 5, elev); // fade out at the horizon
222
+ this.sun.color.copy(_ca);
223
+ glow = 0.55 * (1 - smoothstep(2, 30, elev)) * smoothstep(0, 2, elev);
224
+ } else {
225
+ const u = (((h - DUSK) + 24) % 24) / NIGHT_LEN;
226
+ elev = MOON_ELEV * Math.sin(Math.PI * u);
227
+ az = 100 + 160 * u;
228
+ this.sun.color.copy(this._moon);
229
+ this.sun.intensity = SUN.moon.intensity * smoothstep(0, 6, elev);
230
+ glow = 0;
231
+ }
232
+ this._placeSun(az, Math.max(elev, 0.5));
233
+
234
+ // hemisphere fill day -> night
235
+ this.hemi.color.lerpColors(this._hemiSkyNight, this._hemiSkyDay, d);
236
+ this.hemi.groundColor.lerpColors(this._hemiGndNight, this._hemiGndDay, d);
237
+ this.hemi.intensity = lerp(HEMI.night.intensity, HEMI.day.intensity, d);
238
+
239
+ // dome gradient + sun glow
240
+ const u = this._domeMat.uniforms;
241
+ u.uZenith.value.lerpColors(this._zenithNight, this._zenithDay, d);
242
+ u.uHorizon.value.lerpColors(this._horizonNight, this._horizonDay, d);
243
+ lightDir(az, Math.max(elev, 0), u.uSunDir.value);
244
+ u.uGlowColor.value.copy(this.sun.color);
245
+ u.uGlow.value = glow;
246
+
247
+ // stars fade in as daylight dies
248
+ const starOp = 0.9 * (1 - smoothstep(0.08, 0.45, d));
249
+ this._starMat.opacity = starOp;
250
+ this.stars.visible = starOp > 0.02;
251
+
252
+ // fog + background track the blended horizon (contract: fog = horizon color)
253
+ _cb.lerpColors(this._horizonNight, this._horizonDay, d);
254
+ if (this.scene.fog) {
255
+ this.scene.fog.color.copy(_cb);
256
+ this.scene.fog.near = lerp(FOG.night.near, FOG.day.near, d);
257
+ this.scene.fog.far = lerp(FOG.night.far, FOG.day.far, d);
258
+ }
259
+ if (this.scene.background?.isColor) this.scene.background.copy(_cb);
260
+
261
+ return d;
262
+ }
263
+ }
web/city/traffic.js ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — traffic. The client-side presentation mirror of engine/traffic.py:
2
+ // road graph from 4-adjacency, gravity trip generation from event seeds,
3
+ // congestion-aware A* with a per-road_version path cache, and a 10 Hz queueing
4
+ // cellular automaton (scalar progress per car, capacity holds, deterministic
5
+ // priority = lower car index). Congestion EMA (tau 5 s) feeds the Traffic
6
+ // Index, the heatmap overlay, jam underglow strips and the City Engineer
7
+ // alert. carStates() interpolates the 10 Hz state for 60 fps rendering.
8
+ // All randomness is mulberry32 from event seeds; ticking is fixed-step.
9
+ import * as THREE from "three";
10
+ import {
11
+ GRID, CELL, CITY_EPOCH_S, BUILDINGS, ROAD_CLASSES,
12
+ RUSH_SIGMA, RUSH_BASE, RUSH_AMP, CAR_RATE, CAR_MIN, CAR_MAX,
13
+ } from "./constants.js";
14
+ import { clamp, lerp, bell, dayHours } from "./util.js";
15
+ import { mulberry32 } from "./rng.js";
16
+ import { EMISSIVE } from "./palette.js";
17
+ import { ROAD_Y } from "./roads.js";
18
+
19
+ const HALF = GRID / 2;
20
+ const NCELL = GRID * GRID;
21
+ const TICK = 0.1; // 10 Hz CA
22
+ const TAU = 5; // congestion EMA time constant (s)
23
+ const QCAP = 768; // overlay instances (contract: road cells <= 600)
24
+ const cid = (cx, cz) => (cx + HALF) + (cz + HALF) * GRID;
25
+ const idX = (id) => (id % GRID) - HALF;
26
+ const idZ = (id) => ((id / GRID) | 0) - HALF;
27
+ const idKey = (id) => `${idX(id)},${idZ(id)}`;
28
+ const px = (id) => idX(id) * CELL + CELL / 2;
29
+ const pz = (id) => idZ(id) * CELL + CELL / 2;
30
+
31
+ const _m4 = new THREE.Matrix4();
32
+ const _c = new THREE.Color();
33
+ const COL_GREEN = new THREE.Color(EMISSIVE.signalGreen);
34
+ const COL_AMBER = new THREE.Color(EMISSIVE.signalAmber);
35
+ const COL_RED = new THREE.Color(EMISSIVE.congestion);
36
+
37
+ /** place_building features -> [{kind, name, seed, t, cx, cz, w, d, def, x, z}]. */
38
+ export function buildingsFromEvents(events) {
39
+ const out = [];
40
+ for (const f of events || []) {
41
+ if (!f || f.tool !== "place_building" || !f.args) continue;
42
+ const a = f.args;
43
+ const def = BUILDINGS[a.kind];
44
+ if (!def || a.cx == null || a.cz == null) continue;
45
+ const w = a.w ?? def.w, d = a.d ?? def.d;
46
+ out.push({
47
+ kind: a.kind, name: a.name || "", seed: (f.seed ?? 0) >>> 0, t: f.t ?? CITY_EPOCH_S,
48
+ cx: a.cx, cz: a.cz, w, d, def,
49
+ x: a.cx + (w - 1) / 2, z: a.cz + (d - 1) / 2, // footprint center, cell units
50
+ });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ export const buildProgress = (b, simTime) =>
56
+ clamp((CITY_EPOCH_S + simTime - b.t) / Math.max(b.def.duration, 1), 0, 1);
57
+
58
+ /** population = sum residents * build_progress over place_building events. */
59
+ export function computePopulation(events, simTime) {
60
+ let pop = 0;
61
+ for (const b of buildingsFromEvents(events)) pop += b.def.residents * buildProgress(b, simTime);
62
+ return pop;
63
+ }
64
+
65
+ /**
66
+ * Clamped quadratic B-spline through xyz control triplets in `pts` (n points).
67
+ * u in [0, n-1]; fills outPos {x,y,z} and outTan {x,z} (unnormalized).
68
+ * Shared by cars (cell-center paths) and citizens (sidewalk polylines).
69
+ */
70
+ export function pathPoint(pts, n, u, outPos, outTan) {
71
+ const seg = clamp(Math.floor(u), 0, Math.max(n - 2, 0));
72
+ const f = clamp(u - seg, 0, 1);
73
+ const i0 = Math.max(seg - 1, 0) * 3, i1 = seg * 3, i2 = Math.min(seg + 1, n - 1) * 3;
74
+ const w0 = 0.5 * (1 - f) * (1 - f), w2 = 0.5 * f * f, w1 = 1 - w0 - w2;
75
+ outPos.x = w0 * pts[i0] + w1 * pts[i1] + w2 * pts[i2];
76
+ outPos.y = w0 * pts[i0 + 1] + w1 * pts[i1 + 1] + w2 * pts[i2 + 1];
77
+ outPos.z = w0 * pts[i0 + 2] + w1 * pts[i1 + 2] + w2 * pts[i2 + 2];
78
+ if (outTan) {
79
+ outTan.x = (f - 1) * pts[i0] + (1 - 2 * f) * pts[i1] + f * pts[i2];
80
+ outTan.z = (f - 1) * pts[i0 + 2] + (1 - 2 * f) * pts[i1 + 2] + f * pts[i2 + 2];
81
+ }
82
+ }
83
+
84
+ export class Traffic {
85
+ /**
86
+ * cityView = the City instance: needs .roadCells, .roadVersion, .events,
87
+ * .scene (overlay meshes). Street names for alerts come from the roadCells
88
+ * records (the engine already names runs per event in lay_road args — the
89
+ * client never re-derives crc32 names; documented simplification).
90
+ */
91
+ constructor(city) {
92
+ this.city = city;
93
+ this.onAlert = null; // ({cellKey, streetName}) — wire to the HUD chip
94
+
95
+ this.cap = new Float32Array(NCELL);
96
+ this.spd = new Float32Array(NCELL);
97
+ this.surfY = new Float32Array(NCELL);
98
+ this.occ = new Int16Array(NCELL);
99
+ this.cong = new Float32Array(NCELL);
100
+ this.jamSince = new Float32Array(NCELL).fill(-1);
101
+ this.roadIds = [];
102
+ this.cars = [];
103
+ this.commuters = [];
104
+ this.buildings = [];
105
+
106
+ this._pathCache = new Map();
107
+ this._stale = [];
108
+ this._g = new Float64Array(NCELL);
109
+ this._came = new Int32Array(NCELL);
110
+ this._closed = new Uint8Array(NCELL);
111
+
112
+ this._acc = 0;
113
+ this._slowAcc = 1e9; // force first sync
114
+ this._heatAcc = 1e9;
115
+ this._eventsSeen = -1;
116
+ this._versionSeen = -1;
117
+ this._completedSeen = -1;
118
+ this._alertUntil = -Infinity;
119
+ this._states = [];
120
+ this._heatOn = false;
121
+ this._buildOverlays();
122
+ }
123
+
124
+ // ---- overlays: heatmap (toggle) + jam underglow (always) -----------------
125
+ _buildOverlays() {
126
+ const geo = new THREE.PlaneGeometry(CELL * 0.94, CELL * 0.94).rotateX(-Math.PI / 2);
127
+ const heatMat = new THREE.MeshBasicMaterial({
128
+ transparent: true, opacity: 0.45, depthWrite: false, blending: THREE.AdditiveBlending,
129
+ });
130
+ this.heatMesh = new THREE.InstancedMesh(geo, heatMat, QCAP);
131
+ this.heatMesh.count = 0;
132
+ this.heatMesh.frustumCulled = false;
133
+ this.heatMesh.renderOrder = 4;
134
+ this.heatMesh.visible = false;
135
+ const jamGeo = new THREE.PlaneGeometry(CELL * 0.98, CELL * 0.98).rotateX(-Math.PI / 2);
136
+ this._jamMat = new THREE.MeshBasicMaterial({
137
+ color: EMISSIVE.congestion, transparent: true, opacity: 0.4,
138
+ depthWrite: false, blending: THREE.AdditiveBlending,
139
+ });
140
+ this.jamMesh = new THREE.InstancedMesh(jamGeo, this._jamMat, QCAP);
141
+ this.jamMesh.count = 0;
142
+ this.jamMesh.frustumCulled = false;
143
+ this.jamMesh.renderOrder = 4;
144
+ if (this.city && this.city.scene) {
145
+ this.city.scene.add(this.heatMesh);
146
+ this.city.scene.add(this.jamMesh);
147
+ }
148
+ }
149
+
150
+ setHeatmap(on) {
151
+ this._heatOn = !!on;
152
+ this.heatMesh.visible = this._heatOn;
153
+ }
154
+
155
+ // ---- derived state rebuilds ----------------------------------------------
156
+ _rebuildRoads() {
157
+ this.cap.fill(0);
158
+ this.spd.fill(0);
159
+ this.roadIds.length = 0;
160
+ for (const [key, rec] of this.city.roadCells) {
161
+ const [cx, cz] = key.split(",").map(Number);
162
+ const id = cid(cx, cz);
163
+ const rc = ROAD_CLASSES[rec.klass] || ROAD_CLASSES.street;
164
+ this.cap[id] = rc.capacity;
165
+ this.spd[id] = rc.speed;
166
+ this.surfY[id] = rec.bridge ? ROAD_Y.deck : ROAD_Y.road;
167
+ this.roadIds.push(id);
168
+ }
169
+ const yRoad = Math.fround(ROAD_Y.road), yDeck = Math.fround(ROAD_Y.deck); // Float32Array storage
170
+ for (const id of this.roadIds) { // bridge approaches ride the ramp
171
+ if (this.surfY[id] !== yRoad) continue;
172
+ for (const nb of [id - 1, id + 1, id - GRID, id + GRID]) {
173
+ if (nb >= 0 && nb < NCELL && this.surfY[nb] === yDeck && this.cap[nb]) {
174
+ this.surfY[id] = ROAD_Y.ramp;
175
+ break;
176
+ }
177
+ }
178
+ }
179
+ this._versionSeen = this.city.roadVersion;
180
+ this._pathCache.clear(); // cache keys on road_version implicitly
181
+ this._stale = this.cars.slice(); // incremental re-solve <= 20/frame
182
+ this.occ.fill(0);
183
+ for (const car of this.cars) if (!car.parked) this.occ[car.cell]++;
184
+ // overlay matrices follow the road set
185
+ for (let i = 0; i < this.roadIds.length && i < QCAP; i++) {
186
+ const id = this.roadIds[i];
187
+ _m4.identity().setPosition(px(id), this.surfY[id] + 0.04, pz(id));
188
+ this.heatMesh.setMatrixAt(i, _m4);
189
+ this.heatMesh.setColorAt(i, COL_GREEN);
190
+ }
191
+ this.heatMesh.count = Math.min(this.roadIds.length, QCAP);
192
+ this.heatMesh.instanceMatrix.needsUpdate = true;
193
+ if (this.heatMesh.instanceColor) this.heatMesh.instanceColor.needsUpdate = true;
194
+ }
195
+
196
+ _rebuildTrips(simTime) {
197
+ this.buildings = buildingsFromEvents(this.city.events);
198
+ const homes = [], dests = [];
199
+ let completed = 0;
200
+ for (const b of this.buildings) {
201
+ if (buildProgress(b, simTime) < 1) continue;
202
+ completed++;
203
+ if (b.def.residents > 0) homes.push(b);
204
+ if (b.def.jobs + b.def.attract > 0) dests.push(b);
205
+ }
206
+ this.commuters.length = 0;
207
+ for (const h of homes) {
208
+ const hGate = this._gateFor(h);
209
+ if (hGate < 0) continue;
210
+ for (let i = 0; i < h.def.residents; i++) {
211
+ const seed = (h.seed ^ i) >>> 0;
212
+ const rng = mulberry32(seed);
213
+ let total = 0;
214
+ for (const d of dests) total += this._tripWeight(h, d);
215
+ if (total <= 0) break;
216
+ let r = rng() * total, dest = dests[dests.length - 1];
217
+ for (const d of dests) {
218
+ r -= this._tripWeight(h, d);
219
+ if (r <= 0) { dest = d; break; }
220
+ }
221
+ const dGate = this._gateFor(dest);
222
+ if (dGate < 0 || dGate === hGate) continue;
223
+ this.commuters.push({ seed, home: hGate, dest: dGate });
224
+ }
225
+ }
226
+ this._eventsSeen = this.city.events.length;
227
+ this._completedSeen = completed;
228
+ }
229
+
230
+ _tripWeight(h, d) {
231
+ const md = Math.abs(h.x - d.x) + Math.abs(h.z - d.z);
232
+ return (d.def.jobs + 2 * d.def.attract) / (1 + Math.pow(md, 1.5));
233
+ }
234
+
235
+ /** Nearest road cell id to a building footprint (Chebyshev rings, r<=8). */
236
+ _gateFor(b) {
237
+ if (b._gate !== undefined && b._gateV === this._versionSeen) return b._gate;
238
+ let gate = -1;
239
+ outer:
240
+ for (let r = 1; r <= 8 && gate < 0; r++) {
241
+ for (let z = b.cz - r; z < b.cz + b.d + r; z++) {
242
+ for (let x = b.cx - r; x < b.cx + b.w + r; x++) {
243
+ const dring = Math.max(
244
+ Math.max(b.cx - x, x - (b.cx + b.w - 1)),
245
+ Math.max(b.cz - z, z - (b.cz + b.d - 1)));
246
+ if (dring !== r || x < -HALF || x >= HALF || z < -HALF || z >= HALF) continue;
247
+ const id = cid(x, z);
248
+ if (this.cap[id] > 0) { gate = id; break outer; }
249
+ }
250
+ }
251
+ }
252
+ b._gate = gate;
253
+ b._gateV = this._versionSeen;
254
+ return gate;
255
+ }
256
+
257
+ // ---- car pool -------------------------------------------------------------
258
+ rushFactor(simTime) {
259
+ const h = dayHours(simTime);
260
+ return RUSH_BASE + RUSH_AMP * Math.min(1, bell(h, 8, RUSH_SIGMA) + bell(h, 18, RUSH_SIGMA));
261
+ }
262
+
263
+ population(simTime) {
264
+ let pop = 0;
265
+ for (const b of this.buildings) pop += b.def.residents * buildProgress(b, simTime);
266
+ return pop;
267
+ }
268
+
269
+ _syncCars(simTime) {
270
+ const target = this.commuters.length
271
+ ? Math.min(clamp(Math.round(this.population(simTime) * CAR_RATE * this.rushFactor(simTime)), CAR_MIN, CAR_MAX), CAR_MAX)
272
+ : 0;
273
+ while (this.cars.length > target) {
274
+ const c = this.cars.pop();
275
+ if (!c.parked) this.occ[c.cell]--;
276
+ }
277
+ while (this.cars.length < target) {
278
+ const j = this.cars.length;
279
+ const cm = this.commuters[j % this.commuters.length];
280
+ this.cars.push(this._makeCar(j, cm));
281
+ }
282
+ }
283
+
284
+ _makeCar(j, cm) {
285
+ const rng = mulberry32((cm.seed ^ 0x6a09e667) >>> 0);
286
+ const path = this._route(cm.home, cm.dest);
287
+ const L = path.length;
288
+ const car = {
289
+ idx: j, seed: cm.seed, home: cm.home, dest: cm.dest, path,
290
+ p: 0, pPrev: 0, dir: 1, dwell: 0, cell: path[0], rng,
291
+ speedFactor: 0, colorIdx: cm.seed % 7, _refresh: false, parked: false,
292
+ };
293
+ if (L > 1) { // seeded phase offset along the full home->work->home loop
294
+ const u = rng() * 2 * (L - 1);
295
+ if (u > L - 1) { car.dir = -1; car.p = 2 * (L - 1) - u; }
296
+ else car.p = u;
297
+ car.pPrev = car.p;
298
+ car.cell = path[Math.round(car.p)];
299
+ }
300
+ this.occ[car.cell]++;
301
+ return car;
302
+ }
303
+
304
+ // ---- A* (congestion-aware), cached per road_version ------------------------
305
+ _route(h, d) {
306
+ const key = h * NCELL + d;
307
+ const hit = this._pathCache.get(key);
308
+ if (hit) return hit;
309
+ const g = this._g, came = this._came, closed = this._closed;
310
+ g.fill(Infinity);
311
+ closed.fill(0);
312
+ g[h] = 0;
313
+ came[h] = -1;
314
+ const dx = idX(d), dz = idZ(d);
315
+ const hCost = (id) => (Math.abs(idX(id) - dx) + Math.abs(idZ(id) - dz)) * 0.625;
316
+ const open = [h];
317
+ while (open.length) {
318
+ let bi = 0, bf = Infinity;
319
+ for (let i = 0; i < open.length; i++) {
320
+ const f = g[open[i]] + hCost(open[i]);
321
+ if (f < bf) { bf = f; bi = i; }
322
+ }
323
+ const cur = open[bi];
324
+ open[bi] = open[open.length - 1];
325
+ open.pop();
326
+ if (cur === d) break;
327
+ if (closed[cur]) continue;
328
+ closed[cur] = 1;
329
+ const cx = idX(cur);
330
+ const nbs = [cx > -HALF ? cur - 1 : -1, cx < HALF - 1 ? cur + 1 : -1, cur - GRID, cur + GRID];
331
+ for (const nb of nbs) {
332
+ if (nb < 0 || nb >= NCELL || !this.cap[nb] || closed[nb]) continue;
333
+ const c = g[cur] + (1 / this.spd[nb]) * (1 + 2 * this.cong[nb]);
334
+ if (c < g[nb]) {
335
+ g[nb] = c;
336
+ came[nb] = cur;
337
+ open.push(nb);
338
+ }
339
+ }
340
+ }
341
+ let path;
342
+ if (g[d] === Infinity) path = [h];
343
+ else {
344
+ path = [];
345
+ for (let n = d; n !== -1; n = came[n]) path.push(n);
346
+ path.reverse();
347
+ }
348
+ this._pathCache.set(key, path);
349
+ return path;
350
+ }
351
+
352
+ _resolveStale() {
353
+ let budget = 20;
354
+ while (budget-- > 0 && this._stale.length) {
355
+ const car = this._stale.pop();
356
+ if (this.cars[car.idx] !== car) continue;
357
+ const path = this._route(car.home, car.dest);
358
+ if (path === car.path || path.length < 2) continue;
359
+ const at = path.indexOf(car.cell);
360
+ if (at >= 0) { // seamless swap: same cell exists on the new route
361
+ car.path = path;
362
+ car.p = car.pPrev = at;
363
+ car._refresh = false;
364
+ } else {
365
+ car._refresh = true; // finish this leg on the old road, swap at the dwell
366
+ }
367
+ }
368
+ }
369
+
370
+ // ---- 10 Hz queueing cellular automaton -------------------------------------
371
+ _tick(dt, simTime) {
372
+ // cars in index order = deterministic intersection priority (lower wins)
373
+ for (const car of this.cars) {
374
+ car.pPrev = car.p;
375
+ const path = car.path, L = path.length;
376
+ if (L < 2) { car.speedFactor = 0; continue; }
377
+ if (car.dwell > 0) {
378
+ car.dwell -= dt;
379
+ car.speedFactor = 0;
380
+ if (car.dwell <= 0) { // pull back out of the parking spot
381
+ car.dir = -car.dir;
382
+ car.parked = false;
383
+ this.occ[car.cell]++;
384
+ if (car._refresh) {
385
+ const path2 = this._route(car.home, car.dest);
386
+ if (path2.length > 1) {
387
+ car.path = path2;
388
+ car.p = car.pPrev = car.dir > 0 ? 0 : path2.length - 1;
389
+ car._refresh = false;
390
+ }
391
+ }
392
+ }
393
+ continue;
394
+ }
395
+ const nextIdx = car.dir > 0
396
+ ? Math.min(Math.floor(car.p) + 1, L - 1)
397
+ : Math.max(Math.ceil(car.p) - 1, 0);
398
+ const next = path[nextIdx];
399
+ const cur = path[Math.round(car.p)];
400
+ const capN = Math.max(this.cap[next], 1);
401
+ const occN = next === car.cell ? this.occ[next] - 1 : this.occ[next];
402
+ let speed = 0;
403
+ if (next === car.cell || occN < 2 * capN) { // hold when next is saturated
404
+ speed = (this.spd[cur] || 1) * clamp(capN / Math.max(occN, 1), 0.25, 1);
405
+ }
406
+ car.speedFactor = speed / (this.spd[cur] || 1);
407
+ car.p += car.dir * speed * dt;
408
+ const nc = path[Math.round(car.p)];
409
+ if (nc !== car.cell) {
410
+ this.occ[car.cell]--;
411
+ this.occ[nc]++;
412
+ car.cell = nc;
413
+ }
414
+ // U-turn / park + dwell 10-20 s seeded at each end (parked cars leave occ)
415
+ if ((car.dir > 0 && car.p >= L - 1) || (car.dir < 0 && car.p <= 0)) {
416
+ car.p = car.dir > 0 ? L - 1 : 0;
417
+ car.dwell = 10 + 10 * car.rng();
418
+ car.parked = true;
419
+ this.occ[car.cell]--;
420
+ }
421
+ }
422
+ // congestion EMA + sustained-jam alert
423
+ const k = dt / TAU;
424
+ for (const id of this.roadIds) {
425
+ const ratio = this.occ[id] / Math.max(this.cap[id], 0.001);
426
+ this.cong[id] += (ratio - this.cong[id]) * k;
427
+ if (this.cong[id] >= 1) {
428
+ if (this.jamSince[id] < 0) this.jamSince[id] = simTime;
429
+ else if (simTime - this.jamSince[id] >= 10 && simTime >= this._alertUntil) {
430
+ this._alertUntil = simTime + 60; // once per 60 s
431
+ if (this.onAlert) {
432
+ const key = idKey(id);
433
+ const rec = this.city.roadCells.get(key);
434
+ this.onAlert({ cellKey: key, streetName: rec && rec.name ? rec.name : "an unnamed street" });
435
+ }
436
+ }
437
+ } else this.jamSince[id] = -1;
438
+ }
439
+ }
440
+
441
+ /** Traffic Index = round(100 * mean of the top-15 congested road cells). */
442
+ trafficIndex() {
443
+ const top = [];
444
+ for (const id of this.roadIds) {
445
+ const c = this.cong[id];
446
+ if (top.length < 15) {
447
+ top.push(c);
448
+ if (top.length === 15) top.sort((a, b) => a - b);
449
+ } else if (c > top[0]) {
450
+ top[0] = c;
451
+ top.sort((a, b) => a - b);
452
+ }
453
+ }
454
+ if (!top.length) return 0;
455
+ let s = 0;
456
+ for (const c of top) s += c;
457
+ return Math.round(100 * (s / top.length));
458
+ }
459
+
460
+ _updateOverlays() {
461
+ const n = Math.min(this.roadIds.length, QCAP);
462
+ let jam = 0;
463
+ for (let i = 0; i < n; i++) {
464
+ const id = this.roadIds[i];
465
+ const c = this.cong[id];
466
+ _c.copy(c < 0.5 ? COL_GREEN : c < 1 ? COL_AMBER : COL_RED);
467
+ this.heatMesh.setColorAt(i, _c);
468
+ if (c >= 1 && jam < QCAP) { // underglow strip, independent of the toggle
469
+ _m4.identity().setPosition(px(id), this.surfY[id] + 0.035, pz(id));
470
+ this.jamMesh.setMatrixAt(jam++, _m4);
471
+ }
472
+ }
473
+ this.jamMesh.count = jam;
474
+ this.jamMesh.instanceMatrix.needsUpdate = true;
475
+ if (this.heatMesh.instanceColor) this.heatMesh.instanceColor.needsUpdate = true;
476
+ }
477
+
478
+ /** Explicit invalidation hooks (update() also self-detects from the City). */
479
+ onRoadsChanged() { this._versionSeen = -1; }
480
+ onBuildingsChanged() { this._eventsSeen = -1; }
481
+
482
+ update(dt, simTime) {
483
+ const city = this.city;
484
+ if (city.roadVersion !== this._versionSeen) this._rebuildRoads();
485
+ if (city.events.length !== this._eventsSeen) {
486
+ this._rebuildTrips(simTime);
487
+ this._syncCars(simTime);
488
+ }
489
+ this._slowAcc += dt;
490
+ if (this._slowAcc >= 2) { // completions tick in + rush bell resizing
491
+ this._slowAcc = 0;
492
+ let completed = 0;
493
+ for (const b of this.buildings) if (buildProgress(b, simTime) >= 1) completed++;
494
+ if (completed !== this._completedSeen) this._rebuildTrips(simTime);
495
+ this._syncCars(simTime);
496
+ }
497
+ this._resolveStale();
498
+ this._acc += dt;
499
+ let guard = 0;
500
+ while (this._acc >= TICK && guard++ < 10) {
501
+ this._acc -= TICK;
502
+ this._tick(TICK, simTime);
503
+ }
504
+ this._heatAcc += dt;
505
+ if (this._heatAcc >= 0.5) { // 2 Hz instanceColor refresh
506
+ this._heatAcc = 0;
507
+ this._updateOverlays();
508
+ }
509
+ this._jamMat.opacity = 0.3 + 0.15 * Math.sin(simTime * 5); // pure fn of simTime
510
+ }
511
+
512
+ /**
513
+ * Render states interpolated between CA ticks. Each entry:
514
+ * {i, x, y, z, yaw, speedFactor, colorIdx, headlight}; y is the road surface
515
+ * (bridge decks/ramps included) and the right-lane lateral offset is already
516
+ * baked in. Dwelling (parked) cars are omitted. The array is reused.
517
+ */
518
+ carStates() {
519
+ const alpha = clamp(this._acc / TICK, 0, 1);
520
+ const out = this._states;
521
+ let n = 0;
522
+ const pos = { x: 0, y: 0, z: 0 }, tan = { x: 0, z: 0 };
523
+ for (const car of this.cars) {
524
+ const path = car.path, L = path.length;
525
+ if (L < 2 || car.dwell > 0) continue;
526
+ const u = clamp(lerp(car.pPrev, car.p, alpha), 0, L - 1);
527
+ this._evalCar(path, L, u, pos, tan);
528
+ tan.x *= car.dir;
529
+ tan.z *= car.dir;
530
+ const tl = Math.hypot(tan.x, tan.z) || 1;
531
+ const fx = tan.x / tl, fz = tan.z / tl;
532
+ const lane = this.cap[car.cell] >= 6 ? 1.15 : 0.8; // wider offset on avenues
533
+ const st = out[n] || (out[n] = {});
534
+ st.i = car.idx;
535
+ st.x = pos.x + (-fz) * lane;
536
+ st.z = pos.z + fx * lane;
537
+ st.y = pos.y;
538
+ st.yaw = Math.atan2(fx, fz);
539
+ st.speedFactor = car.speedFactor;
540
+ st.colorIdx = car.colorIdx;
541
+ st.headlight = 1;
542
+ n++;
543
+ }
544
+ out.length = n;
545
+ return out;
546
+ }
547
+
548
+ _evalCar(path, n, u, outPos, outTan) {
549
+ const seg = clamp(Math.floor(u), 0, Math.max(n - 2, 0));
550
+ const f = clamp(u - seg, 0, 1);
551
+ const i0 = path[Math.max(seg - 1, 0)], i1 = path[seg], i2 = path[Math.min(seg + 1, n - 1)];
552
+ const w0 = 0.5 * (1 - f) * (1 - f), w2 = 0.5 * f * f, w1 = 1 - w0 - w2;
553
+ outPos.x = w0 * px(i0) + w1 * px(i1) + w2 * px(i2);
554
+ outPos.y = w0 * this.surfY[i0] + w1 * this.surfY[i1] + w2 * this.surfY[i2];
555
+ outPos.z = w0 * pz(i0) + w1 * pz(i1) + w2 * pz(i2);
556
+ outTan.x = (f - 1) * px(i0) + (1 - 2 * f) * px(i1) + f * px(i2);
557
+ outTan.z = (f - 1) * pz(i0) + (1 - 2 * f) * pz(i1) + f * pz(i2);
558
+ }
559
+ }
web/city/util.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NEMOCITY — shared math + grid helpers. Pure functions only; everything the
2
+ // sim or renderer derives must be a function of (events, simTime), never of
3
+ // wall clock or unseeded randomness.
4
+ import * as THREE from "three";
5
+ import { CELL, DAY_S } from "./constants.js";
6
+
7
+ export const DEG = Math.PI / 180;
8
+
9
+ export const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
10
+ export const lerp = (a, b, t) => a + (b - a) * t;
11
+
12
+ export const smoothstep = (e0, e1, x) => {
13
+ const t = clamp((x - e0) / (e1 - e0), 0, 1);
14
+ return t * t * (3 - 2 * t);
15
+ };
16
+
17
+ export const easeOutBack = (x) => {
18
+ x = clamp(x, 0, 1);
19
+ const c1 = 1.70158, c3 = c1 + 1;
20
+ return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
21
+ };
22
+
23
+ export const easeInOut = (x) => {
24
+ x = clamp(x, 0, 1);
25
+ return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
26
+ };
27
+
28
+ /** Cell coords -> world position of the CELL CENTER, as [x, z]. */
29
+ export const cellToWorld = (cx, cz) => [cx * CELL + CELL / 2, cz * CELL + CELL / 2];
30
+
31
+ /** World position -> containing cell, as [cx, cz]. */
32
+ export const worldToCell = (x, z) => [Math.floor(x / CELL), Math.floor(z / CELL)];
33
+
34
+ /** Canonical map key for a cell: "cx,cz". */
35
+ export const cellKey = (cx, cz) => `${cx},${cz}`;
36
+
37
+ /** Manhattan distance between [cx,cz] pairs. */
38
+ export const manhattan = (a, b) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
39
+
40
+ /** Chebyshev distance between [cx,cz] pairs. */
41
+ export const chebyshev = (a, b) => Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]));
42
+
43
+ /** Unit gaussian bell. rushFactor caps the SUM of two bells at 1, not this. */
44
+ export const bell = (x, mu, sigma) => Math.exp(-((x - mu) * (x - mu)) / (2 * sigma * sigma));
45
+
46
+ /** simTime (s) -> in-game hour of day in [0, 24). One day = DAY_S real seconds. */
47
+ export const dayHours = (simTime) => (((simTime % DAY_S) + DAY_S) % DAY_S) / DAY_S * 24;
48
+
49
+ /** hue (deg, any range) + s,l in [0,1] -> THREE.Color. */
50
+ export function hsl(h, s, l, out = new THREE.Color()) {
51
+ return out.setHSL((((h % 360) + 360) % 360) / 360, clamp(s, 0, 1), clamp(l, 0, 1));
52
+ }
53
+
54
+ /**
55
+ * cityCenter(features) -> [x, z] world units: centroid of every place_building
56
+ * footprint center (genesis included), fallback [0, 0]. The camera's default
57
+ * frame and the engine's "center" anchor agree because both average the same
58
+ * event list.
59
+ */
60
+ export function cityCenter(features) {
61
+ let sx = 0, sz = 0, n = 0;
62
+ for (const f of features || []) {
63
+ if (!f || f.tool !== "place_building" || !f.args) continue;
64
+ const a = f.args;
65
+ if (a.cx == null || a.cz == null) continue;
66
+ sx += (a.cx + (a.w ?? 1) / 2) * CELL;
67
+ sz += (a.cz + (a.d ?? 1) / 2) * CELL;
68
+ n++;
69
+ }
70
+ return n ? [sx / n, sz / n] : [0, 0];
71
+ }