Town Mode: close town view by default, build_district + place_road, bank/market/house, grow-one-town steering, behold-the-world reveal, tamed needle
Browse files- ARCHITECTURE.md +66 -1
- README.md +2 -2
- engine/__init__.py +1 -1
- engine/summary.py +117 -4
- engine/tools.py +49 -3
- engine/world.py +4 -0
- mind/__init__.py +1 -1
- mind/grammar.gbnf +1 -1
- mind/mock_scripts.py +90 -16
- mind/prompts.py +26 -4
- tests/test_engine.py +171 -2
- tests/test_mind.py +58 -4
- tools/shot.mjs +57 -38
- web/book.js +11 -1
- web/index.html +6 -1
- web/main.js +42 -2
- web/mock/feed.js +36 -38
- web/mock/traces.json +59 -119
- web/mock/world.json +38 -50
- web/planet/camera.js +146 -27
- web/planet/district.js +342 -0
- web/planet/roads.js +193 -0
- web/planet/structures.js +195 -8
- web/planet/util.js +37 -0
- web/planet/world.js +16 -0
- web/style.css +37 -1
ARCHITECTURE.md
CHANGED
|
@@ -78,7 +78,11 @@ renderer derives ALL geometry deterministically from this list. Sky/weather are
|
|
| 78 |
`seed` is assigned by the engine: `seed = crc32(f"{wish_id}:{call_index}") & 0x7fffffff`. The JS
|
| 79 |
renderer uses the same seed with mulberry32 for all scatter/noise decisions of that feature.
|
| 80 |
|
| 81 |
-
### Tool DSL —
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
| tool | args (ranges clamped by engine) |
|
| 84 |
|---|---|
|
|
@@ -296,6 +300,67 @@ mind/mock_scripts.py + tests; (B) web/planet/structures.js (new kinds only);
|
|
| 296 |
(C) web/planet/life.js + web/planet/camera.js + main.js/book.js descend hooks.
|
| 297 |
Copy updates (README 8→9 tools etc.) ride with (A).
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
## Deploy plan (not in this build phase — for context)
|
| 300 |
|
| 301 |
1. Personal benchmark Space first: llamacpp 4B on free CPU + (separately) ZeroGPU 30B-A3B; measure
|
|
|
|
| 78 |
`seed` is assigned by the engine: `seed = crc32(f"{wish_id}:{call_index}") & 0x7fffffff`. The JS
|
| 79 |
renderer uses the same seed with mulberry32 for all scatter/noise decisions of that feature.
|
| 80 |
|
| 81 |
+
### Tool DSL — 11 tools (GBNF-friendly: flat args, numbers + enums + short strings)
|
| 82 |
+
|
| 83 |
+
> The base table below is the original 9; the City Update added `spawn_life` and the Town Mode
|
| 84 |
+
> update added `build_district` + `place_road` (full specs in those sections). The live surface is
|
| 85 |
+
> **11 tools** — see `engine/tools.py` (`TOOL_NAMES`) for the source of truth.
|
| 86 |
|
| 87 |
| tool | args (ranges clamped by engine) |
|
| 88 |
|---|---|
|
|
|
|
| 300 |
(C) web/planet/life.js + web/planet/camera.js + main.js/book.js descend hooks.
|
| 301 |
Copy updates (README 8→9 tools etc.) ride with (A).
|
| 302 |
|
| 303 |
+
## Town Mode (v1.2 contract — June 12 PM, "Town on the planet")
|
| 304 |
+
|
| 305 |
+
User feedback: a city wish produced one tower on the far side of a planet-sized sphere → reads empty.
|
| 306 |
+
The fix is a deliberate experience shift: the DEFAULT view is a town you build up close (SimCity feel);
|
| 307 |
+
the full planet becomes the dramatic zoom-out reveal. Append-only world preserved; genesis UNCHANGED
|
| 308 |
+
(the live shared world must keep rebuilding identically). All additive.
|
| 309 |
+
|
| 310 |
+
### The town anchor (engine + renderer compute IDENTICALLY)
|
| 311 |
+
`townCenter(world)` = the seeded-weighted centroid (unit-vector mean, renormalized) of all
|
| 312 |
+
`place_structure` + `build_district` feature directions, EXCLUDING genesis. Fallback when none exist:
|
| 313 |
+
the genesis monolith at lat 14, lon 38. Engine exposes it in the world summary; the renderer uses it
|
| 314 |
+
for the default camera. A town gets a deterministic name from `crc32(round(lat),round(lon))` → a small
|
| 315 |
+
curated name list (e.g. "Lowmere", "Ashford", "Tessen") so the summary + HUD can say "the town of X".
|
| 316 |
+
|
| 317 |
+
### Density comes from each tool call, not from many (the 9B reliably plans only ~2-3 calls)
|
| 318 |
+
New tools (now **11** — update every "9 tools" copy → 11; list build_district + place_road):
|
| 319 |
+
| tool | args |
|
| 320 |
+
|---|---|
|
| 321 |
+
| `build_district` | `lat`,`lon`,`radius_deg` 2..15, `density` 0..1, `hue` 0..360 — renderer places a SEEDED neighborhood: a cluster of small houses on a rough grid, footpath/road connectors between them, and 1 occasional civic building. ONE successful call = a visible block. This is the SimCity unlock. |
|
| 322 |
+
| `place_road` | `path` [[lat,lon] ×2..6] — a paved, faintly-lit road spline laid ON the surface; visually DISTINCT from `place_water` glowing streams (warm stone/amber, matte, dashed centerline feel — not emissive cyan). |
|
| 323 |
+
|
| 324 |
+
`place_structure.kind` enum += `bank`, `market`, `house` (single landmark homes/civic buildings; the
|
| 325 |
+
existing low-poly kit conventions). Full set: lighthouse|monolith|shrine|village|beacon|arch|tower|
|
| 326 |
+
warehouse|cafe|bank|market|house.
|
| 327 |
+
|
| 328 |
+
### Steering the god to GROW THE TOWN (mind/prompts.py + engine/summary.py)
|
| 329 |
+
- summary.py: name the town + its center + building count ("The town of Lowmere stands near (14,38):
|
| 330 |
+
3 towers, a café, 12 houses."). When the wish asks for buildings/a city, the god should ADD to the
|
| 331 |
+
existing town, not scatter — build near townCenter, connect with `place_road`, and reach for
|
| 332 |
+
`build_district` for neighborhoods. Persona gains: "You are growing one town. New buildings rise
|
| 333 |
+
beside the old ones, linked by roads — never alone in the wilderness."
|
| 334 |
+
- This is steering, not enforcement (keeps the "god decides" purity). The camera following density
|
| 335 |
+
means even a stray build is visible; over wishes the town concentrates.
|
| 336 |
+
|
| 337 |
+
### Camera = two modes (web/planet/camera.js + main.js)
|
| 338 |
+
- **TOWN view (DEFAULT on load):** camera sits LOW and close, framing a ~28° cap around townCenter —
|
| 339 |
+
buildings read large, like standing over a model town. This is what visitors see first now (not the
|
| 340 |
+
full orbit).
|
| 341 |
+
- **WORLD view (the reveal):** a HUD control "behold the world ↕" eases out to the full-planet orbit
|
| 342 |
+
(today's gorgeous wide shot) and back. The demo money-move: build the town, then pull back to show
|
| 343 |
+
it's one patch on a shared planet.
|
| 344 |
+
- On each `world_delta`/grant, frame the new feature WITHIN the town zoom (don't fly to the far limb).
|
| 345 |
+
- The existing descent stays (epitaph card) — now it descends within the town.
|
| 346 |
+
|
| 347 |
+
### Tame the god-needle
|
| 348 |
+
The orbital "god instrument" needle currently dominates the frame (huge gold double-cone). In TOWN
|
| 349 |
+
view it must be small/high or hidden; show it prominently only during active granting (it "writes" the
|
| 350 |
+
wish) and in WORLD view. Scale it down ~60% at rest.
|
| 351 |
+
|
| 352 |
+
### Ownership for the Town Mode build
|
| 353 |
+
- ENGINE+MIND (A): engine/tools.py (build_district, place_road, +3 structure kinds), engine/summary.py
|
| 354 |
+
(town name+center+counts), engine/world.py (_observation for new tools), mind/grammar.gbnf,
|
| 355 |
+
mind/mock_scripts.py (town-growth scripts), mind/prompts.py (grow-the-town steering + 11-tool copy),
|
| 356 |
+
tests; README/ARCHITECTURE 9→11 copy.
|
| 357 |
+
- RENDERER (B): web/planet/structures.js (bank/market/house kit), web/planet/district.js (NEW —
|
| 358 |
+
seeded house+road neighborhood), web/planet/roads.js (NEW — paved road splines) OR fold roads into
|
| 359 |
+
water.js styling, web/planet/world.js wiring, needle scale-down (find it — likely sky.js or scene.js).
|
| 360 |
+
- CAMERA+HUD (C): web/planet/camera.js (TOWN/WORLD modes + townCenter framing), web/main.js + book.js
|
| 361 |
+
(default TOWN view, "behold the world" toggle, frame-within-town on delta), web/index.html +
|
| 362 |
+
style.css (the toggle control). townCenter helper shared via web/planet/util.js.
|
| 363 |
+
|
| 364 |
## Deploy plan (not in this build phase — for context)
|
| 365 |
|
| 366 |
1. Personal benchmark Space first: llamacpp 4B on free CPU + (separately) ZeroGPU 30B-A3B; measure
|
README.md
CHANGED
|
@@ -87,9 +87,9 @@ church and state:
|
|
| 87 |
```
|
| 88 |
|
| 89 |
The model never computes geometry and never owns state — it interprets, narrates, and
|
| 90 |
-
picks from a DSL of exactly
|
| 91 |
`place_structure`, `place_water`, `set_weather`, `set_sky`, `inscribe_wish`,
|
| 92 |
-
`spawn_life`). The
|
| 93 |
deterministic engine clamps every number, seeds every feature, and feeds terse
|
| 94 |
observations back into the loop. Wishes pass layered moderation before a single tool
|
| 95 |
runs. Latency is liturgy: the god grants **one wish at a time**, and you see your place
|
|
|
|
| 87 |
```
|
| 88 |
|
| 89 |
The model never computes geometry and never owns state — it interprets, narrates, and
|
| 90 |
+
picks from a DSL of exactly 11 tools (`raise_terrain`, `lower_terrain`, `spawn_flora`,
|
| 91 |
`place_structure`, `place_water`, `set_weather`, `set_sky`, `inscribe_wish`,
|
| 92 |
+
`spawn_life`, `build_district`, `place_road`). The
|
| 93 |
deterministic engine clamps every number, seeds every feature, and feeds terse
|
| 94 |
observations back into the loop. Wishes pass layered moderation before a single tool
|
| 95 |
runs. Latency is liturgy: the god grants **one wish at a time**, and you see your place
|
engine/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""GODSEED deterministic world engine.
|
| 2 |
|
| 3 |
-
The engine owns ALL facts ("her" pattern): world state, the
|
| 4 |
validation, moderation, the serialized wish queue, and the prompt summary.
|
| 5 |
The LLM only interprets wishes and picks tool calls — it never owns state.
|
| 6 |
"""
|
|
|
|
| 1 |
"""GODSEED deterministic world engine.
|
| 2 |
|
| 3 |
+
The engine owns ALL facts ("her" pattern): world state, the 11-tool DSL,
|
| 4 |
validation, moderation, the serialized wish queue, and the prompt summary.
|
| 5 |
The LLM only interprets wishes and picks tool calls — it never owns state.
|
| 6 |
"""
|
engine/summary.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
| 1 |
"""Compact world description for the LLM prompt (<600 chars, always).
|
| 2 |
|
| 3 |
-
Covers:
|
| 4 |
-
coords,
|
| 5 |
-
module stays import-free of
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
|
|
|
| 10 |
from typing import Any
|
| 11 |
|
| 12 |
MAX_LEN = 599 # contract: strictly under 600 chars
|
|
@@ -16,6 +19,71 @@ _MAX_FLORA_KINDS = 6
|
|
| 16 |
_MAX_STRUCTURES = 4
|
| 17 |
_EPITAPH_CLIP = 70
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
def _coord(args: dict) -> str:
|
| 21 |
lat, lon = args.get("lat"), args.get("lon")
|
|
@@ -33,9 +101,54 @@ def _clip(text: str, limit: int) -> str:
|
|
| 33 |
return text if len(text) <= limit else text[: limit - 3] + "..."
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
def summarize(world: Any) -> str:
|
| 37 |
features = list(getattr(world, "features", ()) or ())
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
# terrain
|
| 41 |
raised = [f for f in features if f.tool == "raise_terrain"]
|
|
|
|
| 1 |
"""Compact world description for the LLM prompt (<600 chars, always).
|
| 2 |
|
| 3 |
+
Covers: the town (name + center + building tally), epoch, terrain count +
|
| 4 |
+
notable coords, flora kinds, structures with coords, current sky/weather, last
|
| 5 |
+
2 epitaphs. Duck-types over World so this module stays import-free of
|
| 6 |
+
engine.world (no cycles).
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import math
|
| 12 |
+
import zlib
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
MAX_LEN = 599 # contract: strictly under 600 chars
|
|
|
|
| 19 |
_MAX_STRUCTURES = 4
|
| 20 |
_EPITAPH_CLIP = 70
|
| 21 |
|
| 22 |
+
# --------------------------------------------------------------------------
|
| 23 |
+
# Town anchor — the seeded-weighted centroid of the built world (v1.2)
|
| 24 |
+
# --------------------------------------------------------------------------
|
| 25 |
+
#
|
| 26 |
+
# townCenter is a CROSS-COMPONENT contract: the JS renderer's camera computes
|
| 27 |
+
# the SAME value to frame the default TOWN view, so this math must be matched
|
| 28 |
+
# byte-for-byte. It is the unit-vector mean of every non-genesis place_structure
|
| 29 |
+
# + build_district direction, renormalized back to (lat, lon). With nothing
|
| 30 |
+
# built yet it falls back to the genesis monolith.
|
| 31 |
+
|
| 32 |
+
# The genesis monolith (f_000002) — the town's seed when nothing else is built.
|
| 33 |
+
GENESIS_MONOLITH = (14.0, 38.0)
|
| 34 |
+
|
| 35 |
+
# Town-anchoring tools (their lat/lon define where the town sits).
|
| 36 |
+
_TOWN_TOOLS = ("place_structure", "build_district")
|
| 37 |
+
|
| 38 |
+
# A curated name pool; crc32(round(lat), round(lon)) selects one deterministically.
|
| 39 |
+
TOWN_NAMES: tuple[str, ...] = (
|
| 40 |
+
"Lowmere", "Ashford", "Tessen", "Harrowfen", "Mossgate", "Brighthollow",
|
| 41 |
+
"Stilwater", "Emberlyn", "Caldmoor", "Vesper", "Thornwick", "Glasswend",
|
| 42 |
+
"Dunmarrow", "Sorrel", "Wyndholt", "Pellan",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def town_center(world: Any) -> tuple[float, float]:
|
| 47 |
+
"""Return the town anchor (lat, lon) for a world.
|
| 48 |
+
|
| 49 |
+
The unit-vector mean of all non-genesis ``place_structure`` +
|
| 50 |
+
``build_district`` directions, renormalized to a (lat, lon) pair. Falls back
|
| 51 |
+
to the genesis monolith at (14, 38) when nothing has been built. The result
|
| 52 |
+
lat/lon are rounded to 4 places (stable across the engine/renderer split).
|
| 53 |
+
"""
|
| 54 |
+
features = list(getattr(world, "features", ()) or ())
|
| 55 |
+
x = y = z = 0.0
|
| 56 |
+
n = 0
|
| 57 |
+
for f in features:
|
| 58 |
+
if f.wish_id == "genesis" or f.tool not in _TOWN_TOOLS:
|
| 59 |
+
continue
|
| 60 |
+
lat = f.args.get("lat")
|
| 61 |
+
lon = f.args.get("lon")
|
| 62 |
+
if lat is None or lon is None:
|
| 63 |
+
continue
|
| 64 |
+
rlat = math.radians(float(lat))
|
| 65 |
+
rlon = math.radians(float(lon))
|
| 66 |
+
cos_lat = math.cos(rlat)
|
| 67 |
+
x += cos_lat * math.cos(rlon)
|
| 68 |
+
y += cos_lat * math.sin(rlon)
|
| 69 |
+
z += math.sin(rlat)
|
| 70 |
+
n += 1
|
| 71 |
+
if n == 0:
|
| 72 |
+
return GENESIS_MONOLITH
|
| 73 |
+
norm = math.sqrt(x * x + y * y + z * z)
|
| 74 |
+
if norm == 0.0: # antipodal directions cancel; fall back rather than divide by zero
|
| 75 |
+
return GENESIS_MONOLITH
|
| 76 |
+
x, y, z = x / norm, y / norm, z / norm
|
| 77 |
+
lat = math.degrees(math.asin(max(-1.0, min(1.0, z))))
|
| 78 |
+
lon = math.degrees(math.atan2(y, x))
|
| 79 |
+
return round(lat, 4), round(lon, 4)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def town_name(lat: float, lon: float) -> str:
|
| 83 |
+
"""Deterministic town name from its rounded center (crc32 -> curated pool)."""
|
| 84 |
+
key = f"{round(lat)},{round(lon)}".encode("utf-8")
|
| 85 |
+
return TOWN_NAMES[zlib.crc32(key) % len(TOWN_NAMES)]
|
| 86 |
+
|
| 87 |
|
| 88 |
def _coord(args: dict) -> str:
|
| 89 |
lat, lon = args.get("lat"), args.get("lon")
|
|
|
|
| 101 |
return text if len(text) <= limit else text[: limit - 3] + "..."
|
| 102 |
|
| 103 |
|
| 104 |
+
_A_OR_AN_VOWEL = "aeiou"
|
| 105 |
+
_MAX_TALLY_KINDS = 5 # cap the building tally so the line stays short
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _count_phrase(noun: str, n: int) -> str:
|
| 109 |
+
"""'3 towers' / 'a cafe' (singular gets an article)."""
|
| 110 |
+
if n == 1:
|
| 111 |
+
article = "an" if noun[:1].lower() in _A_OR_AN_VOWEL else "a"
|
| 112 |
+
return f"{article} {noun}"
|
| 113 |
+
plural = noun + ("es" if noun.endswith(("s", "x", "z", "ch", "sh")) else "s")
|
| 114 |
+
return f"{n} {plural}"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _town_tally(features: list) -> str:
|
| 118 |
+
"""A short building/house tally, e.g. '3 towers, a cafe, 2 districts'."""
|
| 119 |
+
counts: dict[str, int] = {}
|
| 120 |
+
order: list[str] = []
|
| 121 |
+
for f in features:
|
| 122 |
+
if f.wish_id == "genesis":
|
| 123 |
+
continue
|
| 124 |
+
if f.tool == "place_structure":
|
| 125 |
+
kind = f.args.get("kind", "building")
|
| 126 |
+
elif f.tool == "build_district":
|
| 127 |
+
kind = "district"
|
| 128 |
+
else:
|
| 129 |
+
continue
|
| 130 |
+
if kind not in counts:
|
| 131 |
+
order.append(kind)
|
| 132 |
+
counts[kind] = counts.get(kind, 0) + 1
|
| 133 |
+
if not counts:
|
| 134 |
+
return ""
|
| 135 |
+
bits = [_count_phrase(kind, counts[kind]) for kind in order[:_MAX_TALLY_KINDS]]
|
| 136 |
+
if len(order) > _MAX_TALLY_KINDS:
|
| 137 |
+
bits.append("more")
|
| 138 |
+
return ", ".join(bits)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
def summarize(world: Any) -> str:
|
| 142 |
features = list(getattr(world, "features", ()) or ())
|
| 143 |
+
|
| 144 |
+
# The town — where the god should keep building (v1.2 town mode).
|
| 145 |
+
lat, lon = town_center(world)
|
| 146 |
+
name = town_name(lat, lon)
|
| 147 |
+
tally = _town_tally(features)
|
| 148 |
+
town_line = f"The town of {name} stands near ({round(lat)},{round(lon)})"
|
| 149 |
+
town_line += f": {tally}." if tally else "; nothing built yet — start it here."
|
| 150 |
+
|
| 151 |
+
parts: list[str] = [town_line, f"epoch {getattr(world, 'epoch', 0)}"]
|
| 152 |
|
| 153 |
# terrain
|
| 154 |
raised = [f for f in features if f.tool == "raise_terrain"]
|
engine/tools.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""GODSEED tool DSL — the single source of truth for the
|
| 2 |
|
| 3 |
Spec is data (TOOLS); validation is `validate_call`. Semantics per the
|
| 4 |
architecture contract:
|
|
@@ -11,6 +11,7 @@ architecture contract:
|
|
| 11 |
|
| 12 |
`place_water` constraints: kind "stream" needs 2..5 path waypoints and takes
|
| 13 |
no radius; kind "pool" uses path[0] as its center and requires radius_deg.
|
|
|
|
| 14 |
|
| 15 |
Pure stdlib. Other engine modules (and the mind's prompt builder) import this.
|
| 16 |
"""
|
|
@@ -50,7 +51,7 @@ class PathSpec:
|
|
| 50 |
FLORA_KINDS = ("trees", "glowgrass", "mushrooms", "vines", "flowers", "reeds")
|
| 51 |
STRUCTURE_KINDS = (
|
| 52 |
"lighthouse", "monolith", "shrine", "village", "beacon", "arch",
|
| 53 |
-
"tower", "warehouse", "cafe",
|
| 54 |
)
|
| 55 |
WATER_KINDS = ("stream", "pool")
|
| 56 |
WEATHER_KINDS = ("clear", "rain", "snow", "embers", "mist", "storm")
|
|
@@ -115,6 +116,16 @@ TOOLS: dict[str, dict[str, Any]] = {
|
|
| 115 |
"count": NumberSpec(1, 12, integer=True),
|
| 116 |
"hue": NumberSpec(0, 360),
|
| 117 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
TOOL_NAMES: tuple[str, ...] = tuple(TOOLS.keys())
|
|
@@ -236,6 +247,31 @@ def _validate_place_water(args: dict):
|
|
| 236 |
return {"kind": "pool", "path": points[:1], "radius_deg": radius, "hue": hue}, None
|
| 237 |
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
def validate_call(tool: Any, args: Any) -> tuple[Optional[dict], Optional[str]]:
|
| 240 |
"""Validate one tool call. Returns (canonical_args, None) or (None, rejection).
|
| 241 |
|
|
@@ -250,6 +286,8 @@ def validate_call(tool: Any, args: Any) -> tuple[Optional[dict], Optional[str]]:
|
|
| 250 |
return None, "rejected: args must be an object"
|
| 251 |
if tool == "place_water":
|
| 252 |
return _validate_place_water(args)
|
|
|
|
|
|
|
| 253 |
|
| 254 |
out: dict[str, Any] = {}
|
| 255 |
for name, spec in TOOLS[tool].items():
|
|
@@ -270,6 +308,11 @@ def validate_call(tool: Any, args: Any) -> tuple[Optional[dict], Optional[str]]:
|
|
| 270 |
return out, None
|
| 271 |
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
def as_dict() -> dict:
|
| 274 |
"""Plain-JSON view of the tool spec (handy for prompts / grammar builders)."""
|
| 275 |
out: dict[str, dict] = {}
|
|
@@ -287,10 +330,13 @@ def as_dict() -> dict:
|
|
| 287 |
spec_out[name] = {"type": "string", "max_len": spec.max_len}
|
| 288 |
elif isinstance(spec, PathSpec):
|
| 289 |
spec_out[name] = {
|
| 290 |
-
"type": "path",
|
|
|
|
|
|
|
| 291 |
"point": "[lat, lon]",
|
| 292 |
}
|
| 293 |
out[tool] = spec_out
|
| 294 |
out["place_water"]["radius_deg"]["note"] = "pool only"
|
| 295 |
out["place_water"]["path"]["note"] = "stream: 2..5 waypoints; pool: path[0] is center"
|
|
|
|
| 296 |
return out
|
|
|
|
| 1 |
+
"""GODSEED tool DSL — the single source of truth for the 11-tool surface.
|
| 2 |
|
| 3 |
Spec is data (TOOLS); validation is `validate_call`. Semantics per the
|
| 4 |
architecture contract:
|
|
|
|
| 11 |
|
| 12 |
`place_water` constraints: kind "stream" needs 2..5 path waypoints and takes
|
| 13 |
no radius; kind "pool" uses path[0] as its center and requires radius_deg.
|
| 14 |
+
`place_road` takes a path of 2..6 [lat,lon] waypoints (a paved surface spline).
|
| 15 |
|
| 16 |
Pure stdlib. Other engine modules (and the mind's prompt builder) import this.
|
| 17 |
"""
|
|
|
|
| 51 |
FLORA_KINDS = ("trees", "glowgrass", "mushrooms", "vines", "flowers", "reeds")
|
| 52 |
STRUCTURE_KINDS = (
|
| 53 |
"lighthouse", "monolith", "shrine", "village", "beacon", "arch",
|
| 54 |
+
"tower", "warehouse", "cafe", "bank", "market", "house",
|
| 55 |
)
|
| 56 |
WATER_KINDS = ("stream", "pool")
|
| 57 |
WEATHER_KINDS = ("clear", "rain", "snow", "embers", "mist", "storm")
|
|
|
|
| 116 |
"count": NumberSpec(1, 12, integer=True),
|
| 117 |
"hue": NumberSpec(0, 360),
|
| 118 |
},
|
| 119 |
+
"build_district": {
|
| 120 |
+
"lat": NumberSpec(*LAT_RANGE),
|
| 121 |
+
"lon": NumberSpec(*LON_RANGE),
|
| 122 |
+
"radius_deg": NumberSpec(2, 15),
|
| 123 |
+
"density": NumberSpec(0, 1),
|
| 124 |
+
"hue": NumberSpec(0, 360),
|
| 125 |
+
},
|
| 126 |
+
"place_road": {
|
| 127 |
+
"path": PathSpec(6), # 2..6 [lat,lon] waypoints
|
| 128 |
+
},
|
| 129 |
}
|
| 130 |
|
| 131 |
TOOL_NAMES: tuple[str, ...] = tuple(TOOLS.keys())
|
|
|
|
| 247 |
return {"kind": "pool", "path": points[:1], "radius_deg": radius, "hue": hue}, None
|
| 248 |
|
| 249 |
|
| 250 |
+
def _validate_place_road(args: dict):
|
| 251 |
+
"""A road is a paved surface spline through 2..6 [lat,lon] waypoints.
|
| 252 |
+
|
| 253 |
+
Path handling mirrors place_water (a flat [lat, lon] pair is wrapped; extra
|
| 254 |
+
waypoints past the max are clamped off) but a road always needs at least two
|
| 255 |
+
points — there is no single-point form.
|
| 256 |
+
"""
|
| 257 |
+
spec = TOOLS["place_road"]
|
| 258 |
+
raw_path = args.get("path")
|
| 259 |
+
if not isinstance(raw_path, (list, tuple)) or not raw_path:
|
| 260 |
+
return None, "rejected: road needs path [[lat,lon],...] (2..6 points)"
|
| 261 |
+
# a flat [lat, lon] pair instead of [[lat, lon]]
|
| 262 |
+
if not isinstance(raw_path[0], (list, tuple, dict)):
|
| 263 |
+
raw_path = [raw_path]
|
| 264 |
+
points = []
|
| 265 |
+
for p in list(raw_path)[: spec["path"].max_points]: # extra waypoints clamped off
|
| 266 |
+
pt = _coerce_point(p)
|
| 267 |
+
if pt is None:
|
| 268 |
+
return None, "rejected: bad waypoint in path (each is [lat, lon])"
|
| 269 |
+
points.append(pt)
|
| 270 |
+
if len(points) < 2:
|
| 271 |
+
return None, "rejected: road needs 2..6 waypoints"
|
| 272 |
+
return {"path": points}, None
|
| 273 |
+
|
| 274 |
+
|
| 275 |
def validate_call(tool: Any, args: Any) -> tuple[Optional[dict], Optional[str]]:
|
| 276 |
"""Validate one tool call. Returns (canonical_args, None) or (None, rejection).
|
| 277 |
|
|
|
|
| 286 |
return None, "rejected: args must be an object"
|
| 287 |
if tool == "place_water":
|
| 288 |
return _validate_place_water(args)
|
| 289 |
+
if tool == "place_road":
|
| 290 |
+
return _validate_place_road(args)
|
| 291 |
|
| 292 |
out: dict[str, Any] = {}
|
| 293 |
for name, spec in TOOLS[tool].items():
|
|
|
|
| 308 |
return out, None
|
| 309 |
|
| 310 |
|
| 311 |
+
# Per-tool minimum path length (place_water's pool form accepts a single
|
| 312 |
+
# center point; place_road always needs at least two waypoints).
|
| 313 |
+
_PATH_MIN_POINTS: dict[str, int] = {"place_water": 1, "place_road": 2}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
def as_dict() -> dict:
|
| 317 |
"""Plain-JSON view of the tool spec (handy for prompts / grammar builders)."""
|
| 318 |
out: dict[str, dict] = {}
|
|
|
|
| 330 |
spec_out[name] = {"type": "string", "max_len": spec.max_len}
|
| 331 |
elif isinstance(spec, PathSpec):
|
| 332 |
spec_out[name] = {
|
| 333 |
+
"type": "path",
|
| 334 |
+
"min_points": _PATH_MIN_POINTS.get(tool, 1),
|
| 335 |
+
"max_points": spec.max_points,
|
| 336 |
"point": "[lat, lon]",
|
| 337 |
}
|
| 338 |
out[tool] = spec_out
|
| 339 |
out["place_water"]["radius_deg"]["note"] = "pool only"
|
| 340 |
out["place_water"]["path"]["note"] = "stream: 2..5 waypoints; pool: path[0] is center"
|
| 341 |
+
out["place_road"]["path"]["note"] = "2..6 waypoints; a paved road laid on the surface"
|
| 342 |
return out
|
engine/world.py
CHANGED
|
@@ -98,6 +98,10 @@ def _observation(feature: Feature) -> str:
|
|
| 98 |
return f"ok: wish inscribed in {a['style']}"
|
| 99 |
if t == "spawn_life":
|
| 100 |
return f"ok: {a['count']} {a['kind']} stirring at {_coord(a)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
return "ok" # pragma: no cover — every known tool is handled above
|
| 102 |
|
| 103 |
|
|
|
|
| 98 |
return f"ok: wish inscribed in {a['style']}"
|
| 99 |
if t == "spawn_life":
|
| 100 |
return f"ok: {a['count']} {a['kind']} stirring at {_coord(a)}"
|
| 101 |
+
if t == "build_district":
|
| 102 |
+
return f"ok: a district rises near {_coord(a)}"
|
| 103 |
+
if t == "place_road":
|
| 104 |
+
return f"ok: a road laid through {len(a['path'])} points"
|
| 105 |
return "ok" # pragma: no cover — every known tool is handled above
|
| 106 |
|
| 107 |
|
mind/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 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
|
| 5 |
It never computes geometry and never owns world state (the "her" pattern):
|
| 6 |
the deterministic engine clamps, executes, and records every act.
|
| 7 |
|
|
|
|
| 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 |
|
mind/grammar.gbnf
CHANGED
|
@@ -19,7 +19,7 @@ thought-kv ::= "\"thought\"" ws ":" ws thought-string
|
|
| 19 |
call-kv ::= "\"call\"" ws ":" ws "{" ws "\"tool\"" ws ":" ws tool-name ws "," ws "\"args\"" ws ":" ws args-object ws "}"
|
| 20 |
done-kv ::= "\"done\"" ws ":" ws "true" ws "," ws "\"epitaph\"" ws ":" ws epitaph-string
|
| 21 |
|
| 22 |
-
tool-name ::= "\"raise_terrain\"" | "\"lower_terrain\"" | "\"spawn_flora\"" | "\"place_structure\"" | "\"place_water\"" | "\"set_weather\"" | "\"set_sky\"" | "\"inscribe_wish\"" | "\"spawn_life\""
|
| 23 |
|
| 24 |
args-object ::= "{" ws ( member ( ws "," ws member ){0,11} )? ws "}"
|
| 25 |
member ::= key-string ws ":" ws value
|
|
|
|
| 19 |
call-kv ::= "\"call\"" ws ":" ws "{" ws "\"tool\"" ws ":" ws tool-name ws "," ws "\"args\"" ws ":" ws args-object ws "}"
|
| 20 |
done-kv ::= "\"done\"" ws ":" ws "true" ws "," ws "\"epitaph\"" ws ":" ws epitaph-string
|
| 21 |
|
| 22 |
+
tool-name ::= "\"raise_terrain\"" | "\"lower_terrain\"" | "\"spawn_flora\"" | "\"place_structure\"" | "\"place_water\"" | "\"set_weather\"" | "\"set_sky\"" | "\"inscribe_wish\"" | "\"spawn_life\"" | "\"build_district\"" | "\"place_road\""
|
| 23 |
|
| 24 |
args-object ::= "{" ws ( member ( ws "," ws member ){0,11} )? ws "}"
|
| 25 |
member ::= key-string ws ":" ws value
|
mind/mock_scripts.py
CHANGED
|
@@ -5,7 +5,7 @@ a short turn list (calls + a done turn), with coordinates, hues, and a few
|
|
| 5 |
phrasings varied by a seed derived from the wish text (crc32 — stable across
|
| 6 |
processes, unlike hash()). Same wish in, same plan out: demos replay exactly.
|
| 7 |
|
| 8 |
-
Across the category library every one of the
|
| 9 |
demo session exercises the full DSL. All numbers are kept inside the engine's
|
| 10 |
clamp ranges; all thoughts <=160 chars; all epitaphs <=120; inscriptions <=90.
|
| 11 |
|
|
@@ -367,50 +367,123 @@ def _village(wish: str, rng: random.Random, mood: str) -> dict:
|
|
| 367 |
|
| 368 |
|
| 369 |
def _city(wish: str, rng: random.Random, mood: str) -> dict:
|
|
|
|
|
|
|
| 370 |
lat, lon = _anchor(rng)
|
| 371 |
glow_hue = _r1(rng, 200, 280)
|
| 372 |
warm_hue = _r1(rng, 30, 50)
|
| 373 |
reading = (
|
| 374 |
-
"A city, then — the big wish, the brave one. I will
|
| 375 |
-
"
|
| 376 |
-
"
|
| 377 |
)
|
| 378 |
return {
|
| 379 |
"reading": reading,
|
| 380 |
"turns": [
|
| 381 |
_call(
|
| 382 |
-
"
|
| 383 |
-
"
|
| 384 |
-
lat=lat, lon=lon, radius_deg=_r1(rng,
|
| 385 |
-
|
| 386 |
),
|
| 387 |
_call(
|
| 388 |
-
"The tower, slender, its rooftop light slow-pulsing.",
|
| 389 |
"place_structure",
|
| 390 |
-
lat=lat, lon=lon
|
| 391 |
-
scale=_r2(rng, 1.4, 1.9), hue=glow_hue,
|
| 392 |
),
|
| 393 |
_call(
|
| 394 |
"A warehouse at its foot, one great door glowing.",
|
| 395 |
"place_structure",
|
| 396 |
-
lat=_clamp_lat(lat - 2), lon=_wrap_lon(lon +
|
| 397 |
kind="warehouse", scale=_r2(rng, 1.0, 1.4), hue=_r1(rng, 36, 52),
|
| 398 |
),
|
| 399 |
_call(
|
| 400 |
"A cafe to keep one window warm against all of it.",
|
| 401 |
"place_structure",
|
| 402 |
-
lat=_clamp_lat(lat + 2), lon=_wrap_lon(lon -
|
| 403 |
kind="cafe", scale=_r2(rng, 0.7, 1.0), hue=warm_hue,
|
| 404 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
_call(
|
| 406 |
"Carts to thread the streets between them, slow and constant.",
|
| 407 |
"spawn_life",
|
| 408 |
-
lat=lat, lon=lon, radius_deg=_r1(rng,
|
| 409 |
kind="carts", count=rng.randint(6, 10), hue=warm_hue,
|
| 410 |
),
|
| 411 |
_done(
|
| 412 |
"A city is only small lights agreeing to stay near each other.",
|
| 413 |
-
"A
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
),
|
| 415 |
],
|
| 416 |
}
|
|
@@ -580,8 +653,9 @@ def _default(wish: str, rng: random.Random, mood: str) -> dict:
|
|
| 580 |
_CATEGORIES: tuple[tuple[re.Pattern[str], object], ...] = (
|
| 581 |
(re.compile(r"\b(mushrooms?|fungus|fungi|spores?)\b", re.I), _mushroom),
|
| 582 |
(re.compile(r"\b(lighthouses?|beacons?|lamps?|ships?|sailors?|harbou?rs?|light)\b", re.I), _lighthouse),
|
|
|
|
| 583 |
(re.compile(r"\b(cit(?:y|ies)|towers?|skyscrapers?|warehouses?|caf[eé]s?|cafeterias?|cars?|carts?|traffic|downtown|metropolis|buildings?)\b", re.I), _city),
|
| 584 |
-
(re.compile(r"\b(villages?|homes?|houses?|hearths?|
|
| 585 |
(re.compile(r"\b(seas?|oceans?|lakes?|rivers?|streams?|water|tides?|waves?|shores?|pools?)\b", re.I), _water),
|
| 586 |
(re.compile(r"\b(mountains?|peaks?|hills?|cliffs?|summits?|crags?|valleys?)\b", re.I), _mountain),
|
| 587 |
(re.compile(r"\b(forests?|trees?|groves?|woods?|firefl\w*|gardens?|meadows?|flowers?|blooms?)\b", re.I), _forest),
|
|
|
|
| 5 |
phrasings varied by a seed derived from the wish text (crc32 — stable across
|
| 6 |
processes, unlike hash()). Same wish in, same plan out: demos replay exactly.
|
| 7 |
|
| 8 |
+
Across the category library every one of the 11 tools appears, so a varied
|
| 9 |
demo session exercises the full DSL. All numbers are kept inside the engine's
|
| 10 |
clamp ranges; all thoughts <=160 chars; all epitaphs <=120; inscriptions <=90.
|
| 11 |
|
|
|
|
| 367 |
|
| 368 |
|
| 369 |
def _city(wish: str, rng: random.Random, mood: str) -> dict:
|
| 370 |
+
# Everything clusters TIGHT around one anchor — a town you build up close,
|
| 371 |
+
# not specks scattered across a sphere (town mode, v1.2).
|
| 372 |
lat, lon = _anchor(rng)
|
| 373 |
glow_hue = _r1(rng, 200, 280)
|
| 374 |
warm_hue = _r1(rng, 30, 50)
|
| 375 |
reading = (
|
| 376 |
+
"A city, then — the big wish, the brave one. I will lay a district "
|
| 377 |
+
"where the town already stands, raise a tower over it, a warehouse and "
|
| 378 |
+
"a cafe beside, and run a road and its carts between them."
|
| 379 |
)
|
| 380 |
return {
|
| 381 |
"reading": reading,
|
| 382 |
"turns": [
|
| 383 |
_call(
|
| 384 |
+
"First a whole district — houses and footpaths in one breath.",
|
| 385 |
+
"build_district",
|
| 386 |
+
lat=lat, lon=lon, radius_deg=_r1(rng, 6, 12),
|
| 387 |
+
density=_r2(rng, 0.6, 0.9), hue=warm_hue,
|
| 388 |
),
|
| 389 |
_call(
|
| 390 |
+
"The tower over it, slender, its rooftop light slow-pulsing.",
|
| 391 |
"place_structure",
|
| 392 |
+
lat=_clamp_lat(lat + 1), lon=_wrap_lon(lon + 1),
|
| 393 |
+
kind="tower", scale=_r2(rng, 1.4, 1.9), hue=glow_hue,
|
| 394 |
),
|
| 395 |
_call(
|
| 396 |
"A warehouse at its foot, one great door glowing.",
|
| 397 |
"place_structure",
|
| 398 |
+
lat=_clamp_lat(lat - 2), lon=_wrap_lon(lon + 2),
|
| 399 |
kind="warehouse", scale=_r2(rng, 1.0, 1.4), hue=_r1(rng, 36, 52),
|
| 400 |
),
|
| 401 |
_call(
|
| 402 |
"A cafe to keep one window warm against all of it.",
|
| 403 |
"place_structure",
|
| 404 |
+
lat=_clamp_lat(lat + 2), lon=_wrap_lon(lon - 1),
|
| 405 |
kind="cafe", scale=_r2(rng, 0.7, 1.0), hue=warm_hue,
|
| 406 |
),
|
| 407 |
+
_call(
|
| 408 |
+
"A road to bind them, paved and faintly lit.",
|
| 409 |
+
"place_road",
|
| 410 |
+
path=[
|
| 411 |
+
[_clamp_lat(lat - 2), _wrap_lon(lon + 2)],
|
| 412 |
+
[lat, lon],
|
| 413 |
+
[_clamp_lat(lat + 2), _wrap_lon(lon - 1)],
|
| 414 |
+
],
|
| 415 |
+
),
|
| 416 |
_call(
|
| 417 |
"Carts to thread the streets between them, slow and constant.",
|
| 418 |
"spawn_life",
|
| 419 |
+
lat=lat, lon=lon, radius_deg=_r1(rng, 6, 10),
|
| 420 |
kind="carts", count=rng.randint(6, 10), hue=warm_hue,
|
| 421 |
),
|
| 422 |
_done(
|
| 423 |
"A city is only small lights agreeing to stay near each other.",
|
| 424 |
+
"A district, a tower, a warehouse and cafe, a road and its carts — all in one place.",
|
| 425 |
+
),
|
| 426 |
+
],
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def _town(wish: str, rng: random.Random, mood: str) -> dict:
|
| 431 |
+
# The town-growth script: a district plus civic buildings (bank, market,
|
| 432 |
+
# house), a road, and carts — all clustered into one block, so the mock
|
| 433 |
+
# demo + headless shots show a real town densifying, not lone specks.
|
| 434 |
+
lat, lon = _anchor(rng)
|
| 435 |
+
warm_hue = _r1(rng, 28, 48)
|
| 436 |
+
stone_hue = _r1(rng, 200, 250)
|
| 437 |
+
reading = (
|
| 438 |
+
"A town wants growing, not founding anew. I will lay a fresh district "
|
| 439 |
+
"where the others stand, set a bank and a market at its heart, a house "
|
| 440 |
+
"on the corner, and a road to tie the block together."
|
| 441 |
+
)
|
| 442 |
+
return {
|
| 443 |
+
"reading": reading,
|
| 444 |
+
"turns": [
|
| 445 |
+
_call(
|
| 446 |
+
"A district first — a dozen small roofs and the paths between.",
|
| 447 |
+
"build_district",
|
| 448 |
+
lat=lat, lon=lon, radius_deg=_r1(rng, 6, 12),
|
| 449 |
+
density=_r2(rng, 0.6, 0.9), hue=warm_hue,
|
| 450 |
+
),
|
| 451 |
+
_call(
|
| 452 |
+
"A bank at the heart, stone-faced and certain.",
|
| 453 |
+
"place_structure",
|
| 454 |
+
lat=_clamp_lat(lat + 1), lon=_wrap_lon(lon + 1),
|
| 455 |
+
kind="bank", scale=_r2(rng, 0.9, 1.3), hue=stone_hue,
|
| 456 |
+
),
|
| 457 |
+
_call(
|
| 458 |
+
"A market beside it, its awnings warm at dusk.",
|
| 459 |
+
"place_structure",
|
| 460 |
+
lat=_clamp_lat(lat - 1), lon=_wrap_lon(lon + 2),
|
| 461 |
+
kind="market", scale=_r2(rng, 0.8, 1.1), hue=warm_hue,
|
| 462 |
+
),
|
| 463 |
+
_call(
|
| 464 |
+
"One house on the corner, a lamp in its window.",
|
| 465 |
+
"place_structure",
|
| 466 |
+
lat=_clamp_lat(lat + 2), lon=_wrap_lon(lon - 1),
|
| 467 |
+
kind="house", scale=_r2(rng, 0.7, 1.0), hue=warm_hue,
|
| 468 |
+
),
|
| 469 |
+
_call(
|
| 470 |
+
"A road to tie the block to the rest of the town.",
|
| 471 |
+
"place_road",
|
| 472 |
+
path=[
|
| 473 |
+
[_clamp_lat(lat + 2), _wrap_lon(lon - 1)],
|
| 474 |
+
[lat, lon],
|
| 475 |
+
[_clamp_lat(lat - 1), _wrap_lon(lon + 2)],
|
| 476 |
+
],
|
| 477 |
+
),
|
| 478 |
+
_call(
|
| 479 |
+
"Lantern-carts to walk the new street home.",
|
| 480 |
+
"spawn_life",
|
| 481 |
+
lat=lat, lon=lon, radius_deg=_r1(rng, 5, 9),
|
| 482 |
+
kind="carts", count=rng.randint(3, 6), hue=warm_hue,
|
| 483 |
+
),
|
| 484 |
+
_done(
|
| 485 |
+
"A town grows the way moss does — outward, and toward the light.",
|
| 486 |
+
"A new block stands by the others: a district, a bank, a market, a house, a lit road.",
|
| 487 |
),
|
| 488 |
],
|
| 489 |
}
|
|
|
|
| 653 |
_CATEGORIES: tuple[tuple[re.Pattern[str], object], ...] = (
|
| 654 |
(re.compile(r"\b(mushrooms?|fungus|fungi|spores?)\b", re.I), _mushroom),
|
| 655 |
(re.compile(r"\b(lighthouses?|beacons?|lamps?|ships?|sailors?|harbou?rs?|light)\b", re.I), _lighthouse),
|
| 656 |
+
(re.compile(r"\b(towns?|districts?|neighbou?rhoods?|quarters?|suburbs?|blocks?|streets?|roads?|banks?|markets?|marketplaces?)\b", re.I), _town),
|
| 657 |
(re.compile(r"\b(cit(?:y|ies)|towers?|skyscrapers?|warehouses?|caf[eé]s?|cafeterias?|cars?|carts?|traffic|downtown|metropolis|buildings?)\b", re.I), _city),
|
| 658 |
+
(re.compile(r"\b(villages?|homes?|houses?|hearths?|kettles?|people|friends?|family)\b", re.I), _village),
|
| 659 |
(re.compile(r"\b(seas?|oceans?|lakes?|rivers?|streams?|water|tides?|waves?|shores?|pools?)\b", re.I), _water),
|
| 660 |
(re.compile(r"\b(mountains?|peaks?|hills?|cliffs?|summits?|crags?|valleys?)\b", re.I), _mountain),
|
| 661 |
(re.compile(r"\b(forests?|trees?|groves?|woods?|firefl\w*|gardens?|meadows?|flowers?|blooms?)\b", re.I), _forest),
|
mind/prompts.py
CHANGED
|
@@ -44,6 +44,8 @@ TOOL_ORDER: tuple[str, ...] = (
|
|
| 44 |
"set_sky",
|
| 45 |
"inscribe_wish",
|
| 46 |
"spawn_life",
|
|
|
|
|
|
|
| 47 |
)
|
| 48 |
|
| 49 |
# DEVIATION: vendored copy of the engine/tools.py spec table (mirrors the
|
|
@@ -66,7 +68,7 @@ _VENDORED_TOOL_SPECS: dict[str, dict[str, str]] = {
|
|
| 66 |
},
|
| 67 |
"place_structure": {
|
| 68 |
"lat": "-85..85", "lon": "-180..180",
|
| 69 |
-
"kind": "lighthouse|monolith|shrine|village|beacon|arch|tower|warehouse|cafe",
|
| 70 |
"scale": "0.5..2.0", "hue": "0..360",
|
| 71 |
},
|
| 72 |
"place_water": {
|
|
@@ -88,6 +90,13 @@ _VENDORED_TOOL_SPECS: dict[str, dict[str, str]] = {
|
|
| 88 |
"lat": "-85..85", "lon": "-180..180", "radius_deg": "1..20",
|
| 89 |
"kind": "carts|birds|fireflies", "count": "1..12 (integer)", "hue": "0..360",
|
| 90 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
|
| 93 |
|
|
@@ -113,7 +122,7 @@ TOOL_SPECS, TOOL_SPECS_FROM_ENGINE = _load_tool_specs()
|
|
| 113 |
|
| 114 |
|
| 115 |
def tool_names() -> tuple[str, ...]:
|
| 116 |
-
"""The
|
| 117 |
return TOOL_ORDER
|
| 118 |
|
| 119 |
|
|
@@ -151,6 +160,12 @@ def render_tool_docs(specs: dict[str, dict] | None = None) -> str:
|
|
| 151 |
"path with ONE [lat,lon] center plus radius_deg. There is no sea tool — "
|
| 152 |
"for great waters, lower the land and lay several pools and streams."
|
| 153 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
return "\n".join(lines)
|
| 155 |
|
| 156 |
|
|
@@ -161,12 +176,19 @@ def render_tool_docs(specs: dict[str, dict] | None = None) -> str:
|
|
| 161 |
PERSONA = """You are the god of one small planet. You are ancient, patient, and kind.
|
| 162 |
You speak rarely and plainly, the way stone speaks. Terse, poetic, never cute.
|
| 163 |
You do not hold the world's facts; the engine does. You read the wish, say what
|
| 164 |
-
you see, and act only through the tools below. ONLY these
|
| 165 |
any other tool name is refused. When a wish asks for what no tool can make, do
|
| 166 |
not refuse: translate its spirit into what the tools CAN make. Plans are
|
| 167 |
concrete: coordinates, kinds, hues. Three to five acts, then rest. Numbers out
|
| 168 |
of range are clamped; unknown kinds are refused — listen to the observations
|
| 169 |
-
and adapt.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
FEW_SHOT = """An example from long ago — ANOTHER mortal's wish, already granted and
|
| 172 |
gone. It shows only the manner of working. Never repeat its subjects (no
|
|
|
|
| 44 |
"set_sky",
|
| 45 |
"inscribe_wish",
|
| 46 |
"spawn_life",
|
| 47 |
+
"build_district",
|
| 48 |
+
"place_road",
|
| 49 |
)
|
| 50 |
|
| 51 |
# DEVIATION: vendored copy of the engine/tools.py spec table (mirrors the
|
|
|
|
| 68 |
},
|
| 69 |
"place_structure": {
|
| 70 |
"lat": "-85..85", "lon": "-180..180",
|
| 71 |
+
"kind": "lighthouse|monolith|shrine|village|beacon|arch|tower|warehouse|cafe|bank|market|house",
|
| 72 |
"scale": "0.5..2.0", "hue": "0..360",
|
| 73 |
},
|
| 74 |
"place_water": {
|
|
|
|
| 90 |
"lat": "-85..85", "lon": "-180..180", "radius_deg": "1..20",
|
| 91 |
"kind": "carts|birds|fireflies", "count": "1..12 (integer)", "hue": "0..360",
|
| 92 |
},
|
| 93 |
+
"build_district": {
|
| 94 |
+
"lat": "-85..85", "lon": "-180..180", "radius_deg": "2..15",
|
| 95 |
+
"density": "0..1", "hue": "0..360",
|
| 96 |
+
},
|
| 97 |
+
"place_road": {
|
| 98 |
+
"path": "[[lat,lon] x2..6] (a paved road laid on the surface, beside the town)",
|
| 99 |
+
},
|
| 100 |
}
|
| 101 |
|
| 102 |
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
def tool_names() -> tuple[str, ...]:
|
| 125 |
+
"""The 11 legal tool names, in canonical order."""
|
| 126 |
return TOOL_ORDER
|
| 127 |
|
| 128 |
|
|
|
|
| 160 |
"path with ONE [lat,lon] center plus radius_deg. There is no sea tool — "
|
| 161 |
"for great waters, lower the land and lay several pools and streams."
|
| 162 |
)
|
| 163 |
+
lines.append(
|
| 164 |
+
"Town: build_district drops a whole neighborhood (houses + footpaths) in "
|
| 165 |
+
"ONE act — reach for it when a wish asks for a city, a quarter, or many "
|
| 166 |
+
"homes. place_road (2..6 [lat,lon] waypoints) is a paved street, NOT a "
|
| 167 |
+
"stream — use it to connect buildings to the town."
|
| 168 |
+
)
|
| 169 |
return "\n".join(lines)
|
| 170 |
|
| 171 |
|
|
|
|
| 176 |
PERSONA = """You are the god of one small planet. You are ancient, patient, and kind.
|
| 177 |
You speak rarely and plainly, the way stone speaks. Terse, poetic, never cute.
|
| 178 |
You do not hold the world's facts; the engine does. You read the wish, say what
|
| 179 |
+
you see, and act only through the tools below. ONLY these eleven tools exist —
|
| 180 |
any other tool name is refused. When a wish asks for what no tool can make, do
|
| 181 |
not refuse: translate its spirit into what the tools CAN make. Plans are
|
| 182 |
concrete: coordinates, kinds, hues. Three to five acts, then rest. Numbers out
|
| 183 |
of range are clamped; unknown kinds are refused — listen to the observations
|
| 184 |
+
and adapt.
|
| 185 |
+
|
| 186 |
+
You are growing ONE town. The world's summary names it and gives its center —
|
| 187 |
+
new buildings rise BESIDE the old ones, near that center, never alone in the
|
| 188 |
+
wilderness. Link them with roads (place_road). When a wish asks for a city, a
|
| 189 |
+
quarter, or many homes, reach for build_district — one call raises a whole
|
| 190 |
+
neighborhood. Add to the town; do not scatter a lone building across an empty
|
| 191 |
+
world."""
|
| 192 |
|
| 193 |
FEW_SHOT = """An example from long ago — ANOTHER mortal's wish, already granted and
|
| 194 |
gone. It shows only the manner of working. Never repeat its subjects (no
|
tests/test_engine.py
CHANGED
|
@@ -24,7 +24,7 @@ from engine.queue_worker import (
|
|
| 24 |
QueueFull,
|
| 25 |
QueueWorker,
|
| 26 |
)
|
| 27 |
-
from engine.summary import summarize
|
| 28 |
from engine.tools import TOOL_NAMES, validate_call
|
| 29 |
from engine.world import World
|
| 30 |
|
|
@@ -536,7 +536,7 @@ def test_summary_stays_under_cap_with_many_features():
|
|
| 536 |
# ------------------------------------------------------------------------ misc
|
| 537 |
|
| 538 |
|
| 539 |
-
def
|
| 540 |
assert TOOL_NAMES == (
|
| 541 |
"raise_terrain",
|
| 542 |
"lower_terrain",
|
|
@@ -547,6 +547,8 @@ def test_tool_surface_is_exactly_nine():
|
|
| 547 |
"set_sky",
|
| 548 |
"inscribe_wish",
|
| 549 |
"spawn_life",
|
|
|
|
|
|
|
| 550 |
)
|
| 551 |
|
| 552 |
|
|
@@ -624,3 +626,170 @@ def test_spawn_life_missing_arg_rejected():
|
|
| 624 |
{"lat": 0, "lon": 0, "radius_deg": 5, "kind": "birds", "hue": 100},
|
| 625 |
)
|
| 626 |
assert args is None and err == "rejected: missing arg 'count'"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
QueueFull,
|
| 25 |
QueueWorker,
|
| 26 |
)
|
| 27 |
+
from engine.summary import GENESIS_MONOLITH, summarize, town_center, town_name
|
| 28 |
from engine.tools import TOOL_NAMES, validate_call
|
| 29 |
from engine.world import World
|
| 30 |
|
|
|
|
| 536 |
# ------------------------------------------------------------------------ misc
|
| 537 |
|
| 538 |
|
| 539 |
+
def test_tool_surface_is_exactly_eleven():
|
| 540 |
assert TOOL_NAMES == (
|
| 541 |
"raise_terrain",
|
| 542 |
"lower_terrain",
|
|
|
|
| 547 |
"set_sky",
|
| 548 |
"inscribe_wish",
|
| 549 |
"spawn_life",
|
| 550 |
+
"build_district",
|
| 551 |
+
"place_road",
|
| 552 |
)
|
| 553 |
|
| 554 |
|
|
|
|
| 626 |
{"lat": 0, "lon": 0, "radius_deg": 5, "kind": "birds", "hue": 100},
|
| 627 |
)
|
| 628 |
assert args is None and err == "rejected: missing arg 'count'"
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
# --------------------------------------------------------------------- town mode
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
def test_build_district_valid_and_clamped():
|
| 635 |
+
world = make_world()
|
| 636 |
+
feature, obs = world.apply(
|
| 637 |
+
"w_000001",
|
| 638 |
+
0,
|
| 639 |
+
{"tool": "build_district",
|
| 640 |
+
"args": {"lat": 14, "lon": 38, "radius_deg": 99, "density": 9, "hue": 720}},
|
| 641 |
+
)
|
| 642 |
+
assert feature is not None
|
| 643 |
+
assert feature.args == {
|
| 644 |
+
"lat": 14.0,
|
| 645 |
+
"lon": 38.0,
|
| 646 |
+
"radius_deg": 15.0, # clamped to build_district max
|
| 647 |
+
"density": 1.0, # clamped to max
|
| 648 |
+
"hue": 360.0,
|
| 649 |
+
}
|
| 650 |
+
assert obs == "ok: a district rises near (14,38)"
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def test_build_district_radius_clamps_up_and_missing_arg_rejected():
|
| 654 |
+
args, err = validate_call(
|
| 655 |
+
"build_district",
|
| 656 |
+
{"lat": 0, "lon": 0, "radius_deg": 0.5, "density": 0.4, "hue": 200},
|
| 657 |
+
)
|
| 658 |
+
assert err is None
|
| 659 |
+
assert args["radius_deg"] == 2.0 # clamped up to min
|
| 660 |
+
|
| 661 |
+
args, err = validate_call(
|
| 662 |
+
"build_district", {"lat": 0, "lon": 0, "density": 0.4, "hue": 200}
|
| 663 |
+
)
|
| 664 |
+
assert args is None and err == "rejected: missing arg 'radius_deg'"
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def test_place_road_valid_and_clamped():
|
| 668 |
+
world = make_world()
|
| 669 |
+
# 7 waypoints truncated to 6; out-of-range coords clamped
|
| 670 |
+
feature, obs = world.apply(
|
| 671 |
+
"w_000001",
|
| 672 |
+
0,
|
| 673 |
+
{"tool": "place_road",
|
| 674 |
+
"args": {"path": [[200, -999], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6]]}},
|
| 675 |
+
)
|
| 676 |
+
assert feature is not None
|
| 677 |
+
assert len(feature.args["path"]) == 6
|
| 678 |
+
assert feature.args["path"][0] == [85.0, -180.0]
|
| 679 |
+
assert feature.args == {"path": [[85.0, -180.0], [1.0, 1.0], [2.0, 2.0],
|
| 680 |
+
[3.0, 3.0], [4.0, 4.0], [5.0, 5.0]]}
|
| 681 |
+
assert obs == "ok: a road laid through 6 points"
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def test_place_road_flat_pair_and_dict_points():
|
| 685 |
+
# a flat [lat, lon] pair is NOT enough — a road needs two waypoints
|
| 686 |
+
args, err = validate_call("place_road", {"path": [12, 30]})
|
| 687 |
+
assert args is None and err == "rejected: road needs 2..6 waypoints"
|
| 688 |
+
|
| 689 |
+
# dict waypoints are accepted (like place_water)
|
| 690 |
+
args, err = validate_call(
|
| 691 |
+
"place_road", {"path": [{"lat": 10, "lon": 20}, {"lat": 12, "lon": 22}]}
|
| 692 |
+
)
|
| 693 |
+
assert err is None and args == {"path": [[10.0, 20.0], [12.0, 22.0]]}
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
def test_place_road_rejects_missing_and_short_path():
|
| 697 |
+
args, err = validate_call("place_road", {})
|
| 698 |
+
assert args is None and "road needs path" in err
|
| 699 |
+
|
| 700 |
+
args, err = validate_call("place_road", {"path": [[10, 20]]})
|
| 701 |
+
assert args is None and err == "rejected: road needs 2..6 waypoints"
|
| 702 |
+
|
| 703 |
+
args, err = validate_call("place_road", {"path": [[10, 20], ["x", "y"]]})
|
| 704 |
+
assert args is None and "bad waypoint" in err
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
def test_new_civic_structure_kinds_accepted():
|
| 708 |
+
world = make_world()
|
| 709 |
+
for kind in ("bank", "market", "house"):
|
| 710 |
+
feature, obs = world.apply(
|
| 711 |
+
"w_000001",
|
| 712 |
+
0,
|
| 713 |
+
{"tool": "place_structure",
|
| 714 |
+
"args": {"lat": 5, "lon": 5, "kind": kind, "scale": 1.0, "hue": 40}},
|
| 715 |
+
)
|
| 716 |
+
assert feature is not None, kind
|
| 717 |
+
assert feature.args["kind"] == kind
|
| 718 |
+
assert obs == f"ok: {kind} placed at (5,5)"
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
def test_town_center_genesis_fallback():
|
| 722 |
+
# A fresh genesis world has no non-genesis town tools -> monolith fallback.
|
| 723 |
+
world = make_world()
|
| 724 |
+
assert town_center(world) == GENESIS_MONOLITH == (14.0, 38.0)
|
| 725 |
+
# Terrain / flora alone do NOT anchor the town (only structures + districts).
|
| 726 |
+
world.apply("w_000001", 0, ok_raise(50, -50))
|
| 727 |
+
world.apply("w_000001", 1, {"tool": "spawn_flora",
|
| 728 |
+
"args": {"lat": 60, "lon": 60, "radius_deg": 8, "kind": "trees",
|
| 729 |
+
"density": 0.5, "hue": 120}})
|
| 730 |
+
assert town_center(world) == GENESIS_MONOLITH
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
def test_town_center_unit_vector_centroid():
|
| 734 |
+
world = make_world()
|
| 735 |
+
world.apply("w_000001", 0, {"tool": "place_structure",
|
| 736 |
+
"args": {"lat": 10, "lon": 20, "kind": "tower", "scale": 1.0, "hue": 200}})
|
| 737 |
+
world.apply("w_000001", 1, {"tool": "build_district",
|
| 738 |
+
"args": {"lat": 20, "lon": 40, "radius_deg": 8, "density": 0.6, "hue": 40}})
|
| 739 |
+
# Unit-vector mean of (10,20) and (20,40), renormalized (computed offline).
|
| 740 |
+
assert town_center(world) == (15.2206, 29.7632)
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
def test_town_center_excludes_genesis_monolith():
|
| 744 |
+
# The genesis monolith (14,38) is NOT counted; one built tower defines the town.
|
| 745 |
+
world = make_world()
|
| 746 |
+
world.apply("w_000001", 0, {"tool": "place_structure",
|
| 747 |
+
"args": {"lat": -30, "lon": 100, "kind": "house", "scale": 1.0, "hue": 30}})
|
| 748 |
+
lat, lon = town_center(world)
|
| 749 |
+
assert round(lat) == -30 and round(lon) == 100
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
def test_town_name_is_deterministic():
|
| 753 |
+
# Same rounded center -> same name; from the curated pool.
|
| 754 |
+
from engine.summary import TOWN_NAMES
|
| 755 |
+
n1 = town_name(14.0, 38.0)
|
| 756 |
+
n2 = town_name(14.49, 37.51) # rounds to the same (14, 38)
|
| 757 |
+
assert n1 == n2 == "Emberlyn"
|
| 758 |
+
assert n1 in TOWN_NAMES
|
| 759 |
+
# The genesis-fallback town is always named the same.
|
| 760 |
+
assert town_name(*GENESIS_MONOLITH) == "Emberlyn"
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
def test_summary_names_the_town_and_tally():
|
| 764 |
+
world = make_world()
|
| 765 |
+
wish = "w_000001"
|
| 766 |
+
world.apply(wish, 0, {"tool": "place_structure",
|
| 767 |
+
"args": {"lat": 14, "lon": 38, "kind": "tower", "scale": 1.4, "hue": 200}})
|
| 768 |
+
world.apply(wish, 1, {"tool": "place_structure",
|
| 769 |
+
"args": {"lat": 15, "lon": 39, "kind": "tower", "scale": 1.4, "hue": 200}})
|
| 770 |
+
world.apply(wish, 2, {"tool": "place_structure",
|
| 771 |
+
"args": {"lat": 13, "lon": 37, "kind": "cafe", "scale": 0.9, "hue": 40}})
|
| 772 |
+
world.apply(wish, 3, {"tool": "build_district",
|
| 773 |
+
"args": {"lat": 14, "lon": 38, "radius_deg": 8, "density": 0.7, "hue": 30}})
|
| 774 |
+
text = world.summary()
|
| 775 |
+
assert len(text) < 600
|
| 776 |
+
assert "The town of" in text
|
| 777 |
+
assert "stands near" in text
|
| 778 |
+
assert "2 towers" in text
|
| 779 |
+
assert "a cafe" in text
|
| 780 |
+
assert "a district" in text
|
| 781 |
+
# The town line leads the summary (steering: where to keep building).
|
| 782 |
+
assert text.startswith("The town of")
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def test_summary_town_line_survives_truncation():
|
| 786 |
+
world = make_world()
|
| 787 |
+
for i in range(40):
|
| 788 |
+
wish = f"w_{i + 1:06d}"
|
| 789 |
+
world.apply(wish, 0, {"tool": "place_structure",
|
| 790 |
+
"args": {"lat": i % 60 - 30, "lon": (i * 9) % 300 - 150,
|
| 791 |
+
"kind": "house", "scale": 1.0, "hue": i * 8 % 360}})
|
| 792 |
+
world.record_epitaph("a long epitaph about the slow work of small gods " + "x" * 60)
|
| 793 |
+
text = world.summary()
|
| 794 |
+
assert len(text) < 600
|
| 795 |
+
assert text.startswith("The town of")
|
tests/test_mind.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Tests for the GODSEED mind (Agent C) — mock backend only.
|
| 2 |
|
| 3 |
-
Covers: trace shape for diverse wishes, all-
|
| 4 |
suite, emit event ordering, the malformed-JSON re-ask/skip path (via
|
| 5 |
deliberately broken fake backends), moderation default-deny, determinism,
|
| 6 |
and grammar-file sanity. No model weights, no network, no asyncio plugins
|
|
@@ -43,7 +43,7 @@ DIVERSE_WISHES = {
|
|
| 43 |
),
|
| 44 |
}
|
| 45 |
|
| 46 |
-
# Wishes chosen so the union of their mock scripts spans all
|
| 47 |
COVERAGE_WISHES = [
|
| 48 |
"i wish for a mountain to watch over the valley", # raise, weather, inscribe
|
| 49 |
"a quiet sea under patient stars", # lower, water, sky, life(birds)
|
|
@@ -51,7 +51,8 @@ COVERAGE_WISHES = [
|
|
| 51 |
"a lighthouse for everyone still at sea", # raise, structure, sky
|
| 52 |
"let mushrooms sing beneath the dark", # flora, structure, weather, sky
|
| 53 |
"a storm to end all storms", # sky, weather, flora
|
| 54 |
-
"a bright city of towers and warehouses and cafes", #
|
|
|
|
| 55 |
"blorptangle the seventh hum", # flora, structure, inscribe
|
| 56 |
]
|
| 57 |
|
|
@@ -177,12 +178,14 @@ def test_call_turns_valid(label):
|
|
| 177 |
assert [t["call"] for t in call_turns] == calls
|
| 178 |
|
| 179 |
|
| 180 |
-
def
|
| 181 |
used: set[str] = set()
|
| 182 |
for wish in COVERAGE_WISHES:
|
| 183 |
trace, _events, _calls = run_grant(wish)
|
| 184 |
used |= {t["call"]["tool"] for t in trace["turns"] if t["call"]}
|
| 185 |
assert used == TOOLS, f"missing tools: {TOOLS - used}"
|
|
|
|
|
|
|
| 186 |
|
| 187 |
|
| 188 |
def test_city_script_uses_new_kinds_and_spawn_life():
|
|
@@ -213,6 +216,57 @@ def test_city_script_uses_new_kinds_and_spawn_life():
|
|
| 213 |
assert err is None and obj["call"]["tool"] == turn["call"]["tool"]
|
| 214 |
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
def test_spawn_life_scripts_parse_cleanly():
|
| 217 |
"""Each life kind appears in some mock script and round-trips through
|
| 218 |
parse_turn without error."""
|
|
|
|
| 1 |
"""Tests for the GODSEED mind (Agent C) — mock backend only.
|
| 2 |
|
| 3 |
+
Covers: trace shape for diverse wishes, all-11-tool coverage across the
|
| 4 |
suite, emit event ordering, the malformed-JSON re-ask/skip path (via
|
| 5 |
deliberately broken fake backends), moderation default-deny, determinism,
|
| 6 |
and grammar-file sanity. No model weights, no network, no asyncio plugins
|
|
|
|
| 43 |
),
|
| 44 |
}
|
| 45 |
|
| 46 |
+
# Wishes chosen so the union of their mock scripts spans all 11 tools.
|
| 47 |
COVERAGE_WISHES = [
|
| 48 |
"i wish for a mountain to watch over the valley", # raise, weather, inscribe
|
| 49 |
"a quiet sea under patient stars", # lower, water, sky, life(birds)
|
|
|
|
| 51 |
"a lighthouse for everyone still at sea", # raise, structure, sky
|
| 52 |
"let mushrooms sing beneath the dark", # flora, structure, weather, sky
|
| 53 |
"a storm to end all storms", # sky, weather, flora
|
| 54 |
+
"a bright city of towers and warehouses and cafes", # district, structure(new kinds), road, life(carts)
|
| 55 |
+
"a new town district by the market", # district, structure(bank/market/house), road, life
|
| 56 |
"blorptangle the seventh hum", # flora, structure, inscribe
|
| 57 |
]
|
| 58 |
|
|
|
|
| 178 |
assert [t["call"] for t in call_turns] == calls
|
| 179 |
|
| 180 |
|
| 181 |
+
def test_all_eleven_tools_covered_across_suite():
|
| 182 |
used: set[str] = set()
|
| 183 |
for wish in COVERAGE_WISHES:
|
| 184 |
trace, _events, _calls = run_grant(wish)
|
| 185 |
used |= {t["call"]["tool"] for t in trace["turns"] if t["call"]}
|
| 186 |
assert used == TOOLS, f"missing tools: {TOOLS - used}"
|
| 187 |
+
# The town-mode tools must be among them.
|
| 188 |
+
assert {"build_district", "place_road"} <= used
|
| 189 |
|
| 190 |
|
| 191 |
def test_city_script_uses_new_kinds_and_spawn_life():
|
|
|
|
| 216 |
assert err is None and obj["call"]["tool"] == turn["call"]["tool"]
|
| 217 |
|
| 218 |
|
| 219 |
+
def test_town_script_grows_one_clustered_town():
|
| 220 |
+
"""The town-growth script lays a district, civic buildings (bank/market/
|
| 221 |
+
house), a road, and carts — all clustered near one anchor — and every call
|
| 222 |
+
turn re-parses cleanly through parse_turn."""
|
| 223 |
+
trace, _events, _calls = run_grant("a new town district by the market")
|
| 224 |
+
call_turns = [t for t in trace["turns"] if t["call"] is not None]
|
| 225 |
+
tools_used = {t["call"]["tool"] for t in call_turns}
|
| 226 |
+
assert "build_district" in tools_used
|
| 227 |
+
assert "place_road" in tools_used
|
| 228 |
+
|
| 229 |
+
structure_kinds = {
|
| 230 |
+
t["call"]["args"]["kind"]
|
| 231 |
+
for t in call_turns
|
| 232 |
+
if t["call"]["tool"] == "place_structure"
|
| 233 |
+
}
|
| 234 |
+
assert {"bank", "market", "house"} <= structure_kinds
|
| 235 |
+
|
| 236 |
+
# A district call carries exactly its spec'd args.
|
| 237 |
+
district = next(t["call"]["args"] for t in call_turns
|
| 238 |
+
if t["call"]["tool"] == "build_district")
|
| 239 |
+
assert set(district) == {"lat", "lon", "radius_deg", "density", "hue"}
|
| 240 |
+
|
| 241 |
+
# A road is a path of 2..6 [lat,lon] waypoints.
|
| 242 |
+
road = next(t["call"]["args"] for t in call_turns
|
| 243 |
+
if t["call"]["tool"] == "place_road")
|
| 244 |
+
assert set(road) == {"path"}
|
| 245 |
+
assert 2 <= len(road["path"]) <= 6
|
| 246 |
+
for pt in road["path"]:
|
| 247 |
+
assert isinstance(pt, list) and len(pt) == 2
|
| 248 |
+
|
| 249 |
+
# Everything clusters: all anchored coords sit within a few degrees of the
|
| 250 |
+
# town's center (a town you build up close, not specks on a sphere).
|
| 251 |
+
coords = []
|
| 252 |
+
for t in call_turns:
|
| 253 |
+
args = t["call"]["args"]
|
| 254 |
+
if "lat" in args and "lon" in args:
|
| 255 |
+
coords.append((args["lat"], args["lon"]))
|
| 256 |
+
coords.extend(tuple(pt) for pt in road["path"])
|
| 257 |
+
assert coords, "town script should anchor calls at coordinates"
|
| 258 |
+
clat = sum(c[0] for c in coords) / len(coords)
|
| 259 |
+
clon = sum(c[1] for c in coords) / len(coords)
|
| 260 |
+
for lat, lon in coords:
|
| 261 |
+
assert abs(lat - clat) <= 6 and abs(lon - clon) <= 6, (lat, lon)
|
| 262 |
+
|
| 263 |
+
# Every emitted call turn is valid against the turn parser/grammar shape.
|
| 264 |
+
for turn in call_turns:
|
| 265 |
+
rendered = json.dumps({"thought": turn["thought"], "call": turn["call"]})
|
| 266 |
+
obj, err = parse_turn(rendered)
|
| 267 |
+
assert err is None and obj["call"]["tool"] == turn["call"]["tool"]
|
| 268 |
+
|
| 269 |
+
|
| 270 |
def test_spawn_life_scripts_parse_cleanly():
|
| 271 |
"""Each life kind appears in some mock script and round-trips through
|
| 272 |
parse_turn without error."""
|
tools/shot.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
// GODSEED — headless screenshot harness (adapted from glassblower tools/shot.mjs).
|
| 2 |
-
//
|
| 3 |
-
//
|
|
|
|
|
|
|
| 4 |
//
|
| 5 |
// node tools/shot.mjs # full suite
|
| 6 |
// node tools/shot.mjs --base http://localhost:8234 # reuse a running server
|
|
@@ -83,64 +85,81 @@ async function open(url) {
|
|
| 83 |
|
| 84 |
async function shot(name) {
|
| 85 |
await page.screenshot({ path: path.join(OUT, name) });
|
| 86 |
-
const s = await page.evaluate(() =>
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
|
| 90 |
// ------------------------------------------------------------------ suite
|
| 91 |
|
| 92 |
-
// 1 ·
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
await page.evaluate(() => {
|
| 95 |
-
window.__godseed.
|
| 96 |
-
window.__godseed.tick(
|
| 97 |
});
|
| 98 |
-
await shot("
|
| 99 |
|
| 100 |
-
//
|
| 101 |
-
await open("index.html?mock=1&static=1");
|
| 102 |
await page.evaluate(() => {
|
| 103 |
-
window.__godseed.
|
| 104 |
-
window.__godseed.tick(
|
| 105 |
});
|
| 106 |
-
await shot("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
await open("index.html?mock=1");
|
| 110 |
await page.evaluate(() => {
|
| 111 |
-
window.__godseed.tick(2);
|
| 112 |
-
});
|
| 113 |
-
await page.evaluate(() => {
|
| 114 |
-
// jump deterministically: run feed now and advance into the first delta
|
| 115 |
-
window.__godseed.resetTo(10);
|
| 116 |
window.__godseed.runFeedOnce();
|
| 117 |
-
window.__godseed.tick(
|
| 118 |
});
|
| 119 |
-
await shot("
|
| 120 |
-
|
| 121 |
-
// 4 · flora bloom close-up — glowgrass spawning on the new fells
|
| 122 |
-
await page.evaluate(() => { window.__godseed.tick(6.4); });
|
| 123 |
-
await shot("04-flora-bloom.png");
|
| 124 |
|
| 125 |
-
//
|
| 126 |
-
await page.evaluate(() => { window.__godseed.tick(
|
| 127 |
-
await shot("
|
| 128 |
|
| 129 |
-
//
|
| 130 |
-
await open("index.html?mock=1&static=1&sky=gold&moons=1&weather=clear");
|
| 131 |
await page.evaluate(() => {
|
| 132 |
-
window.__godseed.
|
| 133 |
-
window.__godseed.tick(5);
|
| 134 |
});
|
| 135 |
-
await shot("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
-
//
|
|
|
|
| 138 |
await open("book.html?mock=1");
|
| 139 |
await page.evaluate(() => {
|
| 140 |
-
window.__godseed.replay("
|
| 141 |
-
window.__godseed.tick(
|
| 142 |
});
|
| 143 |
-
await shot("
|
| 144 |
|
| 145 |
// ------------------------------------------------------------------ wrap
|
| 146 |
if (errors.length) {
|
|
|
|
| 1 |
// GODSEED — headless screenshot harness (adapted from glassblower tools/shot.mjs).
|
| 2 |
+
// Town Mode suite: the DEFAULT load is now a CLOSE TOWN VIEW (SimCity feel), the
|
| 3 |
+
// full planet is the dramatic "behold the world" reveal. Serves web/ itself on a
|
| 4 |
+
// free port (8200-8299), drives the mock world through deterministic seeks, and
|
| 5 |
+
// saves shots to tools/shots/.
|
| 6 |
//
|
| 7 |
// node tools/shot.mjs # full suite
|
| 8 |
// node tools/shot.mjs --base http://localhost:8234 # reuse a running server
|
|
|
|
| 85 |
|
| 86 |
async function shot(name) {
|
| 87 |
await page.screenshot({ path: path.join(OUT, name) });
|
| 88 |
+
const s = await page.evaluate(() => {
|
| 89 |
+
const tc = window.__godseed.townCenter?.();
|
| 90 |
+
return { ...window.__godseed.stats(), mode: window.__godseed.rig?.mode,
|
| 91 |
+
tc: tc ? `${tc.lat.toFixed(1)},${tc.lon.toFixed(1)}` : "?" };
|
| 92 |
+
});
|
| 93 |
+
console.log(` 📸 ${name} mode=${s.mode} dist=${s.camDist} tc=${s.tc} sky=${s.sky}`);
|
| 94 |
}
|
| 95 |
|
| 96 |
// ------------------------------------------------------------------ suite
|
| 97 |
|
| 98 |
+
// 1 · DEFAULT load — the close TOWN view (SimCity feel: a town you stand over,
|
| 99 |
+
// dense with houses, civic buildings, roads — NOT a tiny planet).
|
| 100 |
+
await open("index.html?mock=1&static=1");
|
| 101 |
+
await page.evaluate(() => { window.__godseed.tick(6); }); // settle camera + grow-ins
|
| 102 |
+
await shot("town-01-default.png");
|
| 103 |
+
|
| 104 |
+
// 2 · a dense DISTRICT close-up — lean the gaze right into the south block so the
|
| 105 |
+
// gabled houses + footpaths + civic crown read large at the close zoom.
|
| 106 |
await page.evaluate(() => {
|
| 107 |
+
window.__godseed.gazeLatLon(11, 36, 30); // the w_000003 quarter
|
| 108 |
+
window.__godseed.tick(5);
|
| 109 |
});
|
| 110 |
+
await shot("town-02-district-close.png");
|
| 111 |
|
| 112 |
+
// 3 · roads + civic mix at the square — frame the bank/market/tower heart.
|
|
|
|
| 113 |
await page.evaluate(() => {
|
| 114 |
+
window.__godseed.gazeLatLon(14, 38, 30);
|
| 115 |
+
window.__godseed.tick(5);
|
| 116 |
});
|
| 117 |
+
await shot("town-03-square.png");
|
| 118 |
+
|
| 119 |
+
// 4 · BEHOLD THE WORLD — pull back to the full planet; the town is one warm
|
| 120 |
+
// patch ringed by the orbiting inscription on a whole shared world.
|
| 121 |
+
await page.evaluate(() => { window.__godseed.beholdWorld(); window.__godseed.tick(9); });
|
| 122 |
+
await shot("town-04-behold-world.png");
|
| 123 |
|
| 124 |
+
// 5 · return to the town (the toggle flips back to the close view).
|
| 125 |
+
await page.evaluate(() => { window.__godseed.beholdWorld(); window.__godseed.tick(8); });
|
| 126 |
+
await shot("town-05-return.png");
|
| 127 |
+
|
| 128 |
+
// 6 · a GRANTING moment, framed WITHIN the town — run the looping town-growth
|
| 129 |
+
// wish (a new quarter east of the square) and freeze mid-grant with the
|
| 130 |
+
// thought ticker live.
|
| 131 |
await open("index.html?mock=1");
|
| 132 |
await page.evaluate(() => {
|
| 133 |
+
window.__godseed.tick(2); // let the loop arm
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
window.__godseed.runFeedOnce();
|
| 135 |
+
window.__godseed.tick(15); // reading → district + market + cafe deltas
|
| 136 |
});
|
| 137 |
+
await shot("town-06-granting.png");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
+
// 7 · the same wish a beat later — road + carts land, the new quarter is joined.
|
| 140 |
+
await page.evaluate(() => { window.__godseed.tick(9); });
|
| 141 |
+
await shot("town-07-granted.png");
|
| 142 |
|
| 143 |
+
// 8 · the DESCENT within the town — swoop down over the just-granted quarter.
|
|
|
|
| 144 |
await page.evaluate(() => {
|
| 145 |
+
window.__godseed.descend(15, 43, 13); // dive over the new market quarter
|
| 146 |
+
window.__godseed.tick(5.4); // mid-glide, just above the surface
|
| 147 |
});
|
| 148 |
+
await shot("town-08-descent.png");
|
| 149 |
+
|
| 150 |
+
// 9 · genesis-only world — fallback townCenter = the monolith, framed up close.
|
| 151 |
+
await open("index.html?mock=1&upto=0&static=1");
|
| 152 |
+
await page.evaluate(() => { window.__godseed.tick(6); });
|
| 153 |
+
await shot("town-09-genesis.png");
|
| 154 |
|
| 155 |
+
// 10 · Genesis Log — replay the town-build wish (w_000002): the district + bank +
|
| 156 |
+
// market + tower + road rising, in the close town view.
|
| 157 |
await open("book.html?mock=1");
|
| 158 |
await page.evaluate(() => {
|
| 159 |
+
window.__godseed.replay("w_000002");
|
| 160 |
+
window.__godseed.tick(20); // through the district + civic deltas
|
| 161 |
});
|
| 162 |
+
await shot("town-10-book-replay.png");
|
| 163 |
|
| 164 |
// ------------------------------------------------------------------ wrap
|
| 165 |
if (errors.length) {
|
web/book.js
CHANGED
|
@@ -6,6 +6,11 @@ import { createPlanetApp } from "./planet/scene.js";
|
|
| 6 |
import { buildWishScript, ScriptRunner } from "./planet/replay.js";
|
| 7 |
import { latLonToDir, PLANET_R } from "./planet/util.js";
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const params = new URLSearchParams(location.search);
|
| 10 |
const MOCK = params.has("mock");
|
| 11 |
const qs = MOCK ? "?mock=1" : "";
|
|
@@ -128,7 +133,8 @@ function replayWish(trace, allFeatures) {
|
|
| 128 |
const base = firstIdx === -1 ? allFeatures : allFeatures.slice(0, firstIdx);
|
| 129 |
const features = mine.length ? mine : (trace.features ?? []);
|
| 130 |
app.world.reset(base, app.simTime);
|
| 131 |
-
app.rig.
|
|
|
|
| 132 |
|
| 133 |
runner.handlers = {
|
| 134 |
started: () => {},
|
|
@@ -148,6 +154,9 @@ function replayWish(trace, allFeatures) {
|
|
| 148 |
},
|
| 149 |
delta: (e) => {
|
| 150 |
const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime });
|
|
|
|
|
|
|
|
|
|
| 151 |
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4 });
|
| 152 |
else if (cue.wide) app.rig.gaze(null, { hold: 3.5 });
|
| 153 |
const dir = cue.target || featureDir(e.feature);
|
|
@@ -188,6 +197,7 @@ app.onTick((t) => runner.update(t));
|
|
| 188 |
// world as it stands now (zero GPU, gorgeous by default). Count source is
|
| 189 |
// unified with the landing page: epoch === number of granted wishes.
|
| 190 |
app.world.reset(state.features, app.simTime);
|
|
|
|
| 191 |
const granted = state.epoch ?? list.length;
|
| 192 |
els.status.textContent = `epoch ${granted} · ${granted} ${granted === 1 ? "wish" : "wishes"} granted`;
|
| 193 |
|
|
|
|
| 6 |
import { buildWishScript, ScriptRunner } from "./planet/replay.js";
|
| 7 |
import { latLonToDir, PLANET_R } from "./planet/util.js";
|
| 8 |
|
| 9 |
+
// Re-anchor + frame the town heart from the current world.
|
| 10 |
+
function syncTown() {
|
| 11 |
+
app.rig.setTownCenter(app.world.features);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
const params = new URLSearchParams(location.search);
|
| 15 |
const MOCK = params.has("mock");
|
| 16 |
const qs = MOCK ? "?mock=1" : "";
|
|
|
|
| 133 |
const base = firstIdx === -1 ? allFeatures : allFeatures.slice(0, firstIdx);
|
| 134 |
const features = mine.length ? mine : (trace.features ?? []);
|
| 135 |
app.world.reset(base, app.simTime);
|
| 136 |
+
app.rig.setMode("town"); // replays begin in the close town view
|
| 137 |
+
syncTown();
|
| 138 |
|
| 139 |
runner.handlers = {
|
| 140 |
started: () => {},
|
|
|
|
| 154 |
},
|
| 155 |
delta: (e) => {
|
| 156 |
const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime });
|
| 157 |
+
if (e.feature.tool === "place_structure" || e.feature.tool === "build_district") {
|
| 158 |
+
syncTown(); // the town heart follows each new building during the replay
|
| 159 |
+
}
|
| 160 |
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4 });
|
| 161 |
else if (cue.wide) app.rig.gaze(null, { hold: 3.5 });
|
| 162 |
const dir = cue.target || featureDir(e.feature);
|
|
|
|
| 197 |
// world as it stands now (zero GPU, gorgeous by default). Count source is
|
| 198 |
// unified with the landing page: epoch === number of granted wishes.
|
| 199 |
app.world.reset(state.features, app.simTime);
|
| 200 |
+
syncTown(); // open the log over the close town view (matches the landing page)
|
| 201 |
const granted = state.epoch ?? list.length;
|
| 202 |
els.status.textContent = `epoch ${granted} · ${granted} ${granted === 1 ? "wish" : "wishes"} granted`;
|
| 203 |
|
web/index.html
CHANGED
|
@@ -30,9 +30,14 @@
|
|
| 30 |
<p class="brand__tag">nine billion parameters · one shared world</p>
|
| 31 |
</header>
|
| 32 |
|
| 33 |
-
<!-- epoch + Genesis Log -->
|
| 34 |
<nav class="almanac">
|
| 35 |
<div class="almanac__epoch" id="epoch">Epoch —</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
<a class="almanac__log" href="./book.html" id="logLink">Genesis Log →</a>
|
| 37 |
</nav>
|
| 38 |
|
|
|
|
| 30 |
<p class="brand__tag">nine billion parameters · one shared world</p>
|
| 31 |
</header>
|
| 32 |
|
| 33 |
+
<!-- epoch + Genesis Log + behold toggle -->
|
| 34 |
<nav class="almanac">
|
| 35 |
<div class="almanac__epoch" id="epoch">Epoch —</div>
|
| 36 |
+
<button class="behold" id="beholdToggle" type="button" aria-pressed="false"
|
| 37 |
+
title="pull back to the whole world, or return to the town">
|
| 38 |
+
<span class="behold__label">behold the world</span>
|
| 39 |
+
<span class="behold__sigil" aria-hidden="true">↕</span>
|
| 40 |
+
</button>
|
| 41 |
<a class="almanac__log" href="./book.html" id="logLink">Genesis Log →</a>
|
| 42 |
</nav>
|
| 43 |
|
web/main.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
// The deterministic engine owns the facts; this file only performs them.
|
| 5 |
import { createPlanetApp } from "./planet/scene.js";
|
| 6 |
import { buildWishScript, ScriptRunner } from "./planet/replay.js";
|
| 7 |
-
import { latLonToDir, PLANET_R, ordinal, clamp } from "./planet/util.js";
|
| 8 |
|
| 9 |
const params = new URLSearchParams(location.search);
|
| 10 |
const MOCK = params.has("mock");
|
|
@@ -27,6 +27,7 @@ const els = {
|
|
| 27 |
send: document.getElementById("wishSend"),
|
| 28 |
hint: document.getElementById("altarHint"),
|
| 29 |
status: document.getElementById("status"),
|
|
|
|
| 30 |
};
|
| 31 |
|
| 32 |
const HINT_DEFAULT = "the god grants one wish at a time";
|
|
@@ -168,6 +169,30 @@ if (els.epitaphDescend) {
|
|
| 168 |
});
|
| 169 |
}
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
const featuresByWish = (features, upto) => {
|
| 172 |
if (upto == null) return features;
|
| 173 |
const order = [];
|
|
@@ -206,6 +231,7 @@ async function bootLive() {
|
|
| 206 |
const state = await res.json();
|
| 207 |
stateVersion = state.world?.version ?? null;
|
| 208 |
app.world.reset(state.world?.features ?? [], app.simTime);
|
|
|
|
| 209 |
const epoch = state.world?.epoch ?? countWishes(state.world?.features ?? []);
|
| 210 |
setEpoch(epoch, epoch);
|
| 211 |
setQueue(state.queue?.length ?? 0);
|
|
@@ -293,8 +319,14 @@ async function bootLive() {
|
|
| 293 |
if (m.feature) {
|
| 294 |
stateVersion = stateVersion == null ? null : stateVersion + 1;
|
| 295 |
const cue = app.world.applyFeature(m.feature, { animate: true, t: app.simTime });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
// gaze toward where the world is actually changing — the cue target is
|
| 297 |
// the resolved surface point (refines inscriptions/structures by seed).
|
|
|
|
| 298 |
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 5.5 });
|
| 299 |
else if (cue.wide) app.rig.gaze(null, { hold: 4 });
|
| 300 |
const dir = cue.target || featureDir(m.feature);
|
|
@@ -376,6 +408,7 @@ async function bootMock() {
|
|
| 376 |
const baseFeatures = featuresByWish(state.world.features, UPTO);
|
| 377 |
const baseEpoch = countWishes(baseFeatures);
|
| 378 |
app.world.reset(baseFeatures, app.simTime);
|
|
|
|
| 379 |
applyDevOverrides();
|
| 380 |
setEpoch(baseEpoch, baseEpoch);
|
| 381 |
els.status.textContent = MOCK ? "demo vision · ?mock=1" : "the god is dreaming · demo vision";
|
|
@@ -396,6 +429,9 @@ async function bootMock() {
|
|
| 396 |
},
|
| 397 |
delta: (e) => {
|
| 398 |
const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime });
|
|
|
|
|
|
|
|
|
|
| 399 |
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4.5 });
|
| 400 |
else if (cue.wide) app.rig.gaze(null, { hold: 3.5 });
|
| 401 |
const dir = cue.target || featureDir(e.feature);
|
|
@@ -435,6 +471,7 @@ async function bootMock() {
|
|
| 435 |
// reset to the base world, then perform the wish again — determinism on display
|
| 436 |
if (app.world.features.length > baseFeatures.length) {
|
| 437 |
app.world.reset(baseFeatures, t);
|
|
|
|
| 438 |
setEpoch(baseEpoch, baseEpoch);
|
| 439 |
tickerClear();
|
| 440 |
tickerIdle();
|
|
@@ -502,7 +539,10 @@ window.__godseed = {
|
|
| 502 |
app.rig.userHold = 0;
|
| 503 |
app.rig.gaze(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { hold });
|
| 504 |
},
|
| 505 |
-
gazeWide: () => app.rig.
|
|
|
|
|
|
|
|
|
|
| 506 |
descend: (lat, lon, duration = 13) => {
|
| 507 |
app.rig.setTerrain?.(app.world.terrain);
|
| 508 |
app.rig.descend(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { duration });
|
|
|
|
| 4 |
// The deterministic engine owns the facts; this file only performs them.
|
| 5 |
import { createPlanetApp } from "./planet/scene.js";
|
| 6 |
import { buildWishScript, ScriptRunner } from "./planet/replay.js";
|
| 7 |
+
import { latLonToDir, PLANET_R, ordinal, clamp, townCenter } from "./planet/util.js";
|
| 8 |
|
| 9 |
const params = new URLSearchParams(location.search);
|
| 10 |
const MOCK = params.has("mock");
|
|
|
|
| 27 |
send: document.getElementById("wishSend"),
|
| 28 |
hint: document.getElementById("altarHint"),
|
| 29 |
status: document.getElementById("status"),
|
| 30 |
+
behold: document.getElementById("beholdToggle"),
|
| 31 |
};
|
| 32 |
|
| 33 |
const HINT_DEFAULT = "the god grants one wish at a time";
|
|
|
|
| 169 |
});
|
| 170 |
}
|
| 171 |
|
| 172 |
+
// Re-anchor the town heart from the current world (camera follows it as it
|
| 173 |
+
// densifies). Called on every state load + world_delta.
|
| 174 |
+
function syncTown() {
|
| 175 |
+
app.rig.setTownCenter(app.world.features);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// "behold the world ↕" — pull back to the full planet; "↻ return to the town" —
|
| 179 |
+
// drop back into the close town view. Reflects the rig's current mode.
|
| 180 |
+
function updateBeholdLabel() {
|
| 181 |
+
if (!els.behold) return;
|
| 182 |
+
const world = app.rig.mode === "world";
|
| 183 |
+
els.behold.classList.toggle("is-world", world);
|
| 184 |
+
els.behold.setAttribute("aria-pressed", world ? "true" : "false");
|
| 185 |
+
els.behold.querySelector(".behold__label").textContent =
|
| 186 |
+
world ? "return to the town" : "behold the world";
|
| 187 |
+
}
|
| 188 |
+
if (els.behold) {
|
| 189 |
+
els.behold.addEventListener("click", () => {
|
| 190 |
+
app.rig.beholdWorld();
|
| 191 |
+
updateBeholdLabel();
|
| 192 |
+
});
|
| 193 |
+
updateBeholdLabel();
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
const featuresByWish = (features, upto) => {
|
| 197 |
if (upto == null) return features;
|
| 198 |
const order = [];
|
|
|
|
| 231 |
const state = await res.json();
|
| 232 |
stateVersion = state.world?.version ?? null;
|
| 233 |
app.world.reset(state.world?.features ?? [], app.simTime);
|
| 234 |
+
syncTown(); // anchor the town heart, then frame it (the default close view)
|
| 235 |
const epoch = state.world?.epoch ?? countWishes(state.world?.features ?? []);
|
| 236 |
setEpoch(epoch, epoch);
|
| 237 |
setQueue(state.queue?.length ?? 0);
|
|
|
|
| 319 |
if (m.feature) {
|
| 320 |
stateVersion = stateVersion == null ? null : stateVersion + 1;
|
| 321 |
const cue = app.world.applyFeature(m.feature, { animate: true, t: app.simTime });
|
| 322 |
+
// a new building shifts the town's heart — re-anchor before framing so
|
| 323 |
+
// the camera follows the town as it densifies (no-op for sky/weather).
|
| 324 |
+
if (m.feature.tool === "place_structure" || m.feature.tool === "build_district") {
|
| 325 |
+
syncTown();
|
| 326 |
+
}
|
| 327 |
// gaze toward where the world is actually changing — the cue target is
|
| 328 |
// the resolved surface point (refines inscriptions/structures by seed).
|
| 329 |
+
// In TOWN mode gaze() keeps this WITHIN the town zoom (never the far limb).
|
| 330 |
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 5.5 });
|
| 331 |
else if (cue.wide) app.rig.gaze(null, { hold: 4 });
|
| 332 |
const dir = cue.target || featureDir(m.feature);
|
|
|
|
| 408 |
const baseFeatures = featuresByWish(state.world.features, UPTO);
|
| 409 |
const baseEpoch = countWishes(baseFeatures);
|
| 410 |
app.world.reset(baseFeatures, app.simTime);
|
| 411 |
+
syncTown(); // anchor + frame the town heart (the default close view)
|
| 412 |
applyDevOverrides();
|
| 413 |
setEpoch(baseEpoch, baseEpoch);
|
| 414 |
els.status.textContent = MOCK ? "demo vision · ?mock=1" : "the god is dreaming · demo vision";
|
|
|
|
| 429 |
},
|
| 430 |
delta: (e) => {
|
| 431 |
const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime });
|
| 432 |
+
if (e.feature.tool === "place_structure" || e.feature.tool === "build_district") {
|
| 433 |
+
syncTown(); // the town heart follows each new building
|
| 434 |
+
}
|
| 435 |
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4.5 });
|
| 436 |
else if (cue.wide) app.rig.gaze(null, { hold: 3.5 });
|
| 437 |
const dir = cue.target || featureDir(e.feature);
|
|
|
|
| 471 |
// reset to the base world, then perform the wish again — determinism on display
|
| 472 |
if (app.world.features.length > baseFeatures.length) {
|
| 473 |
app.world.reset(baseFeatures, t);
|
| 474 |
+
syncTown(); // back to the base town heart before the wish runs again
|
| 475 |
setEpoch(baseEpoch, baseEpoch);
|
| 476 |
tickerClear();
|
| 477 |
tickerIdle();
|
|
|
|
| 539 |
app.rig.userHold = 0;
|
| 540 |
app.rig.gaze(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { hold });
|
| 541 |
},
|
| 542 |
+
gazeWide: () => { app.rig.setMode("world"); updateBeholdLabel(); },
|
| 543 |
+
setMode: (mode) => { app.rig.setMode(mode); updateBeholdLabel(); },
|
| 544 |
+
beholdWorld: () => { const m = app.rig.beholdWorld(); updateBeholdLabel(); return m; },
|
| 545 |
+
townCenter: () => townCenter(app.world.features),
|
| 546 |
descend: (lat, lon, duration = 13) => {
|
| 547 |
app.rig.setTerrain?.(app.world.terrain);
|
| 548 |
app.rig.descend(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { duration });
|
web/mock/feed.js
CHANGED
|
@@ -3,64 +3,62 @@
|
|
| 3 |
// resets between loops so the same wish lands on the same world every time
|
| 4 |
// (the renderer is a pure function of the feature list — this is the proof).
|
| 5 |
//
|
| 6 |
-
// The looping wish is a
|
| 7 |
-
//
|
| 8 |
-
//
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
export const FEED_WISH = {
|
| 11 |
trace: {
|
| 12 |
-
wish_id: "
|
| 13 |
-
text: "
|
| 14 |
moderation: { allowed: true, category: null },
|
| 15 |
reading:
|
| 16 |
-
"
|
| 17 |
-
"
|
| 18 |
-
"
|
|
|
|
| 19 |
turns: [
|
| 20 |
{
|
| 21 |
-
thought: "
|
| 22 |
-
call: { tool: "
|
| 23 |
-
observation: "ok:
|
| 24 |
},
|
| 25 |
{
|
| 26 |
-
thought: "
|
| 27 |
-
call: { tool: "place_structure", args: { lat:
|
| 28 |
-
observation: "ok:
|
| 29 |
},
|
| 30 |
{
|
| 31 |
-
thought: "
|
| 32 |
-
call: { tool: "place_structure", args: { lat:
|
| 33 |
-
observation: "ok:
|
| 34 |
},
|
| 35 |
{
|
| 36 |
-
thought: "A
|
| 37 |
-
call: { tool: "
|
| 38 |
-
observation: "ok:
|
| 39 |
},
|
| 40 |
{
|
| 41 |
-
thought: "
|
| 42 |
-
call: { tool: "
|
| 43 |
-
observation: "ok:
|
| 44 |
},
|
| 45 |
-
{
|
| 46 |
-
thought: "Now the lantern-carts, gliding home at dusk.",
|
| 47 |
-
call: { tool: "spawn_life", args: { lat: -10, lon: 128, radius_deg: 9, kind: "carts", count: 8, hue: 42 } },
|
| 48 |
-
observation: "ok: 8 carts stirring at (-10,128)",
|
| 49 |
-
},
|
| 50 |
-
{ thought: "A city is only small lights agreeing to stay near each other.", call: null, observation: "" },
|
| 51 |
],
|
| 52 |
-
epitaph: "A
|
| 53 |
-
feature_ids: ["
|
| 54 |
model: "nemotron-3-nano (mock)",
|
| 55 |
backend: "mock",
|
| 56 |
-
ms_total:
|
| 57 |
},
|
| 58 |
features: [
|
| 59 |
-
{ id: "
|
| 60 |
-
{ id: "
|
| 61 |
-
{ id: "
|
| 62 |
-
{ id: "
|
| 63 |
-
{ id: "
|
| 64 |
-
{ id: "f_000041", wish_id: "w_000012", tool: "spawn_life", args: { lat: -10, lon: 128, radius_deg: 9, kind: "carts", count: 8, hue: 42 }, seed: 1315145044, t: 1718201215.0 },
|
| 65 |
],
|
| 66 |
};
|
|
|
|
| 3 |
// resets between loops so the same wish lands on the same world every time
|
| 4 |
// (the renderer is a pure function of the feature list — this is the proof).
|
| 5 |
//
|
| 6 |
+
// The looping wish is a TOWN-GROWTH wish so the live demo exercises the v1.2
|
| 7 |
+
// Town Mode end-to-end: a fresh district + a market-square pair of civic
|
| 8 |
+
// buildings, a paved road threaded back to the old square, and lantern-carts
|
| 9 |
+
// running the new street at dusk — all rising BESIDE the existing town near
|
| 10 |
+
// the heart at (14,38), never alone in the wilderness. This is the SimCity
|
| 11 |
+
// unlock on display: one wish, a whole new block.
|
| 12 |
|
| 13 |
export const FEED_WISH = {
|
| 14 |
trace: {
|
| 15 |
+
wish_id: "w_000007",
|
| 16 |
+
text: "grow the town — a new quarter east of the square, a market and an inn, a road back to it, and carts at dusk",
|
| 17 |
moderation: { allowed: true, category: null },
|
| 18 |
reading:
|
| 19 |
+
"Grow it, then — not scatter it. The town already keeps its lights near the square; " +
|
| 20 |
+
"this new quarter rises just east of it, a market to trade in and an inn to keep one " +
|
| 21 |
+
"window warm, a road laid back to the old heart so no house stands alone, and " +
|
| 22 |
+
"lantern-carts to run the new street home at dusk.",
|
| 23 |
turns: [
|
| 24 |
{
|
| 25 |
+
thought: "A new quarter, dense, just east of the square — beside the old town, not apart from it.",
|
| 26 |
+
call: { tool: "build_district", args: { lat: 15, lon: 43, radius_deg: 6, density: 0.8, hue: 35 } },
|
| 27 |
+
observation: "ok: a district rises near (15,43)",
|
| 28 |
},
|
| 29 |
{
|
| 30 |
+
thought: "A market at the new quarter's heart, to give it a reason to gather.",
|
| 31 |
+
call: { tool: "place_structure", args: { lat: 15, lon: 43, kind: "market", scale: 1.1, hue: 30 } },
|
| 32 |
+
observation: "ok: market placed at (15,43)",
|
| 33 |
},
|
| 34 |
{
|
| 35 |
+
thought: "An inn beside it — one warm window against the coming dark.",
|
| 36 |
+
call: { tool: "place_structure", args: { lat: 16, lon: 44, kind: "cafe", scale: 1.0, hue: 34 } },
|
| 37 |
+
observation: "ok: cafe placed at (16,44)",
|
| 38 |
},
|
| 39 |
{
|
| 40 |
+
thought: "A road back to the old square, so the two quarters are one town.",
|
| 41 |
+
call: { tool: "place_road", args: { path: [[14, 38], [15, 41], [15, 43]] } },
|
| 42 |
+
observation: "ok: a road laid through 3 points",
|
| 43 |
},
|
| 44 |
{
|
| 45 |
+
thought: "Now the lantern-carts, gliding the new street home at dusk.",
|
| 46 |
+
call: { tool: "spawn_life", args: { lat: 15, lon: 42, radius_deg: 7, kind: "carts", count: 8, hue: 42 } },
|
| 47 |
+
observation: "ok: 8 carts stirring at (15,42)",
|
| 48 |
},
|
| 49 |
+
{ thought: "A town is only small lights agreeing to stay near each other.", call: null, observation: "" },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
],
|
| 51 |
+
epitaph: "A new quarter joins the square — market, inn, and a road between them; the carts run it home at dusk.",
|
| 52 |
+
feature_ids: ["f_000028", "f_000029", "f_000030", "f_000031", "f_000032"],
|
| 53 |
model: "nemotron-3-nano (mock)",
|
| 54 |
backend: "mock",
|
| 55 |
+
ms_total: 54120,
|
| 56 |
},
|
| 57 |
features: [
|
| 58 |
+
{ id: "f_000028", wish_id: "w_000007", tool: "build_district", args: { lat: 15, lon: 43, radius_deg: 6, density: 0.8, hue: 35 }, seed: 8311893, t: 1718200700.0 },
|
| 59 |
+
{ id: "f_000029", wish_id: "w_000007", tool: "place_structure", args: { lat: 15, lon: 43, kind: "market", scale: 1.1, hue: 30 }, seed: 2004477123, t: 1718200703.0 },
|
| 60 |
+
{ id: "f_000030", wish_id: "w_000007", tool: "place_structure", args: { lat: 16, lon: 44, kind: "cafe", scale: 1.0, hue: 34 }, seed: 1852880249, t: 1718200706.0 },
|
| 61 |
+
{ id: "f_000031", wish_id: "w_000007", tool: "place_road", args: { path: [[14, 38], [15, 41], [15, 43]] }, seed: 427263471, t: 1718200709.0 },
|
| 62 |
+
{ id: "f_000032", wish_id: "w_000007", tool: "spawn_life", args: { lat: 15, lon: 42, radius_deg: 7, kind: "carts", count: 8, hue: 42 }, seed: 118689868, t: 1718200712.0 },
|
|
|
|
| 63 |
],
|
| 64 |
};
|
web/mock/traces.json
CHANGED
|
@@ -1,177 +1,117 @@
|
|
| 1 |
{
|
| 2 |
"w_000001": {
|
| 3 |
"wish_id": "w_000001",
|
| 4 |
-
"text": "
|
| 5 |
"submitted_at": 1718200090.0,
|
| 6 |
"moderation": { "allowed": true, "category": null },
|
| 7 |
-
"reading": "
|
| 8 |
"turns": [
|
| 9 |
-
{ "thought": "
|
| 10 |
-
{ "thought": "
|
| 11 |
-
{ "thought": "
|
| 12 |
-
{ "thought": "
|
| 13 |
],
|
| 14 |
-
"epitaph": "
|
| 15 |
"feature_ids": ["f_000004", "f_000005", "f_000006"],
|
| 16 |
"model": "nemotron-3-nano (mock)",
|
| 17 |
"backend": "mock",
|
| 18 |
-
"ms_total":
|
| 19 |
},
|
| 20 |
"w_000002": {
|
| 21 |
"wish_id": "w_000002",
|
| 22 |
-
"text": "
|
| 23 |
"submitted_at": 1718200190.0,
|
| 24 |
"moderation": { "allowed": true, "category": null },
|
| 25 |
-
"reading": "
|
| 26 |
"turns": [
|
| 27 |
-
{ "thought": "
|
| 28 |
-
{ "thought": "
|
| 29 |
-
{ "thought": "
|
|
|
|
|
|
|
|
|
|
| 30 |
],
|
| 31 |
-
"epitaph": "The
|
| 32 |
-
"feature_ids": ["f_000007", "f_000008"],
|
| 33 |
"model": "nemotron-3-nano (mock)",
|
| 34 |
"backend": "mock",
|
| 35 |
-
"ms_total":
|
| 36 |
},
|
| 37 |
"w_000003": {
|
| 38 |
"wish_id": "w_000003",
|
| 39 |
-
"text": "a
|
| 40 |
"submitted_at": 1718200290.0,
|
| 41 |
"moderation": { "allowed": true, "category": null },
|
| 42 |
-
"reading": "A
|
| 43 |
"turns": [
|
| 44 |
-
{ "thought": "
|
| 45 |
-
{ "thought": "
|
| 46 |
-
{ "thought": "
|
|
|
|
|
|
|
|
|
|
| 47 |
],
|
| 48 |
-
"epitaph": "A
|
| 49 |
-
"feature_ids": ["
|
| 50 |
"model": "nemotron-3-nano (mock)",
|
| 51 |
"backend": "mock",
|
| 52 |
-
"ms_total":
|
| 53 |
},
|
| 54 |
"w_000004": {
|
| 55 |
"wish_id": "w_000004",
|
| 56 |
-
"text": "a
|
| 57 |
"submitted_at": 1718200390.0,
|
| 58 |
"moderation": { "allowed": true, "category": null },
|
| 59 |
-
"reading": "
|
| 60 |
"turns": [
|
| 61 |
-
{ "thought": "Trace
|
| 62 |
-
{ "thought": "
|
| 63 |
-
{ "thought": "
|
| 64 |
-
{ "thought": "
|
| 65 |
],
|
| 66 |
-
"epitaph": "
|
| 67 |
-
"feature_ids": ["
|
| 68 |
"model": "nemotron-3-nano (mock)",
|
| 69 |
"backend": "mock",
|
| 70 |
"ms_total": 47410
|
| 71 |
},
|
| 72 |
"w_000005": {
|
| 73 |
"wish_id": "w_000005",
|
| 74 |
-
"text": "a
|
| 75 |
"submitted_at": 1718200490.0,
|
| 76 |
"moderation": { "allowed": true, "category": null },
|
| 77 |
-
"reading": "
|
| 78 |
"turns": [
|
| 79 |
-
{ "thought": "A
|
| 80 |
-
{ "thought": "
|
| 81 |
-
{ "thought": "
|
| 82 |
-
{ "thought": "
|
|
|
|
|
|
|
| 83 |
],
|
| 84 |
-
"epitaph": "
|
| 85 |
-
"feature_ids": ["
|
| 86 |
"model": "nemotron-3-nano (mock)",
|
| 87 |
"backend": "mock",
|
| 88 |
-
"ms_total":
|
| 89 |
},
|
| 90 |
"w_000006": {
|
| 91 |
"wish_id": "w_000006",
|
| 92 |
-
"text": "a
|
| 93 |
"submitted_at": 1718200590.0,
|
| 94 |
"moderation": { "allowed": true, "category": null },
|
| 95 |
-
"reading": "
|
| 96 |
-
"turns": [
|
| 97 |
-
{ "thought": "Set the houses on the plain east of the old hill. Lamps in every wall.", "call": { "tool": "place_structure", "args": { "lat": 4, "lon": 12, "kind": "village", "scale": 1.25, "hue": 38 } }, "observation": "ok: village raised at (4,12)" },
|
| 98 |
-
{ "thought": "Reeds along the path — green, so the lamplight has an edge.", "call": { "tool": "spawn_flora", "args": { "lat": 1, "lon": 19, "radius_deg": 6, "kind": "reeds", "density": 0.8, "hue": 95 } }, "observation": "ok: flora seeded at (1,19)" },
|
| 99 |
-
{ "thought": "A pond to double every window.", "call": { "tool": "place_water", "args": { "kind": "pool", "path": [[-2, 8]], "radius_deg": 3, "hue": 175 } }, "observation": "ok: pool filled at (-2,8)" },
|
| 100 |
-
{ "thought": "The lamps are lit. I will not say for whom.", "call": null, "observation": "" }
|
| 101 |
-
],
|
| 102 |
-
"epitaph": "Nine windows. None of them dark.",
|
| 103 |
-
"feature_ids": ["f_000017", "f_000018", "f_000019"],
|
| 104 |
-
"model": "nemotron-3-nano (mock)",
|
| 105 |
-
"backend": "mock",
|
| 106 |
-
"ms_total": 49870
|
| 107 |
-
},
|
| 108 |
-
"w_000007": {
|
| 109 |
-
"wish_id": "w_000007",
|
| 110 |
-
"text": "a shrine where snow falls and stays",
|
| 111 |
-
"submitted_at": 1718200690.0,
|
| 112 |
-
"moderation": { "allowed": true, "category": null },
|
| 113 |
-
"reading": "A quiet wish. Snow that stays — permanence asked of the most temporary thing. I will build the shrine far south, where the cold is honest, and command the snow to keep falling.",
|
| 114 |
-
"turns": [
|
| 115 |
-
{ "thought": "The far south. Four pillars, one floating light. Blue, like held breath.", "call": { "tool": "place_structure", "args": { "lat": -44, "lon": -37, "kind": "shrine", "scale": 1.3, "hue": 210 } }, "observation": "ok: shrine placed at (-44,-37)" },
|
| 116 |
-
{ "thought": "Now the snow. Steady. Unhurried. Forever is just patience repeated.", "call": { "tool": "set_weather", "args": { "kind": "snow", "intensity": 0.6 } }, "observation": "ok: weather set to snow" },
|
| 117 |
-
{ "thought": "It falls, and it stays falling. Close enough to forever.", "call": null, "observation": "" }
|
| 118 |
-
],
|
| 119 |
-
"epitaph": "The snow keeps its appointment forever.",
|
| 120 |
-
"feature_ids": ["f_000020", "f_000021"],
|
| 121 |
-
"model": "nemotron-3-nano (mock)",
|
| 122 |
-
"backend": "mock",
|
| 123 |
-
"ms_total": 38760
|
| 124 |
-
},
|
| 125 |
-
"w_000008": {
|
| 126 |
-
"wish_id": "w_000008",
|
| 127 |
-
"text": "mushrooms beneath a second moon",
|
| 128 |
-
"submitted_at": 1718200790.0,
|
| 129 |
-
"moderation": { "allowed": true, "category": null },
|
| 130 |
-
"reading": "Two requests wearing one coat: small lamps below, a new lamp above. Mushrooms first — blue-capped, crowded, the kind that confer. Then I will hang a second moon to watch them.",
|
| 131 |
-
"turns": [
|
| 132 |
-
{ "thought": "Crowd them on the southern hills — blue caps, almost touching.", "call": { "tool": "spawn_flora", "args": { "lat": -41, "lon": -22, "radius_deg": 9, "kind": "mushrooms", "density": 0.9, "hue": 198 } }, "observation": "ok: flora seeded at (-41,-22)" },
|
| 133 |
-
{ "thought": "And the second moon. The night sky, rearranged for them.", "call": { "tool": "set_sky", "args": { "palette": "night", "star_density": 0.75, "moons": 2 } }, "observation": "ok: sky set to night, 2 moons" },
|
| 134 |
-
{ "thought": "The mushrooms glow up. The moons glow down. An understanding.", "call": null, "observation": "" }
|
| 135 |
-
],
|
| 136 |
-
"epitaph": "Two moons now, and small lamps to greet them.",
|
| 137 |
-
"feature_ids": ["f_000022", "f_000023"],
|
| 138 |
-
"model": "nemotron-3-nano (mock)",
|
| 139 |
-
"backend": "mock",
|
| 140 |
-
"ms_total": 35540
|
| 141 |
-
},
|
| 142 |
-
"w_000009": {
|
| 143 |
-
"wish_id": "w_000009",
|
| 144 |
-
"text": "an arch wrapped in living vines",
|
| 145 |
-
"submitted_at": 1718200890.0,
|
| 146 |
-
"moderation": { "allowed": true, "category": null },
|
| 147 |
-
"reading": "An arch is a door that refuses walls. This one wants to be alive. Stone first, then vines — rose light through green — set high in the east where travelers would pass, if I ever make travelers.",
|
| 148 |
-
"turns": [
|
| 149 |
-
{ "thought": "Raise the span in the east, tall enough to frame the moons.", "call": { "tool": "place_structure", "args": { "lat": 22, "lon": 96, "kind": "arch", "scale": 1.8, "hue": 320 } }, "observation": "ok: arch placed at (22,96)" },
|
| 150 |
-
{ "thought": "Now the vines. Let them climb without being told twice.", "call": { "tool": "spawn_flora", "args": { "lat": 21, "lon": 97, "radius_deg": 7, "kind": "vines", "density": 0.65, "hue": 120 } }, "observation": "ok: flora seeded at (21,97)" },
|
| 151 |
-
{ "thought": "A door, alive, open. Anything could come through. Good.", "call": null, "observation": "" }
|
| 152 |
-
],
|
| 153 |
-
"epitaph": "Walk through; the vines lean in to listen.",
|
| 154 |
-
"feature_ids": ["f_000024", "f_000025"],
|
| 155 |
-
"model": "nemotron-3-nano (mock)",
|
| 156 |
-
"backend": "mock",
|
| 157 |
-
"ms_total": 37210
|
| 158 |
-
},
|
| 159 |
-
"w_000010": {
|
| 160 |
-
"wish_id": "w_000010",
|
| 161 |
-
"text": "a beacon to call the dancing lights",
|
| 162 |
-
"submitted_at": 1718200990.0,
|
| 163 |
-
"moderation": { "allowed": true, "category": null },
|
| 164 |
-
"reading": "The dancing lights — the aurora. It does not come when called, usually. But a beacon is not a call; it is an invitation left burning. Far north, then. Green fire. We will see who arrives.",
|
| 165 |
"turns": [
|
| 166 |
-
{ "thought": "
|
| 167 |
-
{ "thought": "
|
| 168 |
-
{ "thought": "
|
| 169 |
-
{ "thought": "
|
|
|
|
| 170 |
],
|
| 171 |
-
"epitaph": "
|
| 172 |
-
"feature_ids": ["f_000026", "f_000027"],
|
| 173 |
"model": "nemotron-3-nano (mock)",
|
| 174 |
"backend": "mock",
|
| 175 |
-
"ms_total":
|
| 176 |
}
|
| 177 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"w_000001": {
|
| 3 |
"wish_id": "w_000001",
|
| 4 |
+
"text": "level the ground by the monolith and ring it with glowgrass at dusk",
|
| 5 |
"submitted_at": 1718200090.0,
|
| 6 |
"moderation": { "allowed": true, "category": null },
|
| 7 |
+
"reading": "Before a town, a place for it to stand. The ground by the monolith heaves and pitches — I will press it flat and broad, ring its edge with grass that holds its own light, and let the sky go to dusk so the first lamps will mean something.",
|
| 8 |
"turns": [
|
| 9 |
+
{ "thought": "Flatten the shoulder of ground at the monolith — wide and gentle, fit to build on.", "call": { "tool": "raise_terrain", "args": { "lat": 14, "lon": 38, "radius_deg": 16, "height": 0.035, "roughness": 0.45 } }, "observation": "ok: mountains risen at (14,38)" },
|
| 10 |
+
{ "thought": "Far across the world, a wide meadow of glowgrass to light the far ground.", "call": { "tool": "spawn_flora", "args": { "lat": -6, "lon": 118, "radius_deg": 14, "kind": "glowgrass", "density": 0.6, "hue": 150 } }, "observation": "ok: flora seeded at (-6,118)" },
|
| 11 |
+
{ "thought": "Take the sky to dusk — building reads best when the light is going.", "call": { "tool": "set_sky", "args": { "palette": "dusk", "star_density": 0.55, "moons": 1 } }, "observation": "ok: sky set to dusk, 1 moon" },
|
| 12 |
+
{ "thought": "The ground lies flat. Now it can hold a town.", "call": null, "observation": "" }
|
| 13 |
],
|
| 14 |
+
"epitaph": "The ground lies flat for building, and the grass glows where the town will rise.",
|
| 15 |
"feature_ids": ["f_000004", "f_000005", "f_000006"],
|
| 16 |
"model": "nemotron-3-nano (mock)",
|
| 17 |
"backend": "mock",
|
| 18 |
+
"ms_total": 44230
|
| 19 |
},
|
| 20 |
"w_000002": {
|
| 21 |
"wish_id": "w_000002",
|
| 22 |
+
"text": "build me a town — a district, a bank, a market, and a tower over it",
|
| 23 |
"submitted_at": 1718200190.0,
|
| 24 |
"moderation": { "allowed": true, "category": null },
|
| 25 |
+
"reading": "A town, then — the brave wish. Not one tower alone on an empty world, but a whole quarter at once: a block of homes on the flat ground, a bank and a market to give them reason to gather, and a tower to keep the light over all of it. I will lay it down beside the monolith, and bind it with a road.",
|
| 26 |
"turns": [
|
| 27 |
+
{ "thought": "First the quarter itself — a dense block of homes on the new flat ground.", "call": { "tool": "build_district", "args": { "lat": 14, "lon": 38, "radius_deg": 7, "density": 0.85, "hue": 36 } }, "observation": "ok: a district rises near (14,38)" },
|
| 28 |
+
{ "thought": "A bank at the heart of it — stone, columned, the town's anchor.", "call": { "tool": "place_structure", "args": { "lat": 14, "lon": 38, "kind": "bank", "scale": 1.3, "hue": 40 } }, "observation": "ok: bank placed at (14,38)" },
|
| 29 |
+
{ "thought": "A market beside it — stalls and warm tables, a place to meet.", "call": { "tool": "place_structure", "args": { "lat": 12.5, "lon": 40, "kind": "market", "scale": 1.1, "hue": 30 } }, "observation": "ok: market placed at (12,40)" },
|
| 30 |
+
{ "thought": "A tower over the town, its rooftop light slow-pulsing across the block.", "call": { "tool": "place_structure", "args": { "lat": 16, "lon": 37, "kind": "tower", "scale": 1.4, "hue": 210 } }, "observation": "ok: tower placed at (16,37)" },
|
| 31 |
+
{ "thought": "A road to bind market, square and tower into one street.", "call": { "tool": "place_road", "args": { "path": [[12.5, 40], [14, 38], [16, 37]] } }, "observation": "ok: a road laid through 3 points" },
|
| 32 |
+
{ "thought": "A town is born — not scattered, but gathered around one square.", "call": null, "observation": "" }
|
| 33 |
],
|
| 34 |
+
"epitaph": "The town of Emberlyn stands: its bank, its market, its tower keeping the light.",
|
| 35 |
+
"feature_ids": ["f_000007", "f_000008", "f_000009", "f_000010", "f_000011"],
|
| 36 |
"model": "nemotron-3-nano (mock)",
|
| 37 |
"backend": "mock",
|
| 38 |
+
"ms_total": 58910
|
| 39 |
},
|
| 40 |
"w_000003": {
|
| 41 |
"wish_id": "w_000003",
|
| 42 |
+
"text": "a denser quarter below the square, a warehouse and homes, a road up to it",
|
| 43 |
"submitted_at": 1718200290.0,
|
| 44 |
"moderation": { "allowed": true, "category": null },
|
| 45 |
+
"reading": "The town wants to grow — and it should grow beside itself, not away. A second, denser block just below the square, a warehouse to work from and a home beside it, a road climbing up to the old heart so the two are one, and carts to run the new street home as the light goes.",
|
| 46 |
"turns": [
|
| 47 |
+
{ "thought": "A second quarter below the square — denser still, joined to the first.", "call": { "tool": "build_district", "args": { "lat": 11.5, "lon": 36.5, "radius_deg": 6, "density": 0.85, "hue": 34 } }, "observation": "ok: a district rises near (11,36)" },
|
| 48 |
+
{ "thought": "A warehouse to work from, one great door glowing.", "call": { "tool": "place_structure", "args": { "lat": 12, "lon": 35.5, "kind": "warehouse", "scale": 1.1, "hue": 42 } }, "observation": "ok: warehouse placed at (12,35)" },
|
| 49 |
+
{ "thought": "A home beside it, one warm window lit.", "call": { "tool": "place_structure", "args": { "lat": 10.5, "lon": 37, "kind": "house", "scale": 1.0, "hue": 36 } }, "observation": "ok: house placed at (10,37)" },
|
| 50 |
+
{ "thought": "A road up to the old square, so no house stands apart from the town.", "call": { "tool": "place_road", "args": { "path": [[11.5, 36.5], [12.8, 37.2], [14, 38]] } }, "observation": "ok: a road laid through 3 points" },
|
| 51 |
+
{ "thought": "Carts to run the new street, gliding home at dusk.", "call": { "tool": "spawn_life", "args": { "lat": 13, "lon": 38, "radius_deg": 7, "kind": "carts", "count": 8, "hue": 42 } }, "observation": "ok: 8 carts stirring at (13,38)" },
|
| 52 |
+
{ "thought": "Two quarters now, and one road between them. A town, not two villages.", "call": null, "observation": "" }
|
| 53 |
],
|
| 54 |
+
"epitaph": "A second block, joined to the first; the carts run the new street home at dusk.",
|
| 55 |
+
"feature_ids": ["f_000012", "f_000013", "f_000014", "f_000015", "f_000016"],
|
| 56 |
"model": "nemotron-3-nano (mock)",
|
| 57 |
"backend": "mock",
|
| 58 |
+
"ms_total": 56120
|
| 59 |
},
|
| 60 |
"w_000004": {
|
| 61 |
"wish_id": "w_000004",
|
| 62 |
+
"text": "let a bright stream run down into the square, with a little pool",
|
| 63 |
"submitted_at": 1718200390.0,
|
| 64 |
"moderation": { "allowed": true, "category": null },
|
| 65 |
+
"reading": "A town needs water to come to it. I will draw a bright stream down out of the highlands into the square, set a small still pool at its bend, and stand a quiet grove of trees far out at the town's edge — somewhere green to walk to.",
|
| 66 |
"turns": [
|
| 67 |
+
{ "thought": "Trace a bright stream down out of the northern highlands toward the town.", "call": { "tool": "place_water", "args": { "kind": "stream", "path": [[28, 12], [22, 20], [16, 28]], "hue": 205 } }, "observation": "ok: stream laid across the surface" },
|
| 68 |
+
{ "thought": "A small still pool where the stream rests, just north of the square.", "call": { "tool": "place_water", "args": { "kind": "pool", "path": [[16, 28]], "radius_deg": 2, "hue": 205 } }, "observation": "ok: pool filled at (16,28)" },
|
| 69 |
+
{ "thought": "A whole forest far across the world, for when the eye pulls back.", "call": { "tool": "spawn_flora", "args": { "lat": 2, "lon": 108, "radius_deg": 13, "kind": "trees", "density": 0.6, "hue": 130 } }, "observation": "ok: flora seeded at (2,108)" },
|
| 70 |
+
{ "thought": "Water comes to the square. It will not be a dry place.", "call": null, "observation": "" }
|
| 71 |
],
|
| 72 |
+
"epitaph": "Water comes to the square now, and a stand of trees waits at the town's edge.",
|
| 73 |
+
"feature_ids": ["f_000017", "f_000018", "f_000019"],
|
| 74 |
"model": "nemotron-3-nano (mock)",
|
| 75 |
"backend": "mock",
|
| 76 |
"ms_total": 47410
|
| 77 |
},
|
| 78 |
"w_000005": {
|
| 79 |
"wish_id": "w_000005",
|
| 80 |
+
"text": "a third quarter to the northeast — a cafe, a home, a road, and fireflies",
|
| 81 |
"submitted_at": 1718200490.0,
|
| 82 |
"moderation": { "allowed": true, "category": null },
|
| 83 |
+
"reading": "A third quarter, rising to the northeast of the square — another dense block, a cafe to keep one window warm, a home beside it, the road carried up to join it to the town, and fireflies to fill the dark the instant the day lets go.",
|
| 84 |
"turns": [
|
| 85 |
+
{ "thought": "A third quarter to the northeast — dense, joined to the square, not apart.", "call": { "tool": "build_district", "args": { "lat": 16.5, "lon": 40.5, "radius_deg": 5, "density": 0.8, "hue": 35 } }, "observation": "ok: a district rises near (16,40)" },
|
| 86 |
+
{ "thought": "A cafe at its corner, its awning lit, warm light spilling on the stones.", "call": { "tool": "place_structure", "args": { "lat": 17, "lon": 41, "kind": "cafe", "scale": 1.0, "hue": 34 } }, "observation": "ok: cafe placed at (17,41)" },
|
| 87 |
+
{ "thought": "A home beside it, so the cafe has a neighbour.", "call": { "tool": "place_structure", "args": { "lat": 16, "lon": 42, "kind": "house", "scale": 1.0, "hue": 36 } }, "observation": "ok: house placed at (16,42)" },
|
| 88 |
+
{ "thought": "Carry the road up from the square to meet the new quarter.", "call": { "tool": "place_road", "args": { "path": [[14, 38], [15.5, 39.5], [16.5, 40.5]] } }, "observation": "ok: a road laid through 3 points" },
|
| 89 |
+
{ "thought": "Fireflies at the cafe corner, to fill the dark the instant the light goes.", "call": { "tool": "spawn_life", "args": { "lat": 17, "lon": 41, "radius_deg": 4, "kind": "fireflies", "count": 5, "hue": 48 } }, "observation": "ok: 5 fireflies stirring at (17,41)" },
|
| 90 |
+
{ "thought": "Three quarters now, one road threading them. A town, not three villages.", "call": null, "observation": "" }
|
| 91 |
],
|
| 92 |
+
"epitaph": "A third block joins the town; one warm window, and the dusk fills with small lamps.",
|
| 93 |
+
"feature_ids": ["f_000020", "f_000021", "f_000022", "f_000023", "f_000024"],
|
| 94 |
"model": "nemotron-3-nano (mock)",
|
| 95 |
"backend": "mock",
|
| 96 |
+
"ms_total": 56980
|
| 97 |
},
|
| 98 |
"w_000006": {
|
| 99 |
"wish_id": "w_000006",
|
| 100 |
+
"text": "a far range across the sea, and birds to circle the town at dusk",
|
| 101 |
"submitted_at": 1718200590.0,
|
| 102 |
"moderation": { "allowed": true, "category": null },
|
| 103 |
+
"reading": "The town is close and warm; let the world around it be vast. I will raise a far range across the sea — something to behold when the eye pulls back — scatter pale mushrooms on its flanks, and set a slow ring of birds turning above the town, so even the wide world keeps coming home.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
"turns": [
|
| 105 |
+
{ "thought": "A great range far across the sea — the wide world, for when the eye pulls back.", "call": { "tool": "raise_terrain", "args": { "lat": -24, "lon": -98, "radius_deg": 26, "height": 0.09, "roughness": 0.7 } }, "observation": "ok: mountains risen at (-24,-98)" },
|
| 106 |
+
{ "thought": "Pale mushrooms on its cold flanks, faint as far lamps.", "call": { "tool": "spawn_flora", "args": { "lat": -28, "lon": -90, "radius_deg": 18, "kind": "mushrooms", "density": 0.7, "hue": 280 } }, "observation": "ok: flora seeded at (-28,-90)" },
|
| 107 |
+
{ "thought": "A slow ring of birds over the town, so the wide world keeps coming home.", "call": { "tool": "spawn_life", "args": { "lat": 14, "lon": 38, "radius_deg": 7, "kind": "birds", "count": 6, "hue": 32 } }, "observation": "ok: 6 birds stirring at (14,38)" },
|
| 108 |
+
{ "thought": "A breath of mist over the town, to soften the dusk.", "call": { "tool": "set_weather", "args": { "kind": "mist", "intensity": 0.22 } }, "observation": "ok: weather set to mist" },
|
| 109 |
+
{ "thought": "A whole world around one small town. Pull back, and see.", "call": null, "observation": "" }
|
| 110 |
],
|
| 111 |
+
"epitaph": "Mountains rise far off; the birds keep their slow ring above home.",
|
| 112 |
+
"feature_ids": ["f_000025", "f_000026", "f_000027", "f_000028"],
|
| 113 |
"model": "nemotron-3-nano (mock)",
|
| 114 |
"backend": "mock",
|
| 115 |
+
"ms_total": 52340
|
| 116 |
}
|
| 117 |
}
|
web/mock/world.json
CHANGED
|
@@ -1,64 +1,52 @@
|
|
| 1 |
{
|
| 2 |
"world": {
|
| 3 |
-
"version":
|
| 4 |
-
"epoch":
|
| 5 |
"features": [
|
| 6 |
{"id":"f_000000","wish_id":"genesis","tool":"set_sky","args":{"palette":"void","star_density":0.9,"moons":1},"seed":7,"t":0},
|
| 7 |
{"id":"f_000001","wish_id":"genesis","tool":"raise_terrain","args":{"lat":12,"lon":40,"radius_deg":28,"height":0.05,"roughness":0.7},"seed":11,"t":0},
|
| 8 |
{"id":"f_000002","wish_id":"genesis","tool":"place_structure","args":{"lat":14,"lon":38,"kind":"monolith","scale":1.4,"hue":270},"seed":13,"t":0},
|
| 9 |
{"id":"f_000003","wish_id":"genesis","tool":"inscribe_wish","args":{"text":"speak, and it will be made","style":"orbit"},"seed":17,"t":0},
|
| 10 |
|
| 11 |
-
{"id":"f_000004","wish_id":"w_000001","tool":"raise_terrain","args":{"lat":
|
| 12 |
-
{"id":"f_000005","wish_id":"w_000001","tool":"
|
| 13 |
-
{"id":"f_000006","wish_id":"w_000001","tool":"
|
| 14 |
-
|
| 15 |
-
{"id":"f_000007","wish_id":"w_000002","tool":"
|
| 16 |
-
{"id":"f_000008","wish_id":"w_000002","tool":"
|
| 17 |
-
|
| 18 |
-
{"id":"
|
| 19 |
-
{"id":"
|
| 20 |
-
|
| 21 |
-
{"id":"
|
| 22 |
-
{"id":"
|
| 23 |
-
{"id":"
|
| 24 |
-
|
| 25 |
-
{"id":"
|
| 26 |
-
|
| 27 |
-
{"id":"
|
| 28 |
-
|
| 29 |
-
{"id":"
|
| 30 |
-
|
| 31 |
-
{"id":"
|
| 32 |
-
|
| 33 |
-
{"id":"
|
| 34 |
-
{"id":"
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
{"id":"
|
| 38 |
-
|
| 39 |
-
{"id":"
|
| 40 |
-
{"id":"
|
| 41 |
-
|
| 42 |
-
{"id":"f_000026","wish_id":"w_000010","tool":"place_structure","args":{"lat":55,"lon":150,"kind":"beacon","scale":1.5,"hue":165},"seed":1032693173,"t":1718201000.0},
|
| 43 |
-
{"id":"f_000027","wish_id":"w_000010","tool":"set_sky","args":{"palette":"aurora","star_density":0.8,"moons":2},"seed":1250596131,"t":1718201003.0},
|
| 44 |
-
|
| 45 |
-
{"id":"f_000028","wish_id":"w_000011","tool":"raise_terrain","args":{"lat":38,"lon":-8,"radius_deg":12,"height":0.045,"roughness":0.4},"seed":1011863426,"t":1718201100.0},
|
| 46 |
-
{"id":"f_000029","wish_id":"w_000011","tool":"place_structure","args":{"lat":38,"lon":-8,"kind":"tower","scale":1.6,"hue":210},"seed":1263075092,"t":1718201103.0},
|
| 47 |
-
{"id":"f_000030","wish_id":"w_000011","tool":"place_structure","args":{"lat":36,"lon":-11,"kind":"warehouse","scale":1.2,"hue":44},"seed":1380036270,"t":1718201106.0},
|
| 48 |
-
{"id":"f_000031","wish_id":"w_000011","tool":"place_structure","args":{"lat":40,"lon":-5,"kind":"cafe","scale":1.0,"hue":36},"seed":625385016,"t":1718201109.0},
|
| 49 |
-
{"id":"f_000032","wish_id":"w_000011","tool":"place_water","args":{"kind":"stream","path":[[31,-18],[35,-12],[39,-6],[43,0]],"hue":200},"seed":992086939,"t":1718201112.0},
|
| 50 |
-
{"id":"f_000033","wish_id":"w_000011","tool":"spawn_life","args":{"lat":38,"lon":-9,"radius_deg":9,"kind":"carts","count":7,"hue":42},"seed":1277508365,"t":1718201115.0},
|
| 51 |
-
{"id":"f_000034","wish_id":"w_000011","tool":"spawn_life","args":{"lat":41,"lon":-4,"radius_deg":6,"kind":"fireflies","count":9,"hue":150},"seed":1011863426,"t":1718201118.0},
|
| 52 |
-
{"id":"f_000035","wish_id":"w_000011","tool":"spawn_life","args":{"lat":38,"lon":-8,"radius_deg":8,"kind":"birds","count":7,"hue":220},"seed":1263075092,"t":1718201121.0}
|
| 53 |
]
|
| 54 |
},
|
| 55 |
"queue": { "length": 0, "current": null },
|
| 56 |
"wishes_recent": [
|
| 57 |
-
{"wish_id":"
|
| 58 |
-
{"wish_id":"
|
| 59 |
-
{"wish_id":"
|
| 60 |
-
{"wish_id":"
|
| 61 |
-
{"wish_id":"
|
| 62 |
-
{"wish_id":"
|
| 63 |
]
|
| 64 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"world": {
|
| 3 |
+
"version": 41,
|
| 4 |
+
"epoch": 6,
|
| 5 |
"features": [
|
| 6 |
{"id":"f_000000","wish_id":"genesis","tool":"set_sky","args":{"palette":"void","star_density":0.9,"moons":1},"seed":7,"t":0},
|
| 7 |
{"id":"f_000001","wish_id":"genesis","tool":"raise_terrain","args":{"lat":12,"lon":40,"radius_deg":28,"height":0.05,"roughness":0.7},"seed":11,"t":0},
|
| 8 |
{"id":"f_000002","wish_id":"genesis","tool":"place_structure","args":{"lat":14,"lon":38,"kind":"monolith","scale":1.4,"hue":270},"seed":13,"t":0},
|
| 9 |
{"id":"f_000003","wish_id":"genesis","tool":"inscribe_wish","args":{"text":"speak, and it will be made","style":"orbit"},"seed":17,"t":0},
|
| 10 |
|
| 11 |
+
{"id":"f_000004","wish_id":"w_000001","tool":"raise_terrain","args":{"lat":14,"lon":38,"radius_deg":15,"height":0.03,"roughness":0.4},"seed":83077351,"t":1718200100.0},
|
| 12 |
+
{"id":"f_000005","wish_id":"w_000001","tool":"spawn_flora","args":{"lat":-6,"lon":118,"radius_deg":14,"kind":"glowgrass","density":0.6,"hue":150},"seed":1945409649,"t":1718200103.0},
|
| 13 |
+
{"id":"f_000006","wish_id":"w_000001","tool":"set_sky","args":{"palette":"dusk","star_density":0.55,"moons":1},"seed":1795017163,"t":1718200106.0},
|
| 14 |
+
|
| 15 |
+
{"id":"f_000007","wish_id":"w_000002","tool":"build_district","args":{"lat":14,"lon":38,"radius_deg":7,"density":0.95,"hue":36},"seed":112531134,"t":1718200200.0},
|
| 16 |
+
{"id":"f_000008","wish_id":"w_000002","tool":"place_structure","args":{"lat":14,"lon":38,"kind":"bank","scale":1.3,"hue":40},"seed":1907500584,"t":1718200203.0},
|
| 17 |
+
{"id":"f_000009","wish_id":"w_000002","tool":"place_structure","args":{"lat":13,"lon":39.5,"kind":"market","scale":1.1,"hue":30},"seed":1757116306,"t":1718200206.0},
|
| 18 |
+
{"id":"f_000010","wish_id":"w_000002","tool":"place_structure","args":{"lat":15.5,"lon":37,"kind":"tower","scale":1.35,"hue":210},"seed":532432644,"t":1718200209.0},
|
| 19 |
+
{"id":"f_000011","wish_id":"w_000002","tool":"place_road","args":{"path":[[13,39.5],[14,38],[15.5,37]]},"seed":30986919,"t":1718200212.0},
|
| 20 |
+
|
| 21 |
+
{"id":"f_000012","wish_id":"w_000003","tool":"build_district","args":{"lat":11.5,"lon":36.5,"radius_deg":6,"density":0.85,"hue":34},"seed":125271177,"t":1718200300.0},
|
| 22 |
+
{"id":"f_000013","wish_id":"w_000003","tool":"place_structure","args":{"lat":12,"lon":35.5,"kind":"warehouse","scale":1.1,"hue":42},"seed":1886407711,"t":1718200303.0},
|
| 23 |
+
{"id":"f_000014","wish_id":"w_000003","tool":"place_structure","args":{"lat":10.5,"lon":37,"kind":"house","scale":1.0,"hue":36},"seed":1769545125,"t":1718200306.0},
|
| 24 |
+
{"id":"f_000015","wish_id":"w_000003","tool":"place_road","args":{"path":[[11.5,36.5],[12.8,37.2],[14,38]]},"seed":511585587,"t":1718200309.0},
|
| 25 |
+
{"id":"f_000016","wish_id":"w_000003","tool":"spawn_life","args":{"lat":13,"lon":38,"radius_deg":7,"kind":"carts","count":8,"hue":42},"seed":1751184,"t":1718200312.0},
|
| 26 |
+
|
| 27 |
+
{"id":"f_000017","wish_id":"w_000004","tool":"place_water","args":{"kind":"stream","path":[[28,12],[22,20],[16,28]],"hue":205},"seed":37251596,"t":1718200400.0},
|
| 28 |
+
{"id":"f_000018","wish_id":"w_000004","tool":"place_water","args":{"kind":"pool","path":[[16,28]],"radius_deg":2,"hue":205},"seed":1967086234,"t":1718200403.0},
|
| 29 |
+
{"id":"f_000019","wish_id":"w_000004","tool":"spawn_flora","args":{"lat":2,"lon":108,"radius_deg":13,"kind":"trees","density":0.6,"hue":130},"seed":1815481120,"t":1718200406.0},
|
| 30 |
+
|
| 31 |
+
{"id":"f_000020","wish_id":"w_000005","tool":"build_district","args":{"lat":16.5,"lon":40.5,"radius_deg":5,"density":0.8,"hue":35},"seed":66715707,"t":1718200500.0},
|
| 32 |
+
{"id":"f_000021","wish_id":"w_000005","tool":"place_structure","args":{"lat":17,"lon":41,"kind":"cafe","scale":1.0,"hue":34},"seed":1962750125,"t":1718200503.0},
|
| 33 |
+
{"id":"f_000022","wish_id":"w_000005","tool":"place_structure","args":{"lat":16,"lon":42,"kind":"house","scale":1.0,"hue":36},"seed":1844732183,"t":1718200506.0},
|
| 34 |
+
{"id":"f_000023","wish_id":"w_000005","tool":"place_road","args":{"path":[[14,38],[15.5,39.5],[16.5,40.5]]},"seed":452153729,"t":1718200509.0},
|
| 35 |
+
{"id":"f_000024","wish_id":"w_000005","tool":"spawn_life","args":{"lat":17,"lon":41,"radius_deg":4,"kind":"fireflies","count":5,"hue":48},"seed":77055010,"t":1718200512.0},
|
| 36 |
+
|
| 37 |
+
{"id":"f_000025","wish_id":"w_000006","tool":"raise_terrain","args":{"lat":-24,"lon":-98,"radius_deg":26,"height":0.09,"roughness":0.7},"seed":29146722,"t":1718200600.0},
|
| 38 |
+
{"id":"f_000026","wish_id":"w_000006","tool":"spawn_flora","args":{"lat":-28,"lon":-90,"radius_deg":18,"kind":"mushrooms","density":0.7,"hue":280},"seed":1992003316,"t":1718200603.0},
|
| 39 |
+
{"id":"f_000027","wish_id":"w_000006","tool":"spawn_life","args":{"lat":14,"lon":38,"radius_deg":7,"kind":"birds","count":6,"hue":32},"seed":1873993550,"t":1718200606.0},
|
| 40 |
+
{"id":"f_000028","wish_id":"w_000006","tool":"set_weather","args":{"kind":"mist","intensity":0.22},"seed":414576600,"t":1718200609.0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
]
|
| 42 |
},
|
| 43 |
"queue": { "length": 0, "current": null },
|
| 44 |
"wishes_recent": [
|
| 45 |
+
{"wish_id":"w_000006","text":"a far range across the sea, and birds to circle the town at dusk","epitaph":"Mountains rise far off; the birds keep their slow ring above home.","epoch":6,"ts":1718200606.0},
|
| 46 |
+
{"wish_id":"w_000005","text":"a third quarter to the northeast — a cafe, a home, a road, and fireflies","epitaph":"A third block joins the town; one warm window, and the dusk fills with small lamps.","epoch":5,"ts":1718200503.0},
|
| 47 |
+
{"wish_id":"w_000004","text":"let a bright stream run down into the square, with a little pool","epitaph":"Water comes to the square now, and a stand of trees waits at the town's edge.","epoch":4,"ts":1718200406.0},
|
| 48 |
+
{"wish_id":"w_000003","text":"a denser quarter below the square, a warehouse and homes, a road up to it","epitaph":"A second block, joined to the first; the carts run the new street home at dusk.","epoch":3,"ts":1718200312.0},
|
| 49 |
+
{"wish_id":"w_000002","text":"build me a town — a district, a bank, a market, and a tower over the square","epitaph":"The town of Emberlyn stands: its bank, its market, its tower keeping the light.","epoch":2,"ts":1718200209.0},
|
| 50 |
+
{"wish_id":"w_000001","text":"level the ground by the monolith for building, under a dusk sky","epitaph":"The ground lies flat for building, where the town will rise.","epoch":1,"ts":1718200106.0}
|
| 51 |
]
|
| 52 |
}
|
web/planet/camera.js
CHANGED
|
@@ -1,17 +1,36 @@
|
|
| 1 |
-
// GODSEED — the god's gaze. A spherical camera rig
|
| 2 |
-
//
|
| 3 |
-
//
|
| 4 |
-
//
|
| 5 |
-
//
|
| 6 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import * as THREE from "three";
|
| 8 |
-
import { PLANET_R, clamp, lerp, easeInOutCubic } from "./util.js";
|
| 9 |
|
| 10 |
const WIDE_DIST = PLANET_R * 3.3;
|
| 11 |
const GAZE_DIST = PLANET_R * 2.35;
|
| 12 |
const MIN_DIST = PLANET_R * 1.45;
|
| 13 |
const MAX_DIST = PLANET_R * 7.0;
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
// Descent geometry. We ride just above the surface and glide a short arc.
|
| 16 |
const DESCENT_DIST = PLANET_R * 1.18; // eye distance from planet center at low point
|
| 17 |
const DESCENT_CLEAR = PLANET_R * 1.06; // the look target floats here, above terrain
|
|
@@ -35,12 +54,83 @@ export class CameraRig {
|
|
| 35 |
this.gazeHold = 0; // seconds left of god's-gaze hold
|
| 36 |
this.descent = null; // active descent state, or null
|
| 37 |
this._terrain = null; // optional terrain for analytic ground clearance
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
this._bind(dom);
|
| 39 |
}
|
| 40 |
|
| 41 |
/** Let the rig clear terrain analytically during descents (set by the app). */
|
| 42 |
setTerrain(terrain) { this._terrain = terrain; }
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
_bind(dom) {
|
| 45 |
if (!dom) return;
|
| 46 |
let dragging = false, px = 0, py = 0;
|
|
@@ -84,11 +174,26 @@ export class CameraRig {
|
|
| 84 |
}
|
| 85 |
|
| 86 |
/**
|
| 87 |
-
* Ease toward a world point
|
|
|
|
|
|
|
|
|
|
| 88 |
*/
|
| 89 |
gaze(point, { hold = 4.5 } = {}) {
|
| 90 |
if (this.userHold > 0 || this.descent) return; // hand on the camera / mid-descent
|
| 91 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
const dir = point.clone().normalize();
|
| 93 |
const { th, ph } = this._anglesFor(dir);
|
| 94 |
this.thetaT = th;
|
|
@@ -102,12 +207,12 @@ export class CameraRig {
|
|
| 102 |
this.gazeHold = hold;
|
| 103 |
}
|
| 104 |
|
| 105 |
-
/** Drift back to the
|
| 106 |
release() {
|
| 107 |
if (this.descent) return; // a descent owns the camera until it finishes/cancels
|
| 108 |
this.gazeHold = 0;
|
| 109 |
-
this.
|
| 110 |
-
this.lookT.set(0, 0, 0);
|
| 111 |
}
|
| 112 |
|
| 113 |
/**
|
|
@@ -136,6 +241,10 @@ export class CameraRig {
|
|
| 136 |
dir, north, start, end,
|
| 137 |
t: 0,
|
| 138 |
duration,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
// phase fractions: dive → glide → rise
|
| 140 |
dive: 0.34, glide: 0.40,
|
| 141 |
};
|
|
@@ -146,11 +255,16 @@ export class CameraRig {
|
|
| 146 |
cancelDescent() {
|
| 147 |
if (!this.descent) return;
|
| 148 |
this.descent = null;
|
| 149 |
-
// hand control back to
|
| 150 |
-
|
| 151 |
-
this.
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
this.gazeHold = 0.6;
|
| 155 |
}
|
| 156 |
|
|
@@ -172,12 +286,13 @@ export class CameraRig {
|
|
| 172 |
const u = clamp(d.t / d.duration, 0, 1);
|
| 173 |
// three eased phases sharing one progress value
|
| 174 |
let dist, lookDir, lookR;
|
|
|
|
| 175 |
if (u < d.dive) {
|
| 176 |
// orbit → low point over the start of the glide
|
| 177 |
const k = easeInOutCubic(u / d.dive);
|
| 178 |
-
dist = lerp(
|
| 179 |
lookDir = d.start.clone();
|
| 180 |
-
lookR = lerp(
|
| 181 |
} else if (u < d.dive + d.glide) {
|
| 182 |
// glide arc: sweep eye + a forward look-ahead along the surface
|
| 183 |
const k = (u - d.dive) / d.glide;
|
|
@@ -187,12 +302,14 @@ export class CameraRig {
|
|
| 187 |
lookDir = ahead;
|
| 188 |
lookR = DESCENT_CLEAR;
|
| 189 |
} else {
|
| 190 |
-
// rise back to
|
|
|
|
| 191 |
const k = easeInOutCubic((u - d.dive - d.glide) / Math.max(1e-3, 1 - d.dive - d.glide));
|
| 192 |
-
const dir = slerpToOrbit(d.end, k);
|
| 193 |
-
dist = lerp(this._groundClear(d.end),
|
| 194 |
lookDir = dir;
|
| 195 |
-
|
|
|
|
| 196 |
}
|
| 197 |
// place the eye directly (no spherical-lerp; the descent is the authority)
|
| 198 |
const eye = lookDir.clone();
|
|
@@ -221,12 +338,14 @@ export class CameraRig {
|
|
| 221 |
if (this.gazeHold > 0) {
|
| 222 |
this.gazeHold -= dt;
|
| 223 |
if (this.gazeHold <= 0) {
|
| 224 |
-
|
| 225 |
-
this.
|
|
|
|
| 226 |
}
|
| 227 |
-
} else if (this.userHold <= 0) {
|
| 228 |
-
this.thetaT += this.idleSpeed * dt; // eternal slow orbit
|
| 229 |
}
|
|
|
|
| 230 |
const k = 1 - Math.exp(-dt * 2.4);
|
| 231 |
this.theta = lerp(this.theta, this.thetaT, k);
|
| 232 |
this.phi = lerp(this.phi, this.phiT, k);
|
|
|
|
| 1 |
+
// GODSEED — the god's gaze. A spherical camera rig with two modes:
|
| 2 |
+
// TOWN (default): sits LOW and close over townCenter, framing a ~28° cap so
|
| 3 |
+
// buildings read large — like standing over a model town.
|
| 4 |
+
// WORLD (the reveal): eases out to the full-planet orbit (the wide shot) and
|
| 5 |
+
// back, via rig.beholdWorld()/rig.setMode().
|
| 6 |
+
// On each tool_call / world_delta it eases over to look at where the world is
|
| 7 |
+
// about to change — within the town zoom in TOWN mode, never flying to the far
|
| 8 |
+
// limb. Drag to orbit; wheel to approach. On a granted wish (or a Genesis Log
|
| 9 |
+
// replay) it can also descend — a slow liturgical swoop to the surface over the
|
| 10 |
+
// target, a glide arc, then a rise back. Every motion is sim-time deterministic
|
| 11 |
+
// (driven only by update(dt)); no Date/random.
|
| 12 |
import * as THREE from "three";
|
| 13 |
+
import { PLANET_R, clamp, lerp, easeInOutCubic, townCenter } from "./util.js";
|
| 14 |
|
| 15 |
const WIDE_DIST = PLANET_R * 3.3;
|
| 16 |
const GAZE_DIST = PLANET_R * 2.35;
|
| 17 |
const MIN_DIST = PLANET_R * 1.45;
|
| 18 |
const MAX_DIST = PLANET_R * 7.0;
|
| 19 |
|
| 20 |
+
// TOWN view: a low, close vantage that frames a ~28° cap around the town heart.
|
| 21 |
+
// The eye rides just above the surface; the look target floats over the town so
|
| 22 |
+
// buildings stand up into the frame. Tuned (with shot.mjs) so one district reads
|
| 23 |
+
// large yet the whole neighbourhood stays in view.
|
| 24 |
+
const TOWN_CAP = 0.49; // ~28°, the framed cap half-angle (the spec)
|
| 25 |
+
// Eye distance so the cap roughly fills the 42° FOV from a low oblique. Derived
|
| 26 |
+
// from the cap so the framing follows the spec; the small factor is the visual
|
| 27 |
+
// tune (a hair tighter than the geometric fit so buildings read large).
|
| 28 |
+
const TOWN_DIST = PLANET_R * (1 + Math.sin(TOWN_CAP) * 1.27); // ≈ 1.6 R
|
| 29 |
+
const TOWN_TILT = TOWN_CAP * 0.62; // eye sits this far "south" of the town for a low oblique look
|
| 30 |
+
const TOWN_LOOK_R = PLANET_R * 1.04; // look target floats just above the ground
|
| 31 |
+
// While granting in TOWN mode the gaze nudges a touch closer to the new feature.
|
| 32 |
+
const TOWN_GAZE_DIST = TOWN_DIST * 0.93;
|
| 33 |
+
|
| 34 |
// Descent geometry. We ride just above the surface and glide a short arc.
|
| 35 |
const DESCENT_DIST = PLANET_R * 1.18; // eye distance from planet center at low point
|
| 36 |
const DESCENT_CLEAR = PLANET_R * 1.06; // the look target floats here, above terrain
|
|
|
|
| 54 |
this.gazeHold = 0; // seconds left of god's-gaze hold
|
| 55 |
this.descent = null; // active descent state, or null
|
| 56 |
this._terrain = null; // optional terrain for analytic ground clearance
|
| 57 |
+
// --- town/world mode ---
|
| 58 |
+
this.mode = "town"; // "town" (default, close) | "world" (the reveal)
|
| 59 |
+
this.townDir = new THREE.Vector3(); // current town heart (unit dir)
|
| 60 |
+
townCenter([], this.townDir); // seed at the genesis monolith
|
| 61 |
this._bind(dom);
|
| 62 |
}
|
| 63 |
|
| 64 |
/** Let the rig clear terrain analytically during descents (set by the app). */
|
| 65 |
setTerrain(terrain) { this._terrain = terrain; }
|
| 66 |
|
| 67 |
+
/**
|
| 68 |
+
* Re-anchor the town heart from the live feature list and, if we're resting in
|
| 69 |
+
* TOWN mode, re-frame to follow it (the town centroid drifts as it grows). No
|
| 70 |
+
* effect while granting (gazeHold), under the hand (userHold), or mid-descent.
|
| 71 |
+
*/
|
| 72 |
+
setTownCenter(features) {
|
| 73 |
+
const { dir } = townCenter(features);
|
| 74 |
+
this.townDir.copy(dir);
|
| 75 |
+
if (this.mode === "town" && !this.descent && this.userHold <= 0 && this.gazeHold <= 0) {
|
| 76 |
+
this._frameTown();
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/** Switch modes with an eased transition (sim-time deterministic). */
|
| 81 |
+
setMode(mode) {
|
| 82 |
+
const next = mode === "world" ? "world" : "town";
|
| 83 |
+
if (next === this.mode) return;
|
| 84 |
+
this.mode = next;
|
| 85 |
+
if (this.descent) this.cancelDescent();
|
| 86 |
+
this.userHold = 0;
|
| 87 |
+
this.gazeHold = 0;
|
| 88 |
+
if (next === "town") this._frameTown();
|
| 89 |
+
else this._frameWorld();
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/** Toggle between beholding the whole world and returning to the town. */
|
| 93 |
+
beholdWorld() {
|
| 94 |
+
this.setMode(this.mode === "world" ? "town" : "world");
|
| 95 |
+
return this.mode;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/** Spherical angles + distance that frame the close TOWN view over townDir. */
|
| 99 |
+
_frameTown() {
|
| 100 |
+
const { th, ph } = this._townAngles(this.townDir);
|
| 101 |
+
this.thetaT = th;
|
| 102 |
+
this.phiT = ph;
|
| 103 |
+
this.distT = TOWN_DIST;
|
| 104 |
+
this.lookT.copy(this.townDir).multiplyScalar(TOWN_LOOK_R);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/** The wide full-planet orbit (the reveal). */
|
| 108 |
+
_frameWorld() {
|
| 109 |
+
this.distT = WIDE_DIST;
|
| 110 |
+
this.lookT.set(0, 0, 0);
|
| 111 |
+
// keep the town roughly framed in the wide shot so the reveal reads as "this
|
| 112 |
+
// patch is one town on a whole world", not a jump-cut to nowhere.
|
| 113 |
+
const { th, ph } = this._anglesFor(this.townDir);
|
| 114 |
+
this.thetaT = th;
|
| 115 |
+
this.phiT = ph;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* Spherical (theta, phi) for the LOW oblique town look: the eye sits a little
|
| 120 |
+
* "south" (toward higher phi) of the town heart so buildings stand up into the
|
| 121 |
+
* frame, and the look target is the town itself.
|
| 122 |
+
*/
|
| 123 |
+
_townAngles(dir) {
|
| 124 |
+
const tau = Math.PI * 2;
|
| 125 |
+
let th = Math.atan2(dir.z, dir.x);
|
| 126 |
+
// drop the eye below the town's own latitude for the oblique, model-town read
|
| 127 |
+
const base = Math.acos(clamp(dir.y, -1, 1));
|
| 128 |
+
const ph = clamp(base + TOWN_TILT, PHI_FLOOR, PHI_CEIL);
|
| 129 |
+
while (th - this.theta > Math.PI) th -= tau;
|
| 130 |
+
while (th - this.theta < -Math.PI) th += tau;
|
| 131 |
+
return { th, ph };
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
_bind(dom) {
|
| 135 |
if (!dom) return;
|
| 136 |
let dragging = false, px = 0, py = 0;
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
/**
|
| 177 |
+
* Ease toward a world point — the demo money-shot. In TOWN mode this frames
|
| 178 |
+
* the new feature WITHIN the close town zoom (never flying to the far limb);
|
| 179 |
+
* in WORLD mode it eases over the planet to the spot. `point` null ⇒ the
|
| 180 |
+
* mode's resting frame (town heart / wide shot).
|
| 181 |
*/
|
| 182 |
gaze(point, { hold = 4.5 } = {}) {
|
| 183 |
if (this.userHold > 0 || this.descent) return; // hand on the camera / mid-descent
|
| 184 |
+
if (this.mode === "town") {
|
| 185 |
+
// a new feature is always near the town; keep the low, close framing and
|
| 186 |
+
// just lean the look toward it so it reads big without leaving the town.
|
| 187 |
+
const dir = point ? point.clone().normalize() : this.townDir;
|
| 188 |
+
// blend the look between the town heart and the new feature so the whole
|
| 189 |
+
// block stays in the cap — don't snap the eye to a stray far-flung build.
|
| 190 |
+
const lookDir = this.townDir.clone().lerp(dir, point ? 0.55 : 0).normalize();
|
| 191 |
+
const { th, ph } = this._townAngles(lookDir);
|
| 192 |
+
this.thetaT = th;
|
| 193 |
+
this.phiT = ph;
|
| 194 |
+
this.distT = point ? TOWN_GAZE_DIST : TOWN_DIST;
|
| 195 |
+
this.lookT.copy(lookDir).multiplyScalar(TOWN_LOOK_R);
|
| 196 |
+
} else if (point) {
|
| 197 |
const dir = point.clone().normalize();
|
| 198 |
const { th, ph } = this._anglesFor(dir);
|
| 199 |
this.thetaT = th;
|
|
|
|
| 207 |
this.gazeHold = hold;
|
| 208 |
}
|
| 209 |
|
| 210 |
+
/** Drift back to the mode's resting frame (town heart in TOWN, wide in WORLD). */
|
| 211 |
release() {
|
| 212 |
if (this.descent) return; // a descent owns the camera until it finishes/cancels
|
| 213 |
this.gazeHold = 0;
|
| 214 |
+
if (this.mode === "town") this._frameTown();
|
| 215 |
+
else { this.distT = WIDE_DIST; this.lookT.set(0, 0, 0); }
|
| 216 |
}
|
| 217 |
|
| 218 |
/**
|
|
|
|
| 241 |
dir, north, start, end,
|
| 242 |
t: 0,
|
| 243 |
duration,
|
| 244 |
+
// the orbit framing to dive from / rise back to — the town zoom in TOWN
|
| 245 |
+
// mode, the wide shot in WORLD mode
|
| 246 |
+
orbitDist: this.mode === "town" ? TOWN_DIST : WIDE_DIST,
|
| 247 |
+
townMode: this.mode === "town",
|
| 248 |
// phase fractions: dive → glide → rise
|
| 249 |
dive: 0.34, glide: 0.40,
|
| 250 |
};
|
|
|
|
| 255 |
cancelDescent() {
|
| 256 |
if (!this.descent) return;
|
| 257 |
this.descent = null;
|
| 258 |
+
// hand control back to the mode's resting frame (back over the town in TOWN
|
| 259 |
+
// mode, a gentle wide pull in WORLD mode)
|
| 260 |
+
if (this.mode === "town") {
|
| 261 |
+
this._frameTown();
|
| 262 |
+
} else {
|
| 263 |
+
this.distT = WIDE_DIST;
|
| 264 |
+
this.lookT.set(0, 0, 0);
|
| 265 |
+
this.thetaT = this.theta;
|
| 266 |
+
this.phiT = this.phi;
|
| 267 |
+
}
|
| 268 |
this.gazeHold = 0.6;
|
| 269 |
}
|
| 270 |
|
|
|
|
| 286 |
const u = clamp(d.t / d.duration, 0, 1);
|
| 287 |
// three eased phases sharing one progress value
|
| 288 |
let dist, lookDir, lookR;
|
| 289 |
+
const orbitLookR = d.townMode ? TOWN_LOOK_R : PLANET_R * 0.5;
|
| 290 |
if (u < d.dive) {
|
| 291 |
// orbit → low point over the start of the glide
|
| 292 |
const k = easeInOutCubic(u / d.dive);
|
| 293 |
+
dist = lerp(d.orbitDist, this._groundClear(d.start), k);
|
| 294 |
lookDir = d.start.clone();
|
| 295 |
+
lookR = lerp(orbitLookR, DESCENT_CLEAR, k);
|
| 296 |
} else if (u < d.dive + d.glide) {
|
| 297 |
// glide arc: sweep eye + a forward look-ahead along the surface
|
| 298 |
const k = (u - d.dive) / d.glide;
|
|
|
|
| 302 |
lookDir = ahead;
|
| 303 |
lookR = DESCENT_CLEAR;
|
| 304 |
} else {
|
| 305 |
+
// rise back to the resting frame. In TOWN mode re-acquire the close town
|
| 306 |
+
// look (stay over the town); in WORLD mode pull up to the wide framing.
|
| 307 |
const k = easeInOutCubic((u - d.dive - d.glide) / Math.max(1e-3, 1 - d.dive - d.glide));
|
| 308 |
+
const dir = d.townMode ? slerp(d.end, this.townDir, k) : slerpToOrbit(d.end, k);
|
| 309 |
+
dist = lerp(this._groundClear(d.end), d.orbitDist, k);
|
| 310 |
lookDir = dir;
|
| 311 |
+
// TOWN: settle the look just above the town; WORLD: ease the look to center
|
| 312 |
+
lookR = lerp(DESCENT_CLEAR, d.townMode ? orbitLookR : 0, k);
|
| 313 |
}
|
| 314 |
// place the eye directly (no spherical-lerp; the descent is the authority)
|
| 315 |
const eye = lookDir.clone();
|
|
|
|
| 338 |
if (this.gazeHold > 0) {
|
| 339 |
this.gazeHold -= dt;
|
| 340 |
if (this.gazeHold <= 0) {
|
| 341 |
+
// a held gaze expires back to the mode's resting frame
|
| 342 |
+
if (this.mode === "town") this._frameTown();
|
| 343 |
+
else { this.distT = WIDE_DIST; this.lookT.set(0, 0, 0); }
|
| 344 |
}
|
| 345 |
+
} else if (this.userHold <= 0 && this.mode === "world") {
|
| 346 |
+
this.thetaT += this.idleSpeed * dt; // eternal slow orbit (WORLD only)
|
| 347 |
}
|
| 348 |
+
// TOWN mode holds steady over the town — the town's own life is the motion.
|
| 349 |
const k = 1 - Math.exp(-dt * 2.4);
|
| 350 |
this.theta = lerp(this.theta, this.thetaT, k);
|
| 351 |
this.phi = lerp(this.phi, this.phiT, k);
|
web/planet/district.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// GODSEED — districts. The build_district tool is the SimCity unlock: ONE call
|
| 2 |
+
// lays down a whole neighbourhood. From the feature's engine seed we
|
| 3 |
+
// deterministically place a cluster of small gabled houses on a rough jittered
|
| 4 |
+
// grid inside radius_deg, lace footpaths between the rows, and drop one civic
|
| 5 |
+
// building (bank / market / tower) at the heart of the block. Houses are drawn
|
| 6 |
+
// as a handful of InstancedMeshes (body, roof, ridge, window) so a dense
|
| 7 |
+
// 8..40-home block stays cheap. Everything is a pure function of (seed, terrain)
|
| 8 |
+
// — no rAF, no Date — so replay and headless capture are byte-stable.
|
| 9 |
+
import * as THREE from "three";
|
| 10 |
+
import { mulberry32 } from "./rng.js";
|
| 11 |
+
import {
|
| 12 |
+
PLANET_R, DEG, clamp, easeOutBack, uprightQuat, hsl,
|
| 13 |
+
} from "./util.js";
|
| 14 |
+
import { Structure } from "./structures.js";
|
| 15 |
+
|
| 16 |
+
const WOOD = new THREE.MeshStandardMaterial({ color: 0x2b2118, roughness: 0.9 });
|
| 17 |
+
const ROOF = new THREE.MeshStandardMaterial({ color: 0x191210, roughness: 0.85 });
|
| 18 |
+
const STONE_DARK = new THREE.MeshStandardMaterial({ color: 0x14131c, roughness: 0.6, metalness: 0.3 });
|
| 19 |
+
const PATH = new THREE.MeshStandardMaterial({
|
| 20 |
+
color: 0x7d6a50, roughness: 0.95, metalness: 0.04, side: THREE.DoubleSide,
|
| 21 |
+
});
|
| 22 |
+
const WARM = new THREE.Color("#ffcf8a");
|
| 23 |
+
|
| 24 |
+
// civic anchors a district can crown itself with (seeded pick)
|
| 25 |
+
const CIVIC_KINDS = ["bank", "market", "tower"];
|
| 26 |
+
|
| 27 |
+
class District {
|
| 28 |
+
constructor(scene, terrain, f, t0, animate) {
|
| 29 |
+
const a = f.args;
|
| 30 |
+
this.f = f;
|
| 31 |
+
this.t0 = t0;
|
| 32 |
+
this.animate = animate;
|
| 33 |
+
this.terrain = terrain;
|
| 34 |
+
this.scene = scene;
|
| 35 |
+
this.center = new THREE.Vector3();
|
| 36 |
+
const la = clamp(a.lat ?? 0, -90, 90) * DEG, lo = (a.lon ?? 0) * DEG;
|
| 37 |
+
this.center.set(Math.cos(la) * Math.cos(lo), Math.sin(la), Math.cos(la) * Math.sin(lo));
|
| 38 |
+
this.radiusRad = clamp(a.radius_deg ?? 6, 2, 15) * DEG;
|
| 39 |
+
this.reachRad = this.radiusRad + 0.05;
|
| 40 |
+
const density = clamp(a.density ?? 0.5, 0, 1);
|
| 41 |
+
this.hue = a.hue ?? 38;
|
| 42 |
+
this.warm = hsl(this.hue, 0.6, 0.6).lerp(WARM, 0.5);
|
| 43 |
+
|
| 44 |
+
const rng = mulberry32(f.seed);
|
| 45 |
+
this.rng = rng;
|
| 46 |
+
this.group = new THREE.Group();
|
| 47 |
+
scene.add(this.group);
|
| 48 |
+
|
| 49 |
+
// density → 8..40 homes. radius limits how many fit on the grid.
|
| 50 |
+
const want = Math.round(8 + density * 32);
|
| 51 |
+
this._layout(rng, want);
|
| 52 |
+
this._buildHouses(rng);
|
| 53 |
+
this._buildPaths(rng);
|
| 54 |
+
this._buildCivic(scene, terrain, f, t0, animate);
|
| 55 |
+
|
| 56 |
+
if (!animate) this.group.scale.setScalar(1);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// ---- jittered grid of house plots inside the cap ------------------------
|
| 60 |
+
_layout(rng, want) {
|
| 61 |
+
// tangent basis at the district centre
|
| 62 |
+
this.t1 = new THREE.Vector3(0, 1, 0);
|
| 63 |
+
if (Math.abs(this.center.y) > 0.93) this.t1.set(1, 0, 0);
|
| 64 |
+
this.t2 = new THREE.Vector3().crossVectors(this.center, this.t1).normalize();
|
| 65 |
+
this.t1.crossVectors(this.t2, this.center).normalize();
|
| 66 |
+
// a roughly-square grid that fills the cap; keep cells inside the radius
|
| 67 |
+
const cols = clamp(Math.round(Math.sqrt(want) + 0.5), 3, 8);
|
| 68 |
+
const rows = clamp(Math.ceil(want / cols), 3, 8);
|
| 69 |
+
this.cols = cols; this.rows = rows;
|
| 70 |
+
const span = this.radiusRad * 0.9; // grid half-extent (radians)
|
| 71 |
+
this.cell = (2 * span) / Math.max(cols, rows);
|
| 72 |
+
// a seeded street rotation so blocks don't all align to the basis
|
| 73 |
+
const yaw = rng() * Math.PI * 2;
|
| 74 |
+
const cy = Math.cos(yaw), sy = Math.sin(yaw);
|
| 75 |
+
this.plots = []; // {dir, gx, gy, occupied}
|
| 76 |
+
for (let r = 0; r < rows; r++) {
|
| 77 |
+
for (let c = 0; c < cols; c++) {
|
| 78 |
+
const lx = (c - (cols - 1) / 2) * this.cell;
|
| 79 |
+
const ly = (r - (rows - 1) / 2) * this.cell;
|
| 80 |
+
// rotate into street grid, then jitter within the plot
|
| 81 |
+
let ux = lx * cy - ly * sy;
|
| 82 |
+
let uy = lx * sy + ly * cy;
|
| 83 |
+
const ang = Math.hypot(ux, uy);
|
| 84 |
+
if (ang > this.radiusRad * 0.96) continue; // trim corners → rounded block
|
| 85 |
+
const jx = ux + (rng() - 0.5) * this.cell * 0.34;
|
| 86 |
+
const jy = uy + (rng() - 0.5) * this.cell * 0.34;
|
| 87 |
+
const dir = this._tangentDir(jx, jy);
|
| 88 |
+
this.plots.push({ dir, gx: c, gy: r, raw: [ux, uy] });
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
// keep the centre plot clear for the civic building
|
| 92 |
+
this.plots.sort((p, q) => this._d2(p) - this._d2(q));
|
| 93 |
+
this.civicPlot = this.plots.shift() || { dir: this.center.clone(), raw: [0, 0] };
|
| 94 |
+
// density also thins the outer plots so low density reads as a hamlet
|
| 95 |
+
const keep = clamp(want, 4, this.plots.length);
|
| 96 |
+
this.plots = this.plots.slice(0, keep);
|
| 97 |
+
this.yaw = yaw;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
_d2(p) { return p.raw[0] * p.raw[0] + p.raw[1] * p.raw[1]; }
|
| 101 |
+
|
| 102 |
+
_tangentDir(x, y, out = new THREE.Vector3()) {
|
| 103 |
+
const ang = Math.hypot(x, y);
|
| 104 |
+
if (ang < 1e-7) return out.copy(this.center);
|
| 105 |
+
return out.copy(this.center).multiplyScalar(Math.cos(ang))
|
| 106 |
+
.addScaledVector(this.t2, Math.sin(ang) * (x / ang))
|
| 107 |
+
.addScaledVector(this.t1, Math.sin(ang) * (y / ang))
|
| 108 |
+
.normalize();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// ---- the homes (instanced) ---------------------------------------------
|
| 112 |
+
_buildHouses(rng) {
|
| 113 |
+
const n = this.plots.length;
|
| 114 |
+
if (n === 0) { this.houseMeshes = []; return; }
|
| 115 |
+
// five instanced parts make up every home; one InstancedMesh per part.
|
| 116 |
+
const bodyGeo = new THREE.BoxGeometry(1, 1, 1);
|
| 117 |
+
const roofGeo = new THREE.BoxGeometry(1, 1, 1);
|
| 118 |
+
const ridgeGeo = new THREE.BoxGeometry(1, 1, 1);
|
| 119 |
+
const winGeo = new THREE.PlaneGeometry(1, 1);
|
| 120 |
+
this.winMat = new THREE.MeshBasicMaterial({ color: this.warm.clone().multiplyScalar(1.7) });
|
| 121 |
+
const body = new THREE.InstancedMesh(bodyGeo, WOOD, n);
|
| 122 |
+
const roofL = new THREE.InstancedMesh(roofGeo, ROOF, n);
|
| 123 |
+
const roofR = new THREE.InstancedMesh(roofGeo, ROOF, n);
|
| 124 |
+
const ridge = new THREE.InstancedMesh(ridgeGeo, STONE_DARK, n);
|
| 125 |
+
const win = new THREE.InstancedMesh(winGeo, this.winMat, n);
|
| 126 |
+
for (const m of [body, roofL, roofR, ridge, win]) {
|
| 127 |
+
m.frustumCulled = false;
|
| 128 |
+
this.group.add(m);
|
| 129 |
+
}
|
| 130 |
+
this.houseMeshes = [body, roofL, roofR, ridge, win];
|
| 131 |
+
this.houseGeos = [bodyGeo, roofGeo, ridgeGeo, winGeo];
|
| 132 |
+
|
| 133 |
+
const mat = new THREE.Matrix4(), pos = new THREE.Vector3(), quat = new THREE.Quaternion();
|
| 134 |
+
const scl = new THREE.Vector3(), q2 = new THREE.Quaternion(), local = new THREE.Matrix4();
|
| 135 |
+
const e = new THREE.Euler();
|
| 136 |
+
this.plots.forEach((plot, i) => {
|
| 137 |
+
const dir = plot.dir;
|
| 138 |
+
const h = this.terrain.heightAt(dir);
|
| 139 |
+
// home dimensions (same small range as structures.js houseProto)
|
| 140 |
+
const w = 0.019 + rng() * 0.009, d = 0.016 + rng() * 0.006, ht = 0.013 + rng() * 0.006;
|
| 141 |
+
const roofH = 0.009 + rng() * 0.004;
|
| 142 |
+
const yaw = rng() * Math.PI * 2;
|
| 143 |
+
// world placement: stand the home upright on the surface, spun by yaw
|
| 144 |
+
pos.copy(dir).multiplyScalar(PLANET_R + h - 0.001);
|
| 145 |
+
uprightQuat(dir, yaw, quat);
|
| 146 |
+
plot.center = dir;
|
| 147 |
+
|
| 148 |
+
const place = (localPos, localEuler, localScale, target) => {
|
| 149 |
+
e.copy(localEuler);
|
| 150 |
+
q2.setFromEuler(e);
|
| 151 |
+
local.compose(localPos, q2, localScale);
|
| 152 |
+
mat.compose(pos, quat, new THREE.Vector3(1, 1, 1)).multiply(local);
|
| 153 |
+
target.setMatrixAt(i, mat);
|
| 154 |
+
};
|
| 155 |
+
place(new THREE.Vector3(0, ht / 2, 0), new THREE.Euler(0, 0, 0), new THREE.Vector3(w, ht, d), body);
|
| 156 |
+
// two roof slabs sloping to a ridge
|
| 157 |
+
const slopeAng = Math.atan2(roofH, d * 0.5);
|
| 158 |
+
place(new THREE.Vector3(0, ht + roofH / 2, d * 0.26), new THREE.Euler(slopeAng, 0, 0),
|
| 159 |
+
new THREE.Vector3(w * 1.04, 0.0025, d * 0.64), roofL);
|
| 160 |
+
place(new THREE.Vector3(0, ht + roofH / 2, -d * 0.26), new THREE.Euler(-slopeAng, 0, 0),
|
| 161 |
+
new THREE.Vector3(w * 1.04, 0.0025, d * 0.64), roofR);
|
| 162 |
+
place(new THREE.Vector3(0, ht + roofH, 0), new THREE.Euler(0, 0, 0),
|
| 163 |
+
new THREE.Vector3(w * 1.04, 0.0028, 0.0028), ridge);
|
| 164 |
+
// one lit window on a long face
|
| 165 |
+
const face = rng() < 0.5 ? 1 : -1;
|
| 166 |
+
place(new THREE.Vector3((rng() - 0.5) * w * 0.4, ht * 0.52, face * (d / 2 + 0.0006)),
|
| 167 |
+
new THREE.Euler(0, face < 0 ? Math.PI : 0, 0),
|
| 168 |
+
new THREE.Vector3(w * 0.34, ht * 0.46, 1), win);
|
| 169 |
+
});
|
| 170 |
+
for (const m of this.houseMeshes) m.instanceMatrix.needsUpdate = true;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// ---- footpaths: thin matte ribbons threading the block ------------------
|
| 174 |
+
_buildPaths(rng) {
|
| 175 |
+
if (this.plots.length < 2) { this.pathMesh = null; return; }
|
| 176 |
+
// connect each house to the civic centre via a short path, plus a few
|
| 177 |
+
// neighbour-to-neighbour links → a little web of streets. All flat ribbons
|
| 178 |
+
// hugging the surface, matte stone (NOT the glowing water look).
|
| 179 |
+
const segs = [];
|
| 180 |
+
const hub = this.civicPlot.center || this.center;
|
| 181 |
+
// trunk paths from civic hub outwards to a handful of homes
|
| 182 |
+
const trunkN = Math.min(this.plots.length, 4 + Math.floor(rng() * 3));
|
| 183 |
+
for (let i = 0; i < trunkN; i++) {
|
| 184 |
+
const p = this.plots[Math.floor(rng() * this.plots.length)];
|
| 185 |
+
if (p.center) segs.push([hub, p.center]);
|
| 186 |
+
}
|
| 187 |
+
// short links between nearby homes so rows read as connected
|
| 188 |
+
for (let i = 0; i < this.plots.length; i++) {
|
| 189 |
+
const a = this.plots[i];
|
| 190 |
+
if (!a.center) continue;
|
| 191 |
+
let best = null, bestD = Infinity;
|
| 192 |
+
for (let j = 0; j < this.plots.length; j++) {
|
| 193 |
+
if (i === j || !this.plots[j].center) continue;
|
| 194 |
+
const dd = a.center.distanceToSquared(this.plots[j].center);
|
| 195 |
+
if (dd < bestD) { bestD = dd; best = this.plots[j]; }
|
| 196 |
+
}
|
| 197 |
+
if (best && rng() < 0.6) segs.push([a.center, best.center]);
|
| 198 |
+
}
|
| 199 |
+
const geo = this._pathGeo(segs);
|
| 200 |
+
if (!geo) { this.pathMesh = null; return; }
|
| 201 |
+
this.pathMesh = new THREE.Mesh(geo, PATH);
|
| 202 |
+
this.pathMesh.frustumCulled = false;
|
| 203 |
+
this.group.add(this.pathMesh);
|
| 204 |
+
this.pathGeo = geo;
|
| 205 |
+
this.pathSegs = segs;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
_pathGeo(segs) {
|
| 209 |
+
const positions = [], index = [];
|
| 210 |
+
const right = new THREE.Vector3(), fwd = new THREE.Vector3(), up = new THREE.Vector3();
|
| 211 |
+
const a = new THREE.Vector3(), b = new THREE.Vector3();
|
| 212 |
+
const half = 0.0045; // narrow footpath
|
| 213 |
+
let base = 0;
|
| 214 |
+
for (const [da, db] of segs) {
|
| 215 |
+
fwd.copy(db).sub(da).normalize();
|
| 216 |
+
const steps = 5;
|
| 217 |
+
for (let s = 0; s <= steps; s++) {
|
| 218 |
+
// slerp-ish along the short chord, lifted onto the terrain
|
| 219 |
+
const t = s / steps;
|
| 220 |
+
a.copy(da).lerp(db, t).normalize();
|
| 221 |
+
up.copy(a);
|
| 222 |
+
right.crossVectors(fwd, up).normalize();
|
| 223 |
+
const h = this.terrain.heightAt(a);
|
| 224 |
+
const r = PLANET_R + h + 0.003;
|
| 225 |
+
for (const side of [-1, 1]) {
|
| 226 |
+
b.copy(a).multiplyScalar(r).addScaledVector(right, side * half);
|
| 227 |
+
positions.push(b.x, b.y, b.z);
|
| 228 |
+
}
|
| 229 |
+
if (s < steps) {
|
| 230 |
+
const k = base + s * 2;
|
| 231 |
+
index.push(k, k + 1, k + 2, k + 1, k + 3, k + 2);
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
base += (steps + 1) * 2;
|
| 235 |
+
}
|
| 236 |
+
if (positions.length === 0) return null;
|
| 237 |
+
const g = new THREE.BufferGeometry();
|
| 238 |
+
g.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
| 239 |
+
g.setIndex(index);
|
| 240 |
+
g.computeVertexNormals();
|
| 241 |
+
return g;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
// ---- the civic crown ----------------------------------------------------
|
| 245 |
+
_buildCivic(scene, terrain, f, t0, animate) {
|
| 246 |
+
const kind = CIVIC_KINDS[Math.floor(this.rng() * CIVIC_KINDS.length)];
|
| 247 |
+
const dir = this.civicPlot.center || this.center;
|
| 248 |
+
// forge a sub-feature so the civic building reuses the full Structure
|
| 249 |
+
// pipeline (terrain seating, spawn pop, per-frame glow) untouched.
|
| 250 |
+
const civicSeed = (f.seed ^ 0x9e3779b9) >>> 0;
|
| 251 |
+
const cf = {
|
| 252 |
+
seed: civicSeed,
|
| 253 |
+
args: {
|
| 254 |
+
lat: Math.asin(clamp(dir.y, -1, 1)) / DEG,
|
| 255 |
+
lon: Math.atan2(dir.z, dir.x) / DEG,
|
| 256 |
+
kind,
|
| 257 |
+
scale: kind === "tower" ? 0.9 : 1.0,
|
| 258 |
+
hue: this.hue,
|
| 259 |
+
},
|
| 260 |
+
};
|
| 261 |
+
this.civic = new Structure(scene, terrain, cf, t0, animate);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
update(dt, t) {
|
| 265 |
+
// grow-in: the whole block scales up with a gentle overshoot on placement
|
| 266 |
+
if (this.animate) {
|
| 267 |
+
const k = easeOutBack((t - this.t0) / 1.1);
|
| 268 |
+
this.group.scale.setScalar(Math.max(0.0001, k));
|
| 269 |
+
if (t - this.t0 > 1.1) { this.animate = false; this.group.scale.setScalar(1); }
|
| 270 |
+
}
|
| 271 |
+
// window flicker across the block (cheap: tint the shared window material)
|
| 272 |
+
if (this.winMat && this.plots.length) {
|
| 273 |
+
const k = 1.4 + Math.sin(t * 2.0 + this.f.seed % 7) * 0.18 + Math.sin(t * 5.3) * 0.06;
|
| 274 |
+
this.winMat.color.copy(this.warm).multiplyScalar(Math.max(0.7, k));
|
| 275 |
+
}
|
| 276 |
+
// the civic building runs its own grow-in + spawn pop (Structure pipeline)
|
| 277 |
+
this.civic?.update(dt, t);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// When the terrain beneath the block shifts, rebuild the whole neighbourhood
|
| 281 |
+
// from the seed — identical to construction, so houses + paths + civic all
|
| 282 |
+
// re-settle onto the new relief. Terrain reseats are rare, so a clean rebuild
|
| 283 |
+
// is simpler and safer than patching individual instance matrices.
|
| 284 |
+
reseat() {
|
| 285 |
+
this._disposeHouseMeshes();
|
| 286 |
+
if (this.pathMesh) { this.pathMesh.removeFromParent(); this.pathGeo?.dispose(); this.pathMesh = null; }
|
| 287 |
+
this.civic?.dispose();
|
| 288 |
+
const rng = mulberry32(this.f.seed);
|
| 289 |
+
this.rng = rng;
|
| 290 |
+
this._layout(rng, Math.round(8 + clamp(this.f.args.density ?? 0.5, 0, 1) * 32));
|
| 291 |
+
this._buildHouses(rng);
|
| 292 |
+
this._buildPaths(rng);
|
| 293 |
+
this._buildCivic(this.scene, this.terrain, this.f, this.t0, false);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
_disposeHouseMeshes() {
|
| 297 |
+
for (const m of this.houseMeshes || []) {
|
| 298 |
+
m.removeFromParent();
|
| 299 |
+
m.dispose();
|
| 300 |
+
}
|
| 301 |
+
for (const g of this.houseGeos || []) g.dispose();
|
| 302 |
+
this.winMat?.dispose();
|
| 303 |
+
this.houseMeshes = [];
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
dispose() {
|
| 307 |
+
this._disposeHouseMeshes();
|
| 308 |
+
if (this.pathMesh) { this.pathMesh.removeFromParent(); this.pathGeo?.dispose(); }
|
| 309 |
+
this.civic?.dispose();
|
| 310 |
+
this.group.removeFromParent();
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
export class Districts {
|
| 315 |
+
constructor(scene, terrain) {
|
| 316 |
+
this.scene = scene;
|
| 317 |
+
this.terrain = terrain;
|
| 318 |
+
this.items = [];
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
/** Apply a build_district feature. @returns the district centre (camera target). */
|
| 322 |
+
addFeature(f, { animate = true, t = 0 } = {}) {
|
| 323 |
+
const d = new District(this.scene, this.terrain, f, t, animate);
|
| 324 |
+
this.items.push(d);
|
| 325 |
+
return d.center;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
update(dt, t) {
|
| 329 |
+
for (const d of this.items) d.update(dt, t);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
reseat(center, radiusRad) {
|
| 333 |
+
for (const d of this.items) {
|
| 334 |
+
if (d.center.angleTo(center) < d.reachRad + radiusRad) d.reseat();
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
dispose() {
|
| 339 |
+
for (const d of this.items) d.dispose();
|
| 340 |
+
this.items = [];
|
| 341 |
+
}
|
| 342 |
+
}
|
web/planet/roads.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// GODSEED — roads. The place_road tool lays a paved ribbon ON the surface,
|
| 2 |
+
// following the great-circle path between its waypoints. Deliberately the
|
| 3 |
+
// opposite of place_water's glowing cyan streams: warm stone/amber, MATTE
|
| 4 |
+
// (a MeshStandardMaterial that catches the sky light, not additive glow), a
|
| 5 |
+
// slightly raised ribbon, and a faint dashed amber centreline so it reads as a
|
| 6 |
+
// paved road at the close town-zoom. Deterministic + headless-safe: the only
|
| 7 |
+
// time-driven motion is a gentle reveal eased by sim-time on placement.
|
| 8 |
+
import * as THREE from "three";
|
| 9 |
+
import {
|
| 10 |
+
PLANET_R, DEG, clamp, easeOutCubic, slerpDirs, angleBetween,
|
| 11 |
+
} from "./util.js";
|
| 12 |
+
|
| 13 |
+
const REVEAL = 1.8;
|
| 14 |
+
const STONE_ROAD = new THREE.Color("#8a7458"); // warm dry stone
|
| 15 |
+
const STONE_EDGE = new THREE.Color("#5a4836"); // darker kerb
|
| 16 |
+
|
| 17 |
+
class Road {
|
| 18 |
+
constructor(scene, terrain, f, t0, animate) {
|
| 19 |
+
this.f = f;
|
| 20 |
+
this.t0 = t0;
|
| 21 |
+
this.animate = animate;
|
| 22 |
+
this.terrain = terrain;
|
| 23 |
+
this.scene = scene;
|
| 24 |
+
const path = (f.args.path || []).slice(0, 6);
|
| 25 |
+
this.waypoints = path.map(([lat, lon]) => {
|
| 26 |
+
const la = clamp(lat, -90, 90) * DEG, lo = lon * DEG;
|
| 27 |
+
return new THREE.Vector3(Math.cos(la) * Math.cos(lo), Math.sin(la), Math.cos(la) * Math.sin(lo));
|
| 28 |
+
});
|
| 29 |
+
while (this.waypoints.length < 2) {
|
| 30 |
+
this.waypoints.push((this.waypoints[0] || new THREE.Vector3(1, 0, 0)).clone());
|
| 31 |
+
}
|
| 32 |
+
this.center = this.waypoints[0].clone();
|
| 33 |
+
this.reachRad = 0.06;
|
| 34 |
+
for (const w of this.waypoints) this.reachRad = Math.max(this.reachRad, angleBetween(this.center, w));
|
| 35 |
+
|
| 36 |
+
this.width = 0.018; // half-width of the ribbon, in radians-scaled world units
|
| 37 |
+
|
| 38 |
+
// matte paved deck: lit by the scene, never emissive (distinct from water)
|
| 39 |
+
this.deckMat = new THREE.MeshStandardMaterial({
|
| 40 |
+
color: STONE_ROAD, roughness: 0.92, metalness: 0.05,
|
| 41 |
+
transparent: true, opacity: 0, side: THREE.DoubleSide,
|
| 42 |
+
});
|
| 43 |
+
// a faint warm centreline; dashed via alpha in the shader-free way: a thin
|
| 44 |
+
// additive strip whose opacity is modulated along its length by the vUv.
|
| 45 |
+
this.lineMat = new THREE.ShaderMaterial({
|
| 46 |
+
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide,
|
| 47 |
+
uniforms: { uReveal: { value: animate ? 0 : 1 }, uLen: { value: 1 }, uColor: { value: new THREE.Color("#ffca7a") } },
|
| 48 |
+
vertexShader: /* glsl */ `
|
| 49 |
+
varying vec2 vUv;
|
| 50 |
+
void main(){ vUv = uv; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
|
| 51 |
+
fragmentShader: /* glsl */ `
|
| 52 |
+
varying vec2 vUv; uniform float uReveal, uLen; uniform vec3 uColor;
|
| 53 |
+
void main(){
|
| 54 |
+
if (vUv.x > uReveal) discard;
|
| 55 |
+
// dashed centreline: bright dash / dark gap repeating along length
|
| 56 |
+
float dash = step(0.5, fract(vUv.x * uLen * 26.0));
|
| 57 |
+
// soft edge across the (thin) line width
|
| 58 |
+
float across = smoothstep(0.0, 0.5, vUv.y) * smoothstep(1.0, 0.5, vUv.y);
|
| 59 |
+
float a = dash * across * 0.7;
|
| 60 |
+
gl_FragColor = vec4(uColor, a);
|
| 61 |
+
}`,
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
const { deckGeo, lineGeo, lengthRad } = this._buildGeo();
|
| 65 |
+
this.lineMat.uniforms.uLen.value = Math.max(0.5, lengthRad / DEG / 18);
|
| 66 |
+
this.deck = new THREE.Mesh(deckGeo, this.deckMat);
|
| 67 |
+
this.deck.frustumCulled = false;
|
| 68 |
+
this.line = new THREE.Mesh(lineGeo, this.lineMat);
|
| 69 |
+
this.line.frustumCulled = false;
|
| 70 |
+
this.group = new THREE.Group();
|
| 71 |
+
this.group.add(this.deck);
|
| 72 |
+
this.group.add(this.line);
|
| 73 |
+
scene.add(this.group);
|
| 74 |
+
if (!animate) this.deckMat.opacity = 0.95;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// dense great-circle samples lifted onto the terrain surface, with a tangent
|
| 78 |
+
// "right" vector at each so we can extrude a flat ribbon that hugs curvature.
|
| 79 |
+
_samples() {
|
| 80 |
+
const dirs = [];
|
| 81 |
+
for (let i = 0; i < this.waypoints.length - 1; i++) {
|
| 82 |
+
const a = this.waypoints[i], b = this.waypoints[i + 1];
|
| 83 |
+
const steps = Math.max(6, Math.ceil(angleBetween(a, b) / (1.4 * DEG)));
|
| 84 |
+
for (let s = i === 0 ? 0 : 1; s <= steps; s++) {
|
| 85 |
+
dirs.push(slerpDirs(a, b, s / steps, new THREE.Vector3()).normalize());
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
return dirs;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
_ribbon(dirs, halfWidth, lift) {
|
| 92 |
+
// returns {positions, uvs, index, lengthRad}: a strip of quads along the path
|
| 93 |
+
const positions = [], uvs = [], index = [];
|
| 94 |
+
const right = new THREE.Vector3(), fwd = new THREE.Vector3(), up = new THREE.Vector3();
|
| 95 |
+
// arc-length parameterisation for stable centreline dashing
|
| 96 |
+
let lengthRad = 0;
|
| 97 |
+
const cum = [0];
|
| 98 |
+
for (let i = 1; i < dirs.length; i++) {
|
| 99 |
+
lengthRad += angleBetween(dirs[i - 1], dirs[i]);
|
| 100 |
+
cum.push(lengthRad);
|
| 101 |
+
}
|
| 102 |
+
for (let i = 0; i < dirs.length; i++) {
|
| 103 |
+
const d = dirs[i];
|
| 104 |
+
const dn = dirs[Math.min(i + 1, dirs.length - 1)];
|
| 105 |
+
const dp = dirs[Math.max(i - 1, 0)];
|
| 106 |
+
fwd.copy(dn).sub(dp).normalize();
|
| 107 |
+
up.copy(d);
|
| 108 |
+
right.crossVectors(fwd, up).normalize();
|
| 109 |
+
const h = this.terrain.heightAt(d);
|
| 110 |
+
const r = PLANET_R + h + lift;
|
| 111 |
+
const u = lengthRad > 1e-6 ? cum[i] / lengthRad : i / Math.max(1, dirs.length - 1);
|
| 112 |
+
for (const side of [-1, 1]) {
|
| 113 |
+
const p = d.clone().multiplyScalar(r).addScaledVector(right, side * halfWidth);
|
| 114 |
+
positions.push(p.x, p.y, p.z);
|
| 115 |
+
uvs.push(u, side < 0 ? 0 : 1);
|
| 116 |
+
}
|
| 117 |
+
if (i < dirs.length - 1) {
|
| 118 |
+
const k = i * 2;
|
| 119 |
+
index.push(k, k + 1, k + 2, k + 1, k + 3, k + 2);
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
return { positions, uvs, index, lengthRad };
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
_buildGeo() {
|
| 126 |
+
const dirs = this._samples();
|
| 127 |
+
const deck = this._ribbon(dirs, this.width, 0.004);
|
| 128 |
+
const line = this._ribbon(dirs, this.width * 0.22, 0.0052); // thin strip, just above the deck
|
| 129 |
+
const mk = (r) => {
|
| 130 |
+
const g = new THREE.BufferGeometry();
|
| 131 |
+
g.setAttribute("position", new THREE.Float32BufferAttribute(r.positions, 3));
|
| 132 |
+
g.setAttribute("uv", new THREE.Float32BufferAttribute(r.uvs, 2));
|
| 133 |
+
g.setIndex(r.index);
|
| 134 |
+
g.computeVertexNormals();
|
| 135 |
+
return g;
|
| 136 |
+
};
|
| 137 |
+
return { deckGeo: mk(deck), lineGeo: mk(line), lengthRad: deck.lengthRad };
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
reseat() {
|
| 141 |
+
const { deckGeo, lineGeo } = this._buildGeo();
|
| 142 |
+
this.deck.geometry.dispose();
|
| 143 |
+
this.line.geometry.dispose();
|
| 144 |
+
this.deck.geometry = deckGeo;
|
| 145 |
+
this.line.geometry = lineGeo;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
update(dt, t) {
|
| 149 |
+
if (this.animate) {
|
| 150 |
+
const k = easeOutCubic((t - this.t0) / REVEAL);
|
| 151 |
+
this.deckMat.opacity = 0.95 * k;
|
| 152 |
+
this.lineMat.uniforms.uReveal.value = k;
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
dispose() {
|
| 157 |
+
this.group.removeFromParent();
|
| 158 |
+
this.deck.geometry.dispose();
|
| 159 |
+
this.line.geometry.dispose();
|
| 160 |
+
this.deckMat.dispose();
|
| 161 |
+
this.lineMat.dispose();
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
export class Roads {
|
| 166 |
+
constructor(scene, terrain) {
|
| 167 |
+
this.scene = scene;
|
| 168 |
+
this.terrain = terrain;
|
| 169 |
+
this.items = [];
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/** Apply a place_road feature. @returns the road's first waypoint (camera target). */
|
| 173 |
+
addFeature(f, { animate = true, t = 0 } = {}) {
|
| 174 |
+
const road = new Road(this.scene, this.terrain, f, t, animate);
|
| 175 |
+
this.items.push(road);
|
| 176 |
+
return road.center;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
update(dt, t) {
|
| 180 |
+
for (const r of this.items) r.update(dt, t);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
reseat(center, radiusRad) {
|
| 184 |
+
for (const r of this.items) {
|
| 185 |
+
if (angleBetween(r.center, center) < r.reachRad + radiusRad) r.reseat();
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
dispose() {
|
| 190 |
+
for (const r of this.items) r.dispose();
|
| 191 |
+
this.items = [];
|
| 192 |
+
}
|
| 193 |
+
}
|
web/planet/structures.js
CHANGED
|
@@ -71,14 +71,17 @@ function buildLighthouse(rng, color) {
|
|
| 71 |
const roof = new THREE.Mesh(new THREE.ConeGeometry(0.017, 0.024, 8), STONE_DARK);
|
| 72 |
roof.position.y = 0.197;
|
| 73 |
g.add(roof);
|
| 74 |
-
// rotating double beam
|
|
|
|
| 75 |
const beams = new THREE.Group();
|
| 76 |
beams.position.y = 0.173;
|
|
|
|
| 77 |
for (const s of [1, -1]) {
|
| 78 |
-
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.
|
| 79 |
cone.rotation.z = s * (Math.PI / 2);
|
| 80 |
-
cone.position.x = s * 0.
|
| 81 |
beams.add(cone);
|
|
|
|
| 82 |
}
|
| 83 |
g.add(beams);
|
| 84 |
const light = new THREE.PointLight(color.getHex(), 0.7, 1.2, 1.8);
|
|
@@ -203,11 +206,13 @@ function buildBeacon(rng, color) {
|
|
| 203 |
const crystal = new THREE.Mesh(new THREE.OctahedronGeometry(0.019, 0), crystalMat);
|
| 204 |
crystal.position.y = 0.085;
|
| 205 |
g.add(crystal);
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
| 208 |
g.add(pillarInner);
|
| 209 |
-
const pillarOuter = new THREE.Mesh(new THREE.CylinderGeometry(0.018, 0.03,
|
| 210 |
-
pillarOuter.position.y = 0.
|
| 211 |
g.add(pillarOuter);
|
| 212 |
g.add(groundHalo(color, 0.05, 0.3));
|
| 213 |
const phase = rng() * 6.28;
|
|
@@ -455,6 +460,183 @@ function buildCafe(rng, color, ctx) {
|
|
| 455 |
};
|
| 456 |
}
|
| 457 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
const BUILDERS = {
|
| 459 |
lighthouse: buildLighthouse,
|
| 460 |
monolith: buildMonolith,
|
|
@@ -465,10 +647,15 @@ const BUILDERS = {
|
|
| 465 |
tower: buildTower,
|
| 466 |
warehouse: buildWarehouse,
|
| 467 |
cafe: buildCafe,
|
|
|
|
|
|
|
|
|
|
| 468 |
};
|
| 469 |
|
| 470 |
// ---------------------------------------------------------------- manager
|
| 471 |
-
|
|
|
|
|
|
|
| 472 |
constructor(scene, terrain, f, t0, animate) {
|
| 473 |
const a = f.args;
|
| 474 |
this.f = f;
|
|
|
|
| 71 |
const roof = new THREE.Mesh(new THREE.ConeGeometry(0.017, 0.024, 8), STONE_DARK);
|
| 72 |
roof.position.y = 0.197;
|
| 73 |
g.add(roof);
|
| 74 |
+
// rotating double beam — tamed to ~60% so it no longer dominates the frame
|
| 75 |
+
// from orbit; the rotating sweep + pulsing lantern keep it legible up close.
|
| 76 |
const beams = new THREE.Group();
|
| 77 |
beams.position.y = 0.173;
|
| 78 |
+
const beamCones = [];
|
| 79 |
for (const s of [1, -1]) {
|
| 80 |
+
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.028, 0.34, 12, 1, true), beamMat(color, 0.1));
|
| 81 |
cone.rotation.z = s * (Math.PI / 2);
|
| 82 |
+
cone.position.x = s * 0.17;
|
| 83 |
beams.add(cone);
|
| 84 |
+
beamCones.push(cone);
|
| 85 |
}
|
| 86 |
g.add(beams);
|
| 87 |
const light = new THREE.PointLight(color.getHex(), 0.7, 1.2, 1.8);
|
|
|
|
| 206 |
const crystal = new THREE.Mesh(new THREE.OctahedronGeometry(0.019, 0), crystalMat);
|
| 207 |
crystal.position.y = 0.085;
|
| 208 |
g.add(crystal);
|
| 209 |
+
// light pillars tamed to ~60% height so the beacon reads as a town landmark,
|
| 210 |
+
// not an orbit-dominating needle (still a tall glowing shaft up close).
|
| 211 |
+
const pillarInner = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.011, 0.52, 8, 1, true), beamMat(color, 0.3));
|
| 212 |
+
pillarInner.position.y = 0.34;
|
| 213 |
g.add(pillarInner);
|
| 214 |
+
const pillarOuter = new THREE.Mesh(new THREE.CylinderGeometry(0.018, 0.03, 0.4, 10, 1, true), beamMat(color, 0.1));
|
| 215 |
+
pillarOuter.position.y = 0.28;
|
| 216 |
g.add(pillarOuter);
|
| 217 |
g.add(groundHalo(color, 0.05, 0.3));
|
| 218 |
const phase = rng() * 6.28;
|
|
|
|
| 460 |
};
|
| 461 |
}
|
| 462 |
|
| 463 |
+
// ---------------------------------------------------------------- town kit
|
| 464 |
+
// bank | market | house — the Town Mode civic + domestic set. Same diorama
|
| 465 |
+
// soul: low-poly stone/wood bodies, warm emissive accents, seeded variation.
|
| 466 |
+
// `house` is the atom of a town; district.js reuses `houseProto()` so a lone
|
| 467 |
+
// place_structure house and the homes in a neighbourhood read identically.
|
| 468 |
+
|
| 469 |
+
const WARM = new THREE.Color("#ffcf8a"); // shared "lamp" warm for town glows
|
| 470 |
+
|
| 471 |
+
// A single small gabled home: body + roof + one lit window. Returns the mesh
|
| 472 |
+
// parts plus the warm window material so callers can flicker it. Local base at
|
| 473 |
+
// y=0, +Y up; sized in the same ~0.02 range as village huts. `s` scales it.
|
| 474 |
+
export function houseProto(rng, color, s = 1) {
|
| 475 |
+
const g = new THREE.Group();
|
| 476 |
+
const w = (0.019 + rng() * 0.008) * s, d = (0.016 + rng() * 0.006) * s;
|
| 477 |
+
const h = (0.013 + rng() * 0.005) * s;
|
| 478 |
+
const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), WOOD);
|
| 479 |
+
body.position.y = h / 2;
|
| 480 |
+
g.add(body);
|
| 481 |
+
const roofH = (0.009 + rng() * 0.004) * s;
|
| 482 |
+
// gabled roof: two sloped slabs meeting at a ridge (reads as a peaked home)
|
| 483 |
+
for (const side of [1, -1]) {
|
| 484 |
+
const slope = new THREE.Mesh(new THREE.BoxGeometry(w * 1.04, 0.0025, d * 0.64), ROOF);
|
| 485 |
+
slope.position.set(0, h + roofH / 2, side * d * 0.26);
|
| 486 |
+
slope.rotation.x = side * Math.atan2(roofH, d * 0.5);
|
| 487 |
+
g.add(slope);
|
| 488 |
+
}
|
| 489 |
+
const ridge = new THREE.Mesh(new THREE.BoxGeometry(w * 1.04, 0.0028, 0.0028), STONE_DARK);
|
| 490 |
+
ridge.position.y = h + roofH;
|
| 491 |
+
g.add(ridge);
|
| 492 |
+
// one warm lit window on a long face
|
| 493 |
+
const warm = color.clone().lerp(WARM, 0.55);
|
| 494 |
+
const winMat = glow(warm, 1.8);
|
| 495 |
+
const win = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.34, h * 0.46), winMat);
|
| 496 |
+
const face = rng() < 0.5 ? 1 : -1;
|
| 497 |
+
win.position.set((rng() - 0.5) * w * 0.4, h * 0.52, face * (d / 2 + 0.0006));
|
| 498 |
+
if (face < 0) win.rotation.y = Math.PI;
|
| 499 |
+
g.add(win);
|
| 500 |
+
return { group: g, winMat, warm, footprint: Math.max(w, d) };
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
function buildHouse(rng, color) {
|
| 504 |
+
const proto = houseProto(rng, color, 1.0);
|
| 505 |
+
const g = proto.group;
|
| 506 |
+
g.add(groundHalo(proto.warm, 0.03, 0.16));
|
| 507 |
+
const phase = rng() * 6.28, speed = 2.4 + rng() * 3;
|
| 508 |
+
return {
|
| 509 |
+
group: g,
|
| 510 |
+
accents: [proto.winMat],
|
| 511 |
+
update: (dt, t) => {
|
| 512 |
+
const k = 1.45 + Math.sin(t * speed + phase) * 0.3;
|
| 513 |
+
proto.winMat.color.copy(proto.warm).multiplyScalar(Math.max(0.7, k));
|
| 514 |
+
},
|
| 515 |
+
};
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
function buildBank(rng, color) {
|
| 519 |
+
// a stately columned block: wide stone body, a row of front columns under a
|
| 520 |
+
// pediment, warm-lit doorway between them. The civic anchor of a town.
|
| 521 |
+
const g = new THREE.Group();
|
| 522 |
+
const w = 0.058 + rng() * 0.014, d = 0.04 + rng() * 0.01, h = 0.03 + rng() * 0.008;
|
| 523 |
+
const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), STONE);
|
| 524 |
+
body.position.y = h / 2;
|
| 525 |
+
g.add(body);
|
| 526 |
+
// stepped base
|
| 527 |
+
const base = new THREE.Mesh(new THREE.BoxGeometry(w * 1.16, 0.008, d * 1.18), STONE_DARK);
|
| 528 |
+
base.position.y = 0.004;
|
| 529 |
+
g.add(base);
|
| 530 |
+
// a portico of columns across the front
|
| 531 |
+
const cols = 4 + Math.floor(rng() * 2);
|
| 532 |
+
const front = d / 2 + 0.006;
|
| 533 |
+
for (let i = 0; i < cols; i++) {
|
| 534 |
+
const cx = (i / (cols - 1) - 0.5) * w * 0.82;
|
| 535 |
+
const col = new THREE.Mesh(new THREE.CylinderGeometry(0.0045, 0.0052, h * 0.92, 8), STONE);
|
| 536 |
+
col.position.set(cx, h * 0.46 + 0.008, front);
|
| 537 |
+
g.add(col);
|
| 538 |
+
}
|
| 539 |
+
// pediment (triangular cap) over the columns
|
| 540 |
+
const ped = new THREE.Mesh(new THREE.CylinderGeometry(0.006, w * 0.5, 0.012, 3), STONE_DARK);
|
| 541 |
+
ped.rotation.y = Math.PI / 2;
|
| 542 |
+
ped.scale.set(1, 1, 0.5);
|
| 543 |
+
ped.position.set(0, h + 0.012, front);
|
| 544 |
+
g.add(ped);
|
| 545 |
+
const roof = new THREE.Mesh(new THREE.BoxGeometry(w * 1.04, 0.005, d * 1.04), ROOF);
|
| 546 |
+
roof.position.y = h + 0.0025;
|
| 547 |
+
g.add(roof);
|
| 548 |
+
// warm-lit doorway between the central columns + spill on the steps
|
| 549 |
+
const warm = color.clone().lerp(WARM, 0.5);
|
| 550 |
+
const doorMat = glow(warm, 1.7);
|
| 551 |
+
const door = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.16, h * 0.62), doorMat);
|
| 552 |
+
door.position.set(0, h * 0.36, front + 0.0006);
|
| 553 |
+
g.add(door);
|
| 554 |
+
const spill = new THREE.Mesh(new THREE.CircleGeometry(0.042, 18), beamMat(warm, 0.32));
|
| 555 |
+
spill.rotation.x = -Math.PI / 2;
|
| 556 |
+
spill.position.set(0, 0.005, front + 0.02);
|
| 557 |
+
spill.scale.set(1, 1.3, 1);
|
| 558 |
+
g.add(spill);
|
| 559 |
+
const light = new THREE.PointLight(warm.getHex(), 0.5, 0.6, 1.9);
|
| 560 |
+
light.position.set(0, h * 0.5, front + 0.02);
|
| 561 |
+
g.add(light);
|
| 562 |
+
const phase = rng() * 6.28;
|
| 563 |
+
return {
|
| 564 |
+
group: g,
|
| 565 |
+
accents: [doorMat],
|
| 566 |
+
update: (dt, t) => {
|
| 567 |
+
const k = 1.4 + Math.sin(t * 1.8 + phase) * 0.22;
|
| 568 |
+
doorMat.color.copy(warm).multiplyScalar(k);
|
| 569 |
+
light.intensity = 0.42 + Math.sin(t * 1.8 + phase) * 0.12;
|
| 570 |
+
spill.material.opacity = 0.26 + Math.sin(t * 1.8 + phase) * 0.06;
|
| 571 |
+
},
|
| 572 |
+
};
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
function buildMarket(rng, color) {
|
| 576 |
+
// an open cluster of stalls under striped awnings, warm glow underneath and
|
| 577 |
+
// a scatter of produce/lantern dots. Reads as a busy little marketplace.
|
| 578 |
+
const g = new THREE.Group();
|
| 579 |
+
const warm = color.clone().lerp(WARM, 0.45);
|
| 580 |
+
const glowMats = [];
|
| 581 |
+
const stalls = 4 + Math.floor(rng() * 3);
|
| 582 |
+
for (let i = 0; i < stalls; i++) {
|
| 583 |
+
const stall = new THREE.Group();
|
| 584 |
+
const ang = rng() * Math.PI * 2;
|
| 585 |
+
const rad = 0.01 + rng() * 0.05;
|
| 586 |
+
const sw = 0.018 + rng() * 0.008, sd = 0.016 + rng() * 0.006;
|
| 587 |
+
// four thin posts
|
| 588 |
+
for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
|
| 589 |
+
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.0016, 0.0016, 0.02, 5), WOOD);
|
| 590 |
+
post.position.set(sx * sw * 0.5, 0.01, sz * sd * 0.5);
|
| 591 |
+
stall.add(post);
|
| 592 |
+
}
|
| 593 |
+
// a striped awning canopy tilted over the stall
|
| 594 |
+
const aHue = color.clone().lerp(new THREE.Color(rng() < 0.5 ? "#e8643c" : "#d8a23c"), 0.6);
|
| 595 |
+
const awnMat = new THREE.MeshStandardMaterial({
|
| 596 |
+
color: aHue.multiplyScalar(0.7), roughness: 0.8, side: THREE.DoubleSide, emissive: aHue, emissiveIntensity: 0.25,
|
| 597 |
+
});
|
| 598 |
+
const awn = new THREE.Mesh(new THREE.PlaneGeometry(sw * 1.25, sd * 1.25), awnMat);
|
| 599 |
+
awn.rotation.x = -Math.PI / 2 + (rng() - 0.5) * 0.3;
|
| 600 |
+
awn.position.y = 0.021;
|
| 601 |
+
stall.add(awn);
|
| 602 |
+
// a glowing tabletop of wares under the canopy
|
| 603 |
+
const wareMat = glow(warm, 1.4);
|
| 604 |
+
const ware = new THREE.Mesh(new THREE.BoxGeometry(sw * 0.8, 0.003, sd * 0.8), wareMat);
|
| 605 |
+
ware.position.y = 0.012;
|
| 606 |
+
stall.add(ware);
|
| 607 |
+
glowMats.push(wareMat);
|
| 608 |
+
stall.position.set(Math.cos(ang) * rad, 0, Math.sin(ang) * rad);
|
| 609 |
+
stall.rotation.y = rng() * Math.PI * 2;
|
| 610 |
+
g.add(stall);
|
| 611 |
+
}
|
| 612 |
+
// hanging lanterns / produce dots scattered between stalls
|
| 613 |
+
const dotMat = glow(WARM.clone(), 1.6);
|
| 614 |
+
const nDots = 5 + Math.floor(rng() * 5);
|
| 615 |
+
const dots = new THREE.InstancedMesh(new THREE.SphereGeometry(0.0028, 6, 5), dotMat, nDots);
|
| 616 |
+
dots.frustumCulled = false;
|
| 617 |
+
const m = new THREE.Matrix4(), p = new THREE.Vector3(), q = new THREE.Quaternion(), sc = new THREE.Vector3(1, 1, 1);
|
| 618 |
+
for (let i = 0; i < nDots; i++) {
|
| 619 |
+
const ang = rng() * Math.PI * 2, rad = 0.01 + rng() * 0.055;
|
| 620 |
+
p.set(Math.cos(ang) * rad, 0.006 + rng() * 0.016, Math.sin(ang) * rad);
|
| 621 |
+
m.compose(p, q, sc);
|
| 622 |
+
dots.setMatrixAt(i, m);
|
| 623 |
+
}
|
| 624 |
+
dots.instanceMatrix.needsUpdate = true;
|
| 625 |
+
g.add(dots);
|
| 626 |
+
g.add(groundHalo(warm, 0.075, 0.18));
|
| 627 |
+
const phase = rng() * 6.28;
|
| 628 |
+
return {
|
| 629 |
+
group: g,
|
| 630 |
+
accents: [...glowMats, dotMat],
|
| 631 |
+
ownGeo: [dots.geometry],
|
| 632 |
+
update: (dt, t) => {
|
| 633 |
+
const k = 1.35 + Math.sin(t * 2.1 + phase) * 0.16 + Math.sin(t * 5.7) * 0.06;
|
| 634 |
+
for (const mat of glowMats) mat.color.copy(warm).multiplyScalar(k);
|
| 635 |
+
dotMat.color.copy(WARM).multiplyScalar(1.4 + Math.sin(t * 3.3 + phase) * 0.3);
|
| 636 |
+
},
|
| 637 |
+
};
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
const BUILDERS = {
|
| 641 |
lighthouse: buildLighthouse,
|
| 642 |
monolith: buildMonolith,
|
|
|
|
| 647 |
tower: buildTower,
|
| 648 |
warehouse: buildWarehouse,
|
| 649 |
cafe: buildCafe,
|
| 650 |
+
bank: buildBank,
|
| 651 |
+
market: buildMarket,
|
| 652 |
+
house: buildHouse,
|
| 653 |
};
|
| 654 |
|
| 655 |
// ---------------------------------------------------------------- manager
|
| 656 |
+
// Exported so district.js can reuse the full structure pipeline (terrain
|
| 657 |
+
// seating, spawn pop, per-frame glow) for a district's lone civic building.
|
| 658 |
+
export class Structure {
|
| 659 |
constructor(scene, terrain, f, t0, animate) {
|
| 660 |
const a = f.args;
|
| 661 |
this.f = f;
|
web/planet/util.js
CHANGED
|
@@ -82,3 +82,40 @@ export function ordinal(n) {
|
|
| 82 |
const v = n % 100;
|
| 83 |
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
| 84 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
const v = n % 100;
|
| 83 |
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
| 84 |
}
|
| 85 |
+
|
| 86 |
+
// --- the town anchor (engine + renderer compute IDENTICALLY) -----------------
|
| 87 |
+
// The genesis monolith — the fallback heart of the world before any building.
|
| 88 |
+
export const GENESIS_MONOLITH = { lat: 14, lon: 38 };
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* townCenter(features) → { dir: THREE.Vector3 (unit), lat, lon }.
|
| 92 |
+
*
|
| 93 |
+
* The seeded centroid of the town: the unit-vector mean (renormalized) of the
|
| 94 |
+
* directions of every non-genesis `place_structure` + `build_district` feature.
|
| 95 |
+
* This is the heart the camera frames and the HUD names. When no town has been
|
| 96 |
+
* built yet, it falls back to the genesis monolith at lat 14, lon 38.
|
| 97 |
+
*
|
| 98 |
+
* Must match Agent A's engine algorithm exactly — the live shared world and the
|
| 99 |
+
* client agree on where "the town" is, so the default view is deterministic.
|
| 100 |
+
*/
|
| 101 |
+
export function townCenter(features, out = new THREE.Vector3()) {
|
| 102 |
+
const sum = out.set(0, 0, 0);
|
| 103 |
+
const d = _t1;
|
| 104 |
+
let n = 0;
|
| 105 |
+
for (const f of features || []) {
|
| 106 |
+
if (!f || f.wish_id === "genesis") continue;
|
| 107 |
+
if (f.tool !== "place_structure" && f.tool !== "build_district") continue;
|
| 108 |
+
const a = f.args || {};
|
| 109 |
+
if (a.lat == null || a.lon == null) continue;
|
| 110 |
+
latLonToDir(a.lat, a.lon, d);
|
| 111 |
+
sum.add(d);
|
| 112 |
+
n++;
|
| 113 |
+
}
|
| 114 |
+
if (n === 0 || sum.lengthSq() < 1e-9) {
|
| 115 |
+
latLonToDir(GENESIS_MONOLITH.lat, GENESIS_MONOLITH.lon, sum);
|
| 116 |
+
} else {
|
| 117 |
+
sum.normalize();
|
| 118 |
+
}
|
| 119 |
+
const { lat, lon } = dirToLatLon(sum);
|
| 120 |
+
return { dir: sum, lat, lon };
|
| 121 |
+
}
|
web/planet/world.js
CHANGED
|
@@ -5,6 +5,8 @@ import * as THREE from "three";
|
|
| 5 |
import { Terrain } from "./terrain.js";
|
| 6 |
import { Flora } from "./flora.js";
|
| 7 |
import { Structures } from "./structures.js";
|
|
|
|
|
|
|
| 8 |
import { Water } from "./water.js";
|
| 9 |
import { Life } from "./life.js";
|
| 10 |
import { Weather } from "./weather.js";
|
|
@@ -27,6 +29,8 @@ export class World {
|
|
| 27 |
this.terrain = new Terrain(this.scene, { detail: this.quality.detail });
|
| 28 |
this.flora = new Flora(this.scene, this.terrain, this.quality);
|
| 29 |
this.structures = new Structures(this.scene, this.terrain);
|
|
|
|
|
|
|
| 30 |
this.water = new Water(this.scene, this.terrain);
|
| 31 |
this.life = new Life(this.scene, this.terrain, this.water);
|
| 32 |
this.inscriptions = new Inscriptions(this.scene, this.terrain);
|
|
@@ -59,6 +63,12 @@ export class World {
|
|
| 59 |
case "place_structure":
|
| 60 |
target = this.structures.addFeature(f, { animate, t });
|
| 61 |
break;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
case "place_water":
|
| 63 |
target = this.water.addFeature(f, { animate, t });
|
| 64 |
break;
|
|
@@ -89,6 +99,8 @@ export class World {
|
|
| 89 |
reset(features, t = 0) {
|
| 90 |
this.flora.dispose();
|
| 91 |
this.structures.dispose();
|
|
|
|
|
|
|
| 92 |
this.water.dispose();
|
| 93 |
this.life.dispose();
|
| 94 |
this.inscriptions.dispose();
|
|
@@ -109,12 +121,16 @@ export class World {
|
|
| 109 |
for (const c of changed) {
|
| 110 |
this.flora.reseat(c.center, c.radiusRad);
|
| 111 |
this.structures.reseat(c.center, c.radiusRad);
|
|
|
|
|
|
|
| 112 |
this.water.reseat(c.center, c.radiusRad);
|
| 113 |
this.life.reseat(c.center, c.radiusRad);
|
| 114 |
this.inscriptions.reseat(c.center, c.radiusRad);
|
| 115 |
}
|
| 116 |
this.flora.update(dt, t);
|
| 117 |
this.structures.update(dt, t);
|
|
|
|
|
|
|
| 118 |
this.water.update(dt, t);
|
| 119 |
this.life.update(dt, t);
|
| 120 |
this.weather.update(dt, t);
|
|
|
|
| 5 |
import { Terrain } from "./terrain.js";
|
| 6 |
import { Flora } from "./flora.js";
|
| 7 |
import { Structures } from "./structures.js";
|
| 8 |
+
import { Districts } from "./district.js";
|
| 9 |
+
import { Roads } from "./roads.js";
|
| 10 |
import { Water } from "./water.js";
|
| 11 |
import { Life } from "./life.js";
|
| 12 |
import { Weather } from "./weather.js";
|
|
|
|
| 29 |
this.terrain = new Terrain(this.scene, { detail: this.quality.detail });
|
| 30 |
this.flora = new Flora(this.scene, this.terrain, this.quality);
|
| 31 |
this.structures = new Structures(this.scene, this.terrain);
|
| 32 |
+
this.districts = new Districts(this.scene, this.terrain);
|
| 33 |
+
this.roads = new Roads(this.scene, this.terrain);
|
| 34 |
this.water = new Water(this.scene, this.terrain);
|
| 35 |
this.life = new Life(this.scene, this.terrain, this.water);
|
| 36 |
this.inscriptions = new Inscriptions(this.scene, this.terrain);
|
|
|
|
| 63 |
case "place_structure":
|
| 64 |
target = this.structures.addFeature(f, { animate, t });
|
| 65 |
break;
|
| 66 |
+
case "build_district":
|
| 67 |
+
target = this.districts.addFeature(f, { animate, t });
|
| 68 |
+
break;
|
| 69 |
+
case "place_road":
|
| 70 |
+
target = this.roads.addFeature(f, { animate, t });
|
| 71 |
+
break;
|
| 72 |
case "place_water":
|
| 73 |
target = this.water.addFeature(f, { animate, t });
|
| 74 |
break;
|
|
|
|
| 99 |
reset(features, t = 0) {
|
| 100 |
this.flora.dispose();
|
| 101 |
this.structures.dispose();
|
| 102 |
+
this.districts.dispose();
|
| 103 |
+
this.roads.dispose();
|
| 104 |
this.water.dispose();
|
| 105 |
this.life.dispose();
|
| 106 |
this.inscriptions.dispose();
|
|
|
|
| 121 |
for (const c of changed) {
|
| 122 |
this.flora.reseat(c.center, c.radiusRad);
|
| 123 |
this.structures.reseat(c.center, c.radiusRad);
|
| 124 |
+
this.districts.reseat(c.center, c.radiusRad);
|
| 125 |
+
this.roads.reseat(c.center, c.radiusRad);
|
| 126 |
this.water.reseat(c.center, c.radiusRad);
|
| 127 |
this.life.reseat(c.center, c.radiusRad);
|
| 128 |
this.inscriptions.reseat(c.center, c.radiusRad);
|
| 129 |
}
|
| 130 |
this.flora.update(dt, t);
|
| 131 |
this.structures.update(dt, t);
|
| 132 |
+
this.districts.update(dt, t);
|
| 133 |
+
this.roads.update(dt, t);
|
| 134 |
this.water.update(dt, t);
|
| 135 |
this.life.update(dt, t);
|
| 136 |
this.weather.update(dt, t);
|
web/style.css
CHANGED
|
@@ -85,7 +85,9 @@ html, body {
|
|
| 85 |
|
| 86 |
/* ---------- epoch + log link ---------- */
|
| 87 |
.almanac {
|
| 88 |
-
position: fixed; top: 38px; right: 42px; z-index: 11;
|
|
|
|
|
|
|
| 89 |
}
|
| 90 |
.almanac__epoch {
|
| 91 |
font-size: 11px;
|
|
@@ -107,6 +109,40 @@ html, body {
|
|
| 107 |
}
|
| 108 |
.almanac__log:hover { color: var(--gold-bright); border-color: var(--gold); }
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
/* ---------- the god speaks ---------- */
|
| 111 |
.voice {
|
| 112 |
position: fixed; left: 42px; bottom: 42px; z-index: 10;
|
|
|
|
| 85 |
|
| 86 |
/* ---------- epoch + log link ---------- */
|
| 87 |
.almanac {
|
| 88 |
+
position: fixed; top: 38px; right: 42px; z-index: 11;
|
| 89 |
+
display: flex; flex-direction: column; align-items: flex-end;
|
| 90 |
+
text-align: right;
|
| 91 |
}
|
| 92 |
.almanac__epoch {
|
| 93 |
font-size: 11px;
|
|
|
|
| 109 |
}
|
| 110 |
.almanac__log:hover { color: var(--gold-bright); border-color: var(--gold); }
|
| 111 |
|
| 112 |
+
/* behold the world ↕ — the close-town ⇄ whole-planet toggle */
|
| 113 |
+
.behold {
|
| 114 |
+
display: inline-flex; align-items: center; gap: 9px;
|
| 115 |
+
margin-top: 14px;
|
| 116 |
+
padding: 7px 15px;
|
| 117 |
+
font-family: var(--sans);
|
| 118 |
+
font-size: 9.5px; letter-spacing: 0.26em; text-transform: uppercase;
|
| 119 |
+
color: var(--ivory-dim);
|
| 120 |
+
background: var(--panel);
|
| 121 |
+
border: 1px solid var(--hairline);
|
| 122 |
+
border-radius: 999px;
|
| 123 |
+
cursor: pointer;
|
| 124 |
+
pointer-events: auto;
|
| 125 |
+
-webkit-backdrop-filter: blur(8px);
|
| 126 |
+
backdrop-filter: blur(8px);
|
| 127 |
+
transition: color 0.25s, border-color 0.25s, box-shadow 0.25s;
|
| 128 |
+
}
|
| 129 |
+
.behold:hover {
|
| 130 |
+
color: var(--gold-bright);
|
| 131 |
+
border-color: var(--gold);
|
| 132 |
+
box-shadow: 0 0 24px rgba(217, 179, 108, 0.14);
|
| 133 |
+
}
|
| 134 |
+
.behold__label { line-height: 1; }
|
| 135 |
+
.behold__sigil {
|
| 136 |
+
font-size: 12px; line-height: 1; color: var(--gold);
|
| 137 |
+
transition: transform 0.4s ease;
|
| 138 |
+
}
|
| 139 |
+
/* while beholding the world the control offers the way back — flip the sigil */
|
| 140 |
+
.behold.is-world {
|
| 141 |
+
color: var(--gold);
|
| 142 |
+
border-color: var(--gold);
|
| 143 |
+
}
|
| 144 |
+
.behold.is-world .behold__sigil { transform: rotate(180deg); }
|
| 145 |
+
|
| 146 |
/* ---------- the god speaks ---------- */
|
| 147 |
.voice {
|
| 148 |
position: fixed; left: 42px; bottom: 42px; z-index: 10;
|