AndresCarreon commited on
Commit
b0d758d
·
verified ·
1 Parent(s): 239a30f

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 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 — exactly 9 tools (GBNF-friendly: flat args, numbers + enums + short strings)
 
 
 
 
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 9 tools (`raise_terrain`, `lower_terrain`, `spawn_flora`,
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 9-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
  """
 
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: epoch, terrain count + notable coords, flora kinds, structures with
4
- coords, current sky/weather, last 2 epitaphs. Duck-types over World so this
5
- module stays import-free of engine.world (no cycles).
 
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
- parts: list[str] = [f"epoch {getattr(world, 'epoch', 0)}"]
 
 
 
 
 
 
 
 
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 9-tool surface.
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", "min_points": 1, "max_points": spec.max_points,
 
 
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 9-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
 
 
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 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,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 raise a tower to "
375
- "hold the night's light, lay a long warehouse at its foot, set a cafe "
376
- "to keep one window warm, and let carts run the streets between them."
377
  )
378
  return {
379
  "reading": reading,
380
  "turns": [
381
  _call(
382
- "Bedrock first; tall things need deep shoulders.",
383
- "raise_terrain",
384
- lat=lat, lon=lon, radius_deg=_r1(rng, 8, 12),
385
- height=_r2(rng, 0.03, 0.05), roughness=0.4,
386
  ),
387
  _call(
388
- "The tower, slender, its rooftop light slow-pulsing.",
389
  "place_structure",
390
- lat=lat, lon=lon, kind="tower",
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 + 3),
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 - 2),
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, 8, 14),
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 tower stands over its warehouse and cafe, and carts run the streets at dusk.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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?|towns?|kettles?|people|friends?|family)\b", re.I), _village),
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 9 legal tool names, in canonical order."""
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 nine tools exist —
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 test_tool_surface_is_exactly_nine():
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-8-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,7 +43,7 @@ DIVERSE_WISHES = {
43
  ),
44
  }
45
 
46
- # Wishes chosen so the union of their mock scripts spans all 9 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,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", # raise, structure(new kinds), life(carts)
 
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 test_all_nine_tools_covered_across_suite():
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
- // Serves web/ itself on a free port (8200-8299), drives the mock world through
3
- // deterministic seeks, and saves shots to tools/shots/.
 
 
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(() => window.__godseed.stats());
87
- console.log(` 📸 ${name} ${JSON.stringify(s)}`);
 
 
 
 
88
  }
89
 
90
  // ------------------------------------------------------------------ suite
91
 
92
- // 1 · genesis idle — the zero-GPU landing moment (void sky, monolith, ring)
93
- await open("index.html?mock=1&upto=0&static=1");
 
 
 
 
 
 
94
  await page.evaluate(() => {
95
- window.__godseed.setCamera(0.55, 1.22, 6.4);
96
- window.__godseed.tick(6); // settle camera + ring fade-in + twinkle
97
  });
98
- await shot("01-genesis-idle.png");
99
 
100
- // 2 · full world, wide ten wishes deep under aurora
101
- await open("index.html?mock=1&static=1");
102
  await page.evaluate(() => {
103
- window.__godseed.setCamera(2.2, 1.05, 6.8);
104
- window.__godseed.tick(8);
105
  });
106
- await shot("02-world-aurora-wide.png");
 
 
 
 
 
107
 
108
- // 3 · terrain rising mid-wish run the feed, freeze during raise_terrain
 
 
 
 
 
 
109
  await open("index.html?mock=1");
110
  await page.evaluate(() => {
111
- window.__godseed.tick(2); // let the loop arm (cooldown 7s)…
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(16.2); // reading + first thought + raise_terrain delta mid-rise
118
  });
119
- await shot("03-terrain-rising.png");
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
- // 5 · storm under the inscription ringend of the feed wish
126
- await page.evaluate(() => { window.__godseed.tick(14); window.__godseed.gazeWide(); window.__godseed.tick(3); });
127
- await shot("05-storm-granted.png");
128
 
129
- // 6 · inscription orbit close-up over the village (gold hour)
130
- await open("index.html?mock=1&static=1&sky=gold&moons=1&weather=clear");
131
  await page.evaluate(() => {
132
- window.__godseed.gazeLatLon(8, 24, 60);
133
- window.__godseed.tick(5);
134
  });
135
- await shot("06-inscription-gaze.png");
 
 
 
 
 
136
 
137
- // 7 · Genesis Log — list + replay mid-flight
 
138
  await open("book.html?mock=1");
139
  await page.evaluate(() => {
140
- window.__godseed.replay("w_000005"); // the lighthouse wish
141
- window.__godseed.tick(18);
142
  });
143
- await shot("07-book-replay.png");
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.release();
 
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&ensp;·&ensp;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&ensp;→</a>
37
  </nav>
38
 
 
30
  <p class="brand__tag">nine billion parameters&ensp;·&ensp;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&ensp;→</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.release(),
 
 
 
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 CITY wish so the live demo exercises the v1.1 City
7
- // Update end-to-end: a tower rising over a warehouse and cafe, then lantern
8
- // carts gliding the new street at dusk.
 
 
 
9
 
10
  export const FEED_WISH = {
11
  trace: {
12
- wish_id: "w_000012",
13
- text: "raise me a city — a tower, a warehouse, a cafe, and carts at dusk",
14
  moderation: { allowed: true, category: null },
15
  reading:
16
- "A city, then — the big wish, the brave one. I will raise a shoulder of ground, " +
17
- "set a tower to hold the night's light, lay a long warehouse at its foot, keep one " +
18
- "cafe window warm, and let lantern-carts run the street between them at dusk.",
 
19
  turns: [
20
  {
21
- thought: "Ground first; a tower needs a shoulder to stand on.",
22
- call: { tool: "raise_terrain", args: { lat: -10, lon: 128, radius_deg: 12, height: 0.05, roughness: 0.4 } },
23
- observation: "ok: mountains risen at (-10,128)",
24
  },
25
  {
26
- thought: "The tower, slender, its rooftop light slow-pulsing over the dark.",
27
- call: { tool: "place_structure", args: { lat: -10, lon: 128, kind: "tower", scale: 1.6, hue: 205 } },
28
- observation: "ok: tower placed at (-10,128)",
29
  },
30
  {
31
- thought: "A warehouse at its foot, one great door glowing.",
32
- call: { tool: "place_structure", args: { lat: -12, lon: 126, kind: "warehouse", scale: 1.2, hue: 44 } },
33
- observation: "ok: warehouse placed at (-12,126)",
34
  },
35
  {
36
- thought: "A cafe to keep one window warm against all of it.",
37
- call: { tool: "place_structure", args: { lat: -8, lon: 130, kind: "cafe", scale: 0.9, hue: 36 } },
38
- observation: "ok: cafe placed at (-8,130)",
39
  },
40
  {
41
- thought: "A stream for the road to follow, and the carts to glide.",
42
- call: { tool: "place_water", args: { kind: "stream", path: [[-16, 120], [-12, 126], [-8, 130], [-4, 136]], hue: 200 } },
43
- observation: "ok: stream set toward (-4,136)",
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 tower stands over its warehouse and cafe, and carts run the streets at dusk.",
53
- feature_ids: ["f_000036", "f_000037", "f_000038", "f_000039", "f_000040", "f_000041"],
54
  model: "nemotron-3-nano (mock)",
55
  backend: "mock",
56
- ms_total: 58210,
57
  },
58
  features: [
59
- { id: "f_000036", wish_id: "w_000012", tool: "raise_terrain", args: { lat: -10, lon: 128, radius_deg: 12, height: 0.05, roughness: 0.4 }, seed: 1040806363, t: 1718201200.0 },
60
- { id: "f_000037", wish_id: "w_000012", tool: "place_structure", args: { lat: -10, lon: 128, kind: "tower", scale: 1.6, hue: 205 }, seed: 1225670989, t: 1718201203.0 },
61
- { id: "f_000038", wish_id: "w_000012", tool: "place_structure", args: { lat: -12, lon: 126, kind: "warehouse", scale: 1.2, hue: 44 }, seed: 1342640375, t: 1718201206.0 },
62
- { id: "f_000039", wish_id: "w_000012", tool: "place_structure", args: { lat: -8, lon: 130, kind: "cafe", scale: 0.9, hue: 36 }, seed: 654319713, t: 1718201209.0 },
63
- { id: "f_000040", wish_id: "w_000012", tool: "place_water", args: { kind: "stream", path: [[-16, 120], [-12, 126], [-8, 130], [-4, 136]], hue: 200 }, seed: 962901442, t: 1718201212.0 },
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": "a mountain to catch the first light",
5
  "submitted_at": 1718200090.0,
6
  "moderation": { "allowed": true, "category": null },
7
- "reading": "A mountain. Not for climbing for catching. The wisher wants the dawn to have somewhere to arrive. I will raise stone in the west, high enough to be struck first, and leave a word at its foot.",
8
  "turns": [
9
- { "thought": "The west face is bare. Raise the great peak there, rough, the way light likes it.", "call": { "tool": "raise_terrain", "args": { "lat": -8, "lon": -118, "radius_deg": 24, "height": 0.1, "roughness": 0.6 } }, "observation": "ok: mountains risen at (-8,-118)" },
10
- { "thought": "One peak is a spike. A shoulder beside it makes a mountain.", "call": { "tool": "raise_terrain", "args": { "lat": -16, "lon": -104, "radius_deg": 14, "height": 0.05, "roughness": 0.75 } }, "observation": "ok: mountains risen at (-16,-104)" },
11
- { "thought": "Mark the foot of it, so the light knows it was invited.", "call": { "tool": "inscribe_wish", "args": { "text": "the first light will know where to land", "style": "stone" } }, "observation": "ok: words set in stone" },
12
- { "thought": "It stands. Let the dawn do the rest.", "call": null, "observation": "" }
13
  ],
14
- "epitaph": "Stone was raised to meet the dawn halfway.",
15
  "feature_ids": ["f_000004", "f_000005", "f_000006"],
16
  "model": "nemotron-3-nano (mock)",
17
  "backend": "mock",
18
- "ms_total": 41230
19
  },
20
  "w_000002": {
21
  "wish_id": "w_000002",
22
- "text": "let glowing grass run down the slopes",
23
  "submitted_at": 1718200190.0,
24
  "moderation": { "allowed": true, "category": null },
25
- "reading": "Grass that glows. The old hill by the monolith has been dark too long. I will let light grow downhill, the way water would, and scatter flowers where it slows.",
26
  "turns": [
27
- { "thought": "Seed the slope below the monolith. Green-gold, dense, alive.", "call": { "tool": "spawn_flora", "args": { "lat": 8, "lon": 36, "radius_deg": 13, "kind": "glowgrass", "density": 0.85, "hue": 150 } }, "observation": "ok: flora seeded at (8,36)" },
28
- { "thought": "Where the grass thins, flowers. Violet ones, for contrast.", "call": { "tool": "spawn_flora", "args": { "lat": 16, "lon": 48, "radius_deg": 8, "kind": "flowers", "density": 0.7, "hue": 295 } }, "observation": "ok: flora seeded at (16,48)" },
29
- { "thought": "The hill breathes now.", "call": null, "observation": "" }
 
 
 
30
  ],
31
- "epitaph": "The slopes learned to hold their own light.",
32
- "feature_ids": ["f_000007", "f_000008"],
33
  "model": "nemotron-3-nano (mock)",
34
  "backend": "mock",
35
- "ms_total": 33180
36
  },
37
  "w_000003": {
38
  "wish_id": "w_000003",
39
- "text": "a sea of violet trees",
40
  "submitted_at": 1718200290.0,
41
  "moderation": { "allowed": true, "category": null },
42
- "reading": "A sea, but of trees. Seas need basins. I will press a hollow into the south and flood it with violet canopies a tide that never goes out.",
43
  "turns": [
44
- { "thought": "First the basin. Gentle, wide, where the trees will pool.", "call": { "tool": "lower_terrain", "args": { "lat": -26, "lon": 64, "radius_deg": 12, "depth": 0.04, "roughness": 0.5 } }, "observation": "ok: land lowered at (-26,64)" },
45
- { "thought": "Now the flood: violet trees, shore to shore.", "call": { "tool": "spawn_flora", "args": { "lat": -19, "lon": 57, "radius_deg": 15, "kind": "trees", "density": 0.75, "hue": 278 } }, "observation": "ok: flora seeded at (-19,57)" },
46
- { "thought": "From orbit it reads as water. Close up, it whispers like a forest. Both true.", "call": null, "observation": "" }
 
 
 
47
  ],
48
- "epitaph": "A forest that the horizon mistakes for water.",
49
- "feature_ids": ["f_000009", "f_000010"],
50
  "model": "nemotron-3-nano (mock)",
51
  "backend": "mock",
52
- "ms_total": 36900
53
  },
54
  "w_000004": {
55
  "wish_id": "w_000004",
56
- "text": "a river of light for the dark side",
57
  "submitted_at": 1718200390.0,
58
  "moderation": { "allowed": true, "category": null },
59
- "reading": "The dark side asks for a river. Not water light that behaves like water. I will draw it down from the highlands, let it pool where the land cups, and send rain to keep it company.",
60
  "turns": [
61
- { "thought": "Trace the course: down from the cold north, bending east where the land leans.", "call": { "tool": "place_water", "args": { "kind": "stream", "path": [[24, -30], [12, -14], [2, -2], [-9, 14]], "hue": 190 } }, "observation": "ok: stream laid across the surface" },
62
- { "thought": "Every river deserves a place to rest. A pool, at the end.", "call": { "tool": "place_water", "args": { "kind": "pool", "path": [[-12, 18]], "radius_deg": 5, "hue": 205 } }, "observation": "ok: pool filled at (-12,18)" },
63
- { "thought": "And rain, so the river has a sound.", "call": { "tool": "set_weather", "args": { "kind": "rain", "intensity": 0.45 } }, "observation": "ok: weather set to rain" },
64
- { "thought": "Light moves. The dark side is only dim now.", "call": null, "observation": "" }
65
  ],
66
- "epitaph": "A river was taught to carry light instead of water.",
67
- "feature_ids": ["f_000011", "f_000012", "f_000013"],
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 lighthouse for lost moons",
75
  "submitted_at": 1718200490.0,
76
  "moderation": { "allowed": true, "category": null },
77
- "reading": "Moons get lost too. They circle and circle and nothing waves back. A lighthouse, then on a headland I will raise for it sweeping gold through the mist.",
78
  "turns": [
79
- { "thought": "A lighthouse needs a cape. Raise one, modest, sea-smooth.", "call": { "tool": "raise_terrain", "args": { "lat": 31, "lon": -63, "radius_deg": 7, "height": 0.045, "roughness": 0.4 } }, "observation": "ok: mountains risen at (31,-63)" },
80
- { "thought": "The tower itself. Tall, patient, gold-lanterned.", "call": { "tool": "place_structure", "args": { "lat": 32, "lon": -61, "kind": "lighthouse", "scale": 1.6, "hue": 45 } }, "observation": "ok: lighthouse placed at (32,-61)" },
81
- { "thought": "Mist, so the beam has something to be seen against.", "call": { "tool": "set_weather", "args": { "kind": "mist", "intensity": 0.45 } }, "observation": "ok: weather set to mist" },
82
- { "thought": "Now even a lost moon can find the shore.", "call": null, "observation": "" }
 
 
83
  ],
84
- "epitaph": "Its beam sweeps the sky for anyone still circling.",
85
- "feature_ids": ["f_000014", "f_000015", "f_000016"],
86
  "model": "nemotron-3-nano (mock)",
87
  "backend": "mock",
88
- "ms_total": 45120
89
  },
90
  "w_000006": {
91
  "wish_id": "w_000006",
92
- "text": "a village that keeps its lamps lit",
93
  "submitted_at": 1718200590.0,
94
  "moderation": { "allowed": true, "category": null },
95
- "reading": "A village, and the lamps must not go out. I hear the real wish underneath: let no window go dark. Houses near water, reeds at the door, a hearth that argues with the night.",
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": "The far north, where the sky is thinnest. A green-flamed beacon.", "call": { "tool": "place_structure", "args": { "lat": 55, "lon": 150, "kind": "beacon", "scale": 1.5, "hue": 165 } }, "observation": "ok: beacon placed at (55,150)" },
167
- { "thought": "And the sky itself. Polar fire is that a sky I own?", "call": { "tool": "set_sky", "args": { "palette": "polar", "star_density": 0.8, "moons": 2 } }, "observation": "rejected: unknown palette 'polar'" },
168
- { "thought": "The sky, then: curtains of green and violet, drawn for the beacon.", "call": { "tool": "set_sky", "args": { "palette": "aurora", "star_density": 0.8, "moons": 2 } }, "observation": "ok: sky set to aurora, 2 moons" },
169
- { "thought": "It burns. The lights are already leaning closer.", "call": null, "observation": "" }
 
170
  ],
171
- "epitaph": "It burns green, and the sky answers.",
172
- "feature_ids": ["f_000026", "f_000027"],
173
  "model": "nemotron-3-nano (mock)",
174
  "backend": "mock",
175
- "ms_total": 51020
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": 36,
4
- "epoch": 11,
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":-8,"lon":-118,"radius_deg":24,"height":0.1,"roughness":0.6},"seed":83077351,"t":1718200100.0},
12
- {"id":"f_000005","wish_id":"w_000001","tool":"raise_terrain","args":{"lat":-16,"lon":-104,"radius_deg":14,"height":0.05,"roughness":0.75},"seed":1945409649,"t":1718200103.0},
13
- {"id":"f_000006","wish_id":"w_000001","tool":"inscribe_wish","args":{"text":"the first light will know where to land","style":"stone"},"seed":1795017163,"t":1718200106.0},
14
-
15
- {"id":"f_000007","wish_id":"w_000002","tool":"spawn_flora","args":{"lat":8,"lon":36,"radius_deg":13,"kind":"glowgrass","density":0.85,"hue":150},"seed":112531134,"t":1718200200.0},
16
- {"id":"f_000008","wish_id":"w_000002","tool":"spawn_flora","args":{"lat":16,"lon":48,"radius_deg":8,"kind":"flowers","density":0.7,"hue":295},"seed":1907500584,"t":1718200203.0},
17
-
18
- {"id":"f_000009","wish_id":"w_000003","tool":"lower_terrain","args":{"lat":-26,"lon":64,"radius_deg":12,"depth":0.04,"roughness":0.5},"seed":125271177,"t":1718200300.0},
19
- {"id":"f_000010","wish_id":"w_000003","tool":"spawn_flora","args":{"lat":-19,"lon":57,"radius_deg":15,"kind":"trees","density":0.75,"hue":278},"seed":1886407711,"t":1718200303.0},
20
-
21
- {"id":"f_000011","wish_id":"w_000004","tool":"place_water","args":{"kind":"stream","path":[[24,-30],[12,-14],[2,-2],[-9,14]],"hue":190},"seed":37251596,"t":1718200400.0},
22
- {"id":"f_000012","wish_id":"w_000004","tool":"place_water","args":{"kind":"pool","path":[[-12,18]],"radius_deg":5,"hue":205},"seed":1967086234,"t":1718200403.0},
23
- {"id":"f_000013","wish_id":"w_000004","tool":"set_weather","args":{"kind":"rain","intensity":0.45},"seed":1815481120,"t":1718200406.0},
24
-
25
- {"id":"f_000014","wish_id":"w_000005","tool":"raise_terrain","args":{"lat":31,"lon":-63,"radius_deg":7,"height":0.045,"roughness":0.4},"seed":66715707,"t":1718200500.0},
26
- {"id":"f_000015","wish_id":"w_000005","tool":"place_structure","args":{"lat":32,"lon":-61,"kind":"lighthouse","scale":1.6,"hue":45},"seed":1962750125,"t":1718200503.0},
27
- {"id":"f_000016","wish_id":"w_000005","tool":"set_weather","args":{"kind":"mist","intensity":0.45},"seed":1844732183,"t":1718200506.0},
28
-
29
- {"id":"f_000017","wish_id":"w_000006","tool":"place_structure","args":{"lat":4,"lon":12,"kind":"village","scale":1.25,"hue":38},"seed":29146722,"t":1718200600.0},
30
- {"id":"f_000018","wish_id":"w_000006","tool":"spawn_flora","args":{"lat":1,"lon":19,"radius_deg":6,"kind":"reeds","density":0.8,"hue":95},"seed":1992003316,"t":1718200603.0},
31
- {"id":"f_000019","wish_id":"w_000006","tool":"place_water","args":{"kind":"pool","path":[[-2,8]],"radius_deg":3,"hue":175},"seed":1873993550,"t":1718200606.0},
32
-
33
- {"id":"f_000020","wish_id":"w_000007","tool":"place_structure","args":{"lat":-44,"lon":-37,"kind":"shrine","scale":1.3,"hue":210},"seed":8311893,"t":1718200700.0},
34
- {"id":"f_000021","wish_id":"w_000007","tool":"set_weather","args":{"kind":"snow","intensity":0.6},"seed":2004477123,"t":1718200703.0},
35
-
36
- {"id":"f_000022","wish_id":"w_000008","tool":"spawn_flora","args":{"lat":-41,"lon":-22,"radius_deg":9,"kind":"mushrooms","density":0.9,"hue":198},"seed":186815336,"t":1718200800.0},
37
- {"id":"f_000023","wish_id":"w_000008","tool":"set_sky","args":{"palette":"night","star_density":0.75,"moons":2},"seed":2082841598,"t":1718200803.0},
38
-
39
- {"id":"f_000024","wish_id":"w_000009","tool":"place_structure","args":{"lat":22,"lon":96,"kind":"arch","scale":1.8,"hue":320},"seed":182516063,"t":1718200900.0},
40
- {"id":"f_000025","wish_id":"w_000009","tool":"spawn_flora","args":{"lat":21,"lon":97,"radius_deg":7,"kind":"vines","density":0.65,"hue":120},"seed":2112342473,"t":1718200903.0},
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":"w_000011","text":"a city with a tower, a warehouse, a cafe, and carts at dusk","epitaph":"A tower stands over its warehouse and cafe, and carts run the streets at dusk.","epoch":11,"ts":1718201115.0},
58
- {"wish_id":"w_000010","text":"a beacon to call the dancing lights","epitaph":"It burns green, and the sky answers.","epoch":10,"ts":1718201003.0},
59
- {"wish_id":"w_000009","text":"an arch wrapped in living vines","epitaph":"Walk through; the vines lean in to listen.","epoch":9,"ts":1718200903.0},
60
- {"wish_id":"w_000008","text":"mushrooms beneath a second moon","epitaph":"Two moons now, and small lamps to greet them.","epoch":8,"ts":1718200803.0},
61
- {"wish_id":"w_000007","text":"a shrine where snow falls and stays","epitaph":"The snow keeps its appointment forever.","epoch":7,"ts":1718200703.0},
62
- {"wish_id":"w_000006","text":"a village that keeps its lamps lit","epitaph":"Nine windows. None of them dark.","epoch":6,"ts":1718200606.0}
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 that auto-orbits the planet
2
- // and, on each tool_call, eases over to look at the spot where the world is
3
- // about to change. Drag to orbit; wheel to approach. On a granted wish (or a
4
- // Genesis Log replay) it can also descend a slow liturgical swoop down to the
5
- // surface over the target, then a glide arc, then a rise back to orbit. Every
6
- // motion is sim-time deterministic (driven only by update(dt)); no Date/random.
 
 
 
 
 
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 (the demo money-shot). `point` null wide shot.
 
 
 
88
  */
89
  gaze(point, { hold = 4.5 } = {}) {
90
  if (this.userHold > 0 || this.descent) return; // hand on the camera / mid-descent
91
- if (point) {
 
 
 
 
 
 
 
 
 
 
 
 
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 slow contemplative orbit. */
106
  release() {
107
  if (this.descent) return; // a descent owns the camera until it finishes/cancels
108
  this.gazeHold = 0;
109
- this.distT = WIDE_DIST;
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 a gentle wide pull
150
- this.distT = WIDE_DIST;
151
- this.lookT.set(0, 0, 0);
152
- this.thetaT = this.theta;
153
- this.phiT = this.phi;
 
 
 
 
 
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(WIDE_DIST, this._groundClear(d.start), k);
179
  lookDir = d.start.clone();
180
- lookR = lerp(PLANET_R * 0.5, DESCENT_CLEAR, k);
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 orbit, re-acquiring the wide framing
 
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), WIDE_DIST, k);
194
  lookDir = dir;
195
- lookR = lerp(DESCENT_CLEAR, 0, k);
 
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
- this.distT = WIDE_DIST;
225
- this.lookT.set(0, 0, 0);
 
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.05, 0.7, 12, 1, true), beamMat(color, 0.16));
79
  cone.rotation.z = s * (Math.PI / 2);
80
- cone.position.x = s * 0.35;
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
- const pillarInner = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.011, 1.3, 8, 1, true), beamMat(color, 0.3));
207
- pillarInner.position.y = 0.73;
 
 
208
  g.add(pillarInner);
209
- const pillarOuter = new THREE.Mesh(new THREE.CylinderGeometry(0.018, 0.03, 1.0, 10, 1, true), beamMat(color, 0.1));
210
- pillarOuter.position.y = 0.58;
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
- class Structure {
 
 
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; text-align: right;
 
 
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;