NEMOCITY v0 — mock backend, gradio 6.16.0 (pre-SSR)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- ARCHITECTURE.md +583 -0
- README.md +140 -6
- app.py +649 -0
- engine/__init__.py +42 -0
- engine/city.py +246 -0
- engine/constants.py +141 -0
- engine/genesis.py +134 -0
- engine/moderation.py +311 -0
- engine/placement.py +313 -0
- engine/queue_worker.py +719 -0
- engine/summary.py +79 -0
- engine/tools.py +279 -0
- engine/traffic.py +393 -0
- engine/world.py +205 -0
- mind/__init__.py +25 -0
- mind/backends.py +497 -0
- mind/mock_scripts.py +267 -0
- mind/moderation_judge.py +67 -0
- mind/planner.py +324 -0
- mind/prompts.py +311 -0
- mind/validate.py +293 -0
- package.json +12 -0
- requirements.txt +18 -0
- server/__init__.py +8 -0
- server/persistence.py +221 -0
- server/ratelimit.py +137 -0
- server/schemas.py +88 -0
- server/sse.py +117 -0
- tests/test_api.py +475 -0
- tests/test_engine.py +322 -0
- tests/test_mind.py +363 -0
- tests/test_traffic.py +167 -0
- tools/gen_web.py +110 -0
- tools/shot.mjs +163 -0
- traces/.gitkeep +0 -0
- web/city/buildings.js +827 -0
- web/city/camera.js +286 -0
- web/city/citizens.js +297 -0
- web/city/constants.js +402 -0
- web/city/construction.js +406 -0
- web/city/fpv.js +218 -0
- web/city/ground.js +336 -0
- web/city/palette.js +78 -0
- web/city/replay.js +98 -0
- web/city/rng.js +56 -0
- web/city/roads.js +481 -0
- web/city/scene.js +269 -0
- web/city/sky.js +263 -0
- web/city/traffic.js +559 -0
- web/city/util.js +71 -0
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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|