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

City Update: towers/warehouses/cafes, spawn_life (carts/birds/fireflies), cinematic descent, visible deltas, camera gaze, bigger ticker, counter fix

Browse files
ARCHITECTURE.md CHANGED
@@ -78,18 +78,19 @@ 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 8 tools (GBNF-friendly: flat args, numbers + enums + short strings)
82
 
83
  | tool | args (ranges clamped by engine) |
84
  |---|---|
85
  | `raise_terrain` | `lat` −85..85, `lon` −180..180, `radius_deg` 2..45, `height` 0.01..0.12 (planet radii), `roughness` 0..1 |
86
  | `lower_terrain` | `lat`, `lon`, `radius_deg` 2..45, `depth` 0.01..0.10, `roughness` 0..1 |
87
  | `spawn_flora` | `lat`, `lon`, `radius_deg` 1..30, `kind` ∈ trees\|glowgrass\|mushrooms\|vines\|flowers\|reeds, `density` 0..1, `hue` 0..360 |
88
- | `place_structure` | `lat`, `lon`, `kind` ∈ lighthouse\|monolith\|shrine\|village\|beacon\|arch, `scale` 0.5..2.0, `hue` 0..360 |
89
  | `place_water` | `kind` ∈ stream\|pool, `path` [[lat,lon] ×1..5] (stream: 2..5 waypoints, becomes glowing spline on the surface — NOT carved; pool: path[0] center), `radius_deg` 1..10 (pool only), `hue` 160..260 |
90
  | `set_weather` | `kind` ∈ clear\|rain\|snow\|embers\|mist\|storm, `intensity` 0..1 |
91
  | `set_sky` | `palette` ∈ dawn\|dusk\|night\|aurora\|ember\|void\|gold, `star_density` 0..1, `moons` 0..3 |
92
  | `inscribe_wish` | `text` ≤90 chars (the wish, possibly poeticized), `style` ∈ orbit\|stone |
 
93
 
94
  Engine **clamps** out-of-range numbers (doesn't reject) and rejects only unknown tools/enums with a
95
  terse observation string the model can react to.
 
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
  |---|---|
85
  | `raise_terrain` | `lat` −85..85, `lon` −180..180, `radius_deg` 2..45, `height` 0.01..0.12 (planet radii), `roughness` 0..1 |
86
  | `lower_terrain` | `lat`, `lon`, `radius_deg` 2..45, `depth` 0.01..0.10, `roughness` 0..1 |
87
  | `spawn_flora` | `lat`, `lon`, `radius_deg` 1..30, `kind` ∈ trees\|glowgrass\|mushrooms\|vines\|flowers\|reeds, `density` 0..1, `hue` 0..360 |
88
+ | `place_structure` | `lat`, `lon`, `kind` ∈ lighthouse\|monolith\|shrine\|village\|beacon\|arch\|tower\|warehouse\|cafe, `scale` 0.5..2.0, `hue` 0..360 |
89
  | `place_water` | `kind` ∈ stream\|pool, `path` [[lat,lon] ×1..5] (stream: 2..5 waypoints, becomes glowing spline on the surface — NOT carved; pool: path[0] center), `radius_deg` 1..10 (pool only), `hue` 160..260 |
90
  | `set_weather` | `kind` ∈ clear\|rain\|snow\|embers\|mist\|storm, `intensity` 0..1 |
91
  | `set_sky` | `palette` ∈ dawn\|dusk\|night\|aurora\|ember\|void\|gold, `star_density` 0..1, `moons` 0..3 |
92
  | `inscribe_wish` | `text` ≤90 chars (the wish, possibly poeticized), `style` ∈ orbit\|stone |
93
+ | `spawn_life` | `lat`, `lon`, `radius_deg` 1..20, `kind` ∈ carts\|birds\|fireflies, `count` 1..12 (integer), `hue` 0..360 |
94
 
95
  Engine **clamps** out-of-range numbers (doesn't reject) and rejects only unknown tools/enums with a
96
  terse observation string the model can react to.
README.md CHANGED
@@ -87,8 +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 8 tools (`raise_terrain`, `lower_terrain`, `spawn_flora`,
91
- `place_structure`, `place_water`, `set_weather`, `set_sky`, `inscribe_wish`). The
 
92
  deterministic engine clamps every number, seeds every feature, and feeds terse
93
  observations back into the loop. Wishes pass layered moderation before a single tool
94
  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 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
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 8-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 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
  """
engine/tools.py CHANGED
@@ -1,4 +1,4 @@
1
- """GODSEED tool DSL — the single source of truth for the 8-tool surface.
2
 
3
  Spec is data (TOOLS); validation is `validate_call`. Semantics per the
4
  architecture contract:
@@ -48,11 +48,15 @@ class PathSpec:
48
 
49
 
50
  FLORA_KINDS = ("trees", "glowgrass", "mushrooms", "vines", "flowers", "reeds")
51
- STRUCTURE_KINDS = ("lighthouse", "monolith", "shrine", "village", "beacon", "arch")
 
 
 
52
  WATER_KINDS = ("stream", "pool")
53
  WEATHER_KINDS = ("clear", "rain", "snow", "embers", "mist", "storm")
54
  SKY_PALETTES = ("dawn", "dusk", "night", "aurora", "ember", "void", "gold")
55
  INSCRIBE_STYLES = ("orbit", "stone")
 
56
 
57
  TOOLS: dict[str, dict[str, Any]] = {
58
  "raise_terrain": {
@@ -103,6 +107,14 @@ TOOLS: dict[str, dict[str, Any]] = {
103
  "text": TextSpec(90),
104
  "style": EnumSpec(INSCRIBE_STYLES),
105
  },
 
 
 
 
 
 
 
 
106
  }
107
 
108
  TOOL_NAMES: tuple[str, ...] = tuple(TOOLS.keys())
 
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:
 
48
 
49
 
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")
57
  SKY_PALETTES = ("dawn", "dusk", "night", "aurora", "ember", "void", "gold")
58
  INSCRIBE_STYLES = ("orbit", "stone")
59
+ LIFE_KINDS = ("carts", "birds", "fireflies")
60
 
61
  TOOLS: dict[str, dict[str, Any]] = {
62
  "raise_terrain": {
 
107
  "text": TextSpec(90),
108
  "style": EnumSpec(INSCRIBE_STYLES),
109
  },
110
+ "spawn_life": {
111
+ "lat": NumberSpec(*LAT_RANGE),
112
+ "lon": NumberSpec(*LON_RANGE),
113
+ "radius_deg": NumberSpec(1, 20),
114
+ "kind": EnumSpec(LIFE_KINDS),
115
+ "count": NumberSpec(1, 12, integer=True),
116
+ "hue": NumberSpec(0, 360),
117
+ },
118
  }
119
 
120
  TOOL_NAMES: tuple[str, ...] = tuple(TOOLS.keys())
engine/world.py CHANGED
@@ -96,6 +96,8 @@ def _observation(feature: Feature) -> str:
96
  return f"ok: sky set to {a['palette']}"
97
  if t == "inscribe_wish":
98
  return f"ok: wish inscribed in {a['style']}"
 
 
99
  return "ok" # pragma: no cover — every known tool is handled above
100
 
101
 
 
96
  return f"ok: sky set to {a['palette']}"
97
  if t == "inscribe_wish":
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
 
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 8-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 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
 
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\""
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\""
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 8 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
 
@@ -224,9 +224,16 @@ def _forest(wish: str, rng: random.Random, mood: str) -> dict:
224
  "set_weather",
225
  kind="rain", intensity=_r2(rng, 0.2, 0.4),
226
  ),
 
 
 
 
 
 
 
227
  _done(
228
  "Forests finish themselves. I only start them.",
229
- f"A forest grows in {place}, lit from below.",
230
  ),
231
  ],
232
  }
@@ -313,11 +320,11 @@ def _lighthouse(wish: str, rng: random.Random, mood: str) -> dict:
313
 
314
  def _village(wish: str, rng: random.Random, mood: str) -> dict:
315
  lat, lon = _anchor(rng)
316
- flower_hue = _r1(rng, 20, 60) if mood != "melancholy" else _r1(rng, 220, 280)
317
  reading = (
318
  "A hearth-wish. These are my favorite, though a god should not say "
319
- "so. A village in a gentle fold of land, flowers along the road, and "
320
- "the words themselves kept over the rooftops."
321
  )
322
  return {
323
  "reading": reading,
@@ -335,11 +342,16 @@ def _village(wish: str, rng: random.Random, mood: str) -> dict:
335
  scale=_r2(rng, 1.0, 1.4), hue=_r1(rng, 30, 50),
336
  ),
337
  _call(
338
- "Flowers along the road no one has walked yet.",
339
- "spawn_flora",
340
- lat=_clamp_lat(lat - 2), lon=_wrap_lon(lon + 3),
341
- radius_deg=_r1(rng, 5, 9), kind="flowers",
342
- density=_r2(rng, 0.5, 0.7), hue=flower_hue,
 
 
 
 
 
343
  ),
344
  _call(
345
  "Set the wish over the rooftops, where smoke would go.",
@@ -348,7 +360,57 @@ def _village(wish: str, rng: random.Random, mood: str) -> dict:
348
  ),
349
  _done(
350
  "Small lights, kept on. There is no greater architecture.",
351
- "A village stands with its windows lit, as if expecting someone.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  ),
353
  ],
354
  }
@@ -452,9 +514,15 @@ def _sky(wish: str, rng: random.Random, mood: str) -> dict:
452
  kind="glowgrass", density=_r2(rng, 0.4, 0.7),
453
  hue=_r1(rng, 150, 200),
454
  ),
 
 
 
 
 
 
455
  _done(
456
  "Look up, little world. I rewrote you an answer.",
457
- f"The night is rewritten: more stars, {moons} moons, one wish in orbit.",
458
  ),
459
  ],
460
  }
@@ -512,6 +580,7 @@ def _default(wish: str, rng: random.Random, mood: str) -> dict:
512
  _CATEGORIES: tuple[tuple[re.Pattern[str], object], ...] = (
513
  (re.compile(r"\b(mushrooms?|fungus|fungi|spores?)\b", re.I), _mushroom),
514
  (re.compile(r"\b(lighthouses?|beacons?|lamps?|ships?|sailors?|harbou?rs?|light)\b", re.I), _lighthouse),
 
515
  (re.compile(r"\b(villages?|homes?|houses?|hearths?|towns?|kettles?|people|friends?|family)\b", re.I), _village),
516
  (re.compile(r"\b(seas?|oceans?|lakes?|rivers?|streams?|water|tides?|waves?|shores?|pools?)\b", re.I), _water),
517
  (re.compile(r"\b(mountains?|peaks?|hills?|cliffs?|summits?|crags?|valleys?)\b", re.I), _mountain),
 
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
 
 
224
  "set_weather",
225
  kind="rain", intensity=_r2(rng, 0.2, 0.4),
226
  ),
227
+ _call(
228
+ "Fireflies, to carry the glowgrass up into the air.",
229
+ "spawn_life",
230
+ lat=lat, lon=lon, radius_deg=_r1(rng, 8, 14),
231
+ kind="fireflies", count=rng.randint(6, 12),
232
+ hue=_r1(rng, 150, 200),
233
+ ),
234
  _done(
235
  "Forests finish themselves. I only start them.",
236
+ f"A forest grows in {place}, lit from below and adrift with fireflies.",
237
  ),
238
  ],
239
  }
 
320
 
321
  def _village(wish: str, rng: random.Random, mood: str) -> dict:
322
  lat, lon = _anchor(rng)
323
+ warm_hue = _r1(rng, 28, 46)
324
  reading = (
325
  "A hearth-wish. These are my favorite, though a god should not say "
326
+ "so. A village in a gentle fold of land, a small cafe with its light "
327
+ "on the road, lantern-carts at dusk, and the words kept over the roofs."
328
  )
329
  return {
330
  "reading": reading,
 
342
  scale=_r2(rng, 1.0, 1.4), hue=_r1(rng, 30, 50),
343
  ),
344
  _call(
345
+ "A cafe at the corner, its warmth spilled on the road.",
346
+ "place_structure",
347
+ lat=_clamp_lat(lat + 1), lon=_wrap_lon(lon - 2),
348
+ kind="cafe", scale=_r2(rng, 0.8, 1.1), hue=warm_hue,
349
+ ),
350
+ _call(
351
+ "Lantern-carts, gliding home at dusk.",
352
+ "spawn_life",
353
+ lat=lat, lon=lon, radius_deg=_r1(rng, 6, 10),
354
+ kind="carts", count=rng.randint(3, 6), hue=warm_hue,
355
  ),
356
  _call(
357
  "Set the wish over the rooftops, where smoke would go.",
 
360
  ),
361
  _done(
362
  "Small lights, kept on. There is no greater architecture.",
363
+ "A village stands with its windows lit, and carts come and go beneath them.",
364
+ ),
365
+ ],
366
+ }
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
  }
 
514
  kind="glowgrass", density=_r2(rng, 0.4, 0.7),
515
  hue=_r1(rng, 150, 200),
516
  ),
517
+ _call(
518
+ "And a flock to circle beneath the new moons.",
519
+ "spawn_life",
520
+ lat=lat, lon=lon, radius_deg=_r1(rng, 10, 16),
521
+ kind="birds", count=rng.randint(5, 9), hue=_r1(rng, 200, 260),
522
+ ),
523
  _done(
524
  "Look up, little world. I rewrote you an answer.",
525
+ f"The night is rewritten: more stars, {moons} moons, a flock, one wish in orbit.",
526
  ),
527
  ],
528
  }
 
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),
mind/prompts.py CHANGED
@@ -43,6 +43,7 @@ TOOL_ORDER: tuple[str, ...] = (
43
  "set_weather",
44
  "set_sky",
45
  "inscribe_wish",
 
46
  )
47
 
48
  # DEVIATION: vendored copy of the engine/tools.py spec table (mirrors the
@@ -65,7 +66,7 @@ _VENDORED_TOOL_SPECS: dict[str, dict[str, str]] = {
65
  },
66
  "place_structure": {
67
  "lat": "-85..85", "lon": "-180..180",
68
- "kind": "lighthouse|monolith|shrine|village|beacon|arch",
69
  "scale": "0.5..2.0", "hue": "0..360",
70
  },
71
  "place_water": {
@@ -83,6 +84,10 @@ _VENDORED_TOOL_SPECS: dict[str, dict[str, str]] = {
83
  "inscribe_wish": {
84
  "text": "<=90 chars (the wish, possibly poeticized)", "style": "orbit|stone",
85
  },
 
 
 
 
86
  }
87
 
88
 
@@ -108,7 +113,7 @@ TOOL_SPECS, TOOL_SPECS_FROM_ENGINE = _load_tool_specs()
108
 
109
 
110
  def tool_names() -> tuple[str, ...]:
111
- """The 8 legal tool names, in canonical order."""
112
  return TOOL_ORDER
113
 
114
 
@@ -156,7 +161,7 @@ def render_tool_docs(specs: dict[str, dict] | None = None) -> str:
156
  PERSONA = """You are the god of one small planet. You are ancient, patient, and kind.
157
  You speak rarely and plainly, the way stone speaks. Terse, poetic, never cute.
158
  You do not hold the world's facts; the engine does. You read the wish, say what
159
- you see, and act only through the tools below. ONLY these eight tools exist —
160
  any other tool name is refused. When a wish asks for what no tool can make, do
161
  not refuse: translate its spirit into what the tools CAN make. Plans are
162
  concrete: coordinates, kinds, hues. Three to five acts, then rest. Numbers out
 
43
  "set_weather",
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
  },
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": {
 
84
  "inscribe_wish": {
85
  "text": "<=90 chars (the wish, possibly poeticized)", "style": "orbit|stone",
86
  },
87
+ "spawn_life": {
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
 
114
 
115
  def tool_names() -> tuple[str, ...]:
116
+ """The 9 legal tool names, in canonical order."""
117
  return TOOL_ORDER
118
 
119
 
 
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
tests/test_engine.py CHANGED
@@ -536,7 +536,7 @@ def test_summary_stays_under_cap_with_many_features():
536
  # ------------------------------------------------------------------------ misc
537
 
538
 
539
- def test_tool_surface_is_exactly_eight():
540
  assert TOOL_NAMES == (
541
  "raise_terrain",
542
  "lower_terrain",
@@ -546,4 +546,81 @@ def test_tool_surface_is_exactly_eight():
546
  "set_weather",
547
  "set_sky",
548
  "inscribe_wish",
 
549
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  # ------------------------------------------------------------------------ misc
537
 
538
 
539
+ def test_tool_surface_is_exactly_nine():
540
  assert TOOL_NAMES == (
541
  "raise_terrain",
542
  "lower_terrain",
 
546
  "set_weather",
547
  "set_sky",
548
  "inscribe_wish",
549
+ "spawn_life",
550
  )
551
+
552
+
553
+ # --------------------------------------------------------------------- city update
554
+
555
+
556
+ def test_new_structure_kinds_accepted():
557
+ world = make_world()
558
+ for kind in ("tower", "warehouse", "cafe"):
559
+ feature, obs = world.apply(
560
+ "w_000001",
561
+ 0,
562
+ {"tool": "place_structure",
563
+ "args": {"lat": 5, "lon": 5, "kind": kind, "scale": 1.0, "hue": 40}},
564
+ )
565
+ assert feature is not None, kind
566
+ assert feature.args["kind"] == kind
567
+ assert obs == f"ok: {kind} placed at (5,5)"
568
+
569
+
570
+ def test_place_structure_still_rejects_unknown_kind():
571
+ args, err = validate_call(
572
+ "place_structure",
573
+ {"lat": 0, "lon": 0, "kind": "castle", "scale": 1.0, "hue": 100},
574
+ )
575
+ assert args is None and err == "rejected: unknown kind 'castle'"
576
+
577
+
578
+ def test_spawn_life_valid_and_clamped():
579
+ world = make_world()
580
+ feature, obs = world.apply(
581
+ "w_000001",
582
+ 0,
583
+ {"tool": "spawn_life",
584
+ "args": {"lat": 200, "lon": -999, "radius_deg": 99, "kind": "CARTS",
585
+ "count": 50, "hue": 720}},
586
+ )
587
+ assert feature is not None
588
+ assert feature.args == {
589
+ "lat": 85.0,
590
+ "lon": -180.0,
591
+ "radius_deg": 20.0, # clamped to spawn_life max
592
+ "kind": "carts", # enum lowercased
593
+ "count": 12, # clamped to max, integer
594
+ "hue": 360.0,
595
+ }
596
+ assert isinstance(feature.args["count"], int)
597
+ assert obs == "ok: 12 carts stirring at (85,-180)"
598
+
599
+
600
+ def test_spawn_life_count_is_integer_and_low_clamp():
601
+ args, err = validate_call(
602
+ "spawn_life",
603
+ {"lat": 0, "lon": 0, "radius_deg": 0.1, "kind": "fireflies",
604
+ "count": 0.4, "hue": 200},
605
+ )
606
+ assert err is None
607
+ assert args["radius_deg"] == 1.0 # clamped up to min
608
+ assert args["count"] == 1 # rounds/clamps up to min, integer
609
+ assert isinstance(args["count"], int)
610
+ assert args["kind"] == "fireflies"
611
+
612
+
613
+ def test_spawn_life_rejects_unknown_kind():
614
+ args, err = validate_call(
615
+ "spawn_life",
616
+ {"lat": 0, "lon": 0, "radius_deg": 5, "kind": "dragons", "count": 3, "hue": 100},
617
+ )
618
+ assert args is None and err == "rejected: unknown kind 'dragons'"
619
+
620
+
621
+ def test_spawn_life_missing_arg_rejected():
622
+ args, err = validate_call(
623
+ "spawn_life",
624
+ {"lat": 0, "lon": 0, "radius_deg": 5, "kind": "birds", "hue": 100},
625
+ )
626
+ assert args is None and err == "rejected: missing arg 'count'"
tests/test_mind.py CHANGED
@@ -43,15 +43,15 @@ DIVERSE_WISHES = {
43
  ),
44
  }
45
 
46
- # Wishes chosen so the union of their mock scripts spans all 8 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
50
- "an ancient forest full of fireflies", # flora, weather
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 tiny village where the kettle is always on", # raise, structure, flora, inscribe
55
  "blorptangle the seventh hum", # flora, structure, inscribe
56
  ]
57
 
@@ -177,7 +177,7 @@ def test_call_turns_valid(label):
177
  assert [t["call"] for t in call_turns] == calls
178
 
179
 
180
- def test_all_eight_tools_covered_across_suite():
181
  used: set[str] = set()
182
  for wish in COVERAGE_WISHES:
183
  trace, _events, _calls = run_grant(wish)
@@ -185,6 +185,54 @@ def test_all_eight_tools_covered_across_suite():
185
  assert used == TOOLS, f"missing tools: {TOOLS - used}"
186
 
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  def test_inscriptions_clamped_for_long_wish():
189
  trace, _events, _calls = run_grant(DIVERSE_WISHES["long"])
190
  inscriptions = [
 
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)
50
+ "an ancient forest full of fireflies", # flora, weather, life(fireflies)
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
  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)
 
185
  assert used == TOOLS, f"missing tools: {TOOLS - used}"
186
 
187
 
188
+ def test_city_script_uses_new_kinds_and_spawn_life():
189
+ """The city script exercises the new building kinds and the 9th tool, and
190
+ every call turn re-parses cleanly through parse_turn (grammar's JSON shape)."""
191
+ trace, _events, _calls = run_grant("a bright city of towers and warehouses and cafes")
192
+ call_turns = [t for t in trace["turns"] if t["call"] is not None]
193
+
194
+ structure_kinds = {
195
+ t["call"]["args"]["kind"]
196
+ for t in call_turns
197
+ if t["call"]["tool"] == "place_structure"
198
+ }
199
+ assert {"tower", "warehouse", "cafe"} <= structure_kinds
200
+
201
+ life_calls = [t["call"] for t in call_turns if t["call"]["tool"] == "spawn_life"]
202
+ assert life_calls, "city script should call spawn_life"
203
+ for call in life_calls:
204
+ args = call["args"]
205
+ assert args["kind"] in ("carts", "birds", "fireflies")
206
+ assert isinstance(args["count"], int) and 1 <= args["count"] <= 12
207
+ assert set(args) == {"lat", "lon", "radius_deg", "kind", "count", "hue"}
208
+
209
+ # Every emitted call turn is valid against the turn parser/grammar shape.
210
+ for turn in call_turns:
211
+ rendered = json.dumps({"thought": turn["thought"], "call": turn["call"]})
212
+ obj, err = parse_turn(rendered)
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."""
219
+ life_kinds: set[str] = set()
220
+ for wish in (
221
+ "a bright city of towers and warehouses and cafes", # carts
222
+ "an ancient forest full of fireflies", # fireflies
223
+ "more stars and another moon over the night sky", # birds
224
+ ):
225
+ trace, _events, _calls = run_grant(wish)
226
+ for turn in trace["turns"]:
227
+ call = turn["call"]
228
+ if call and call["tool"] == "spawn_life":
229
+ life_kinds.add(call["args"]["kind"])
230
+ rendered = json.dumps({"thought": turn["thought"], "call": call})
231
+ obj, err = parse_turn(rendered)
232
+ assert err is None, f"spawn_life turn failed to parse: {err}"
233
+ assert life_kinds == {"carts", "birds", "fireflies"}
234
+
235
+
236
  def test_inscriptions_clamped_for_long_wish():
237
  trace, _events, _calls = run_grant(DIVERSE_WISHES["long"])
238
  inscriptions = [
web/book.js CHANGED
@@ -48,17 +48,27 @@ try {
48
  }
49
  app.start();
50
 
 
 
 
 
 
 
 
 
 
51
  // ---------------------------------------------------------------- data
52
  async function loadIndex() {
53
  if (MOCK) {
54
  const traces = await fetch("./mock/traces.json").then((r) => r.json());
55
  const { FEED_WISH } = await import("./mock/feed.js");
56
- const list = Object.values(traces).map((t) => ({
57
  wish_id: t.wish_id, text: t.text, epitaph: t.epitaph, ts: t.submitted_at,
 
58
  }));
59
  list.push({
60
  wish_id: FEED_WISH.trace.wish_id, text: FEED_WISH.trace.text,
61
- epitaph: FEED_WISH.trace.epitaph, ts: 1718201100,
62
  });
63
  return { list, traces: { ...traces, [FEED_WISH.trace.wish_id]: FEED_WISH.trace }, feed: FEED_WISH };
64
  }
@@ -92,6 +102,16 @@ async function loadTrace(ctx, wishId) {
92
  // ---------------------------------------------------------------- replay
93
  const runner = new ScriptRunner({});
94
  let phase = "reading";
 
 
 
 
 
 
 
 
 
 
95
 
96
  function replayWish(trace, allFeatures) {
97
  runner.cancel();
@@ -100,6 +120,7 @@ function replayWish(trace, allFeatures) {
100
  els.voiceWish.textContent = `“${trace.text}”`;
101
  els.voiceDot.classList.add("is-live");
102
  phase = "reading";
 
103
 
104
  // world at features[0..K)
105
  const mine = allFeatures.filter((f) => f.wish_id === trace.wish_id);
@@ -129,14 +150,22 @@ function replayWish(trace, allFeatures) {
129
  const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime });
130
  if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4 });
131
  else if (cue.wide) app.rig.gaze(null, { hold: 3.5 });
 
 
132
  },
133
  granted: (e) => {
134
  els.epitaphText.textContent = e.epitaph;
135
  els.epitaph.classList.add("is-shown");
136
  els.voiceDot.classList.remove("is-live");
137
  app.world.sky.flash(0.3);
138
- app.rig.release();
139
- setTimeout(() => els.epitaph.classList.remove("is-shown"), 7000);
 
 
 
 
 
 
140
  },
141
  };
142
  runner.start(buildWishScript(trace, features), app.simTime);
@@ -148,11 +177,19 @@ app.onTick((t) => runner.update(t));
148
  (async () => {
149
  try {
150
  const [ctx, state] = await Promise.all([loadIndex(), loadState()]);
151
- const list = [...ctx.list].sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
152
-
153
- // world as it stands now (zero GPU, gorgeous by default)
 
 
 
 
 
 
 
154
  app.world.reset(state.features, app.simTime);
155
- els.status.textContent = `epoch ${state.epoch} · ${list.length} wishes inscribed`;
 
156
 
157
  if (!list.length) {
158
  const div = document.createElement("div");
@@ -163,11 +200,11 @@ app.onTick((t) => runner.update(t));
163
  }
164
 
165
  const buttons = new Map();
166
- list.forEach((w, i) => {
167
  const btn = document.createElement("button");
168
  btn.className = "ledger__entry";
169
  btn.innerHTML = `
170
- <div class="ledger__epoch">epoch ${w.epoch ?? i + 1}</div>
171
  <div class="ledger__wish"></div>
172
  <div class="ledger__epitaph"></div>`;
173
  btn.querySelector(".ledger__wish").textContent = `“${w.text}”`;
@@ -200,6 +237,10 @@ app.onTick((t) => runner.update(t));
200
  pause: (v) => app.pause(v),
201
  stats: () => app.stats(),
202
  replay: (id) => buttons.get(id)?.click(),
 
 
 
 
203
  setCamera: (theta, phi, dist) => {
204
  Object.assign(app.rig, {
205
  theta, thetaT: theta, phi, phiT: phi,
 
48
  }
49
  app.start();
50
 
51
+ // left-side dark scrim so the log column reads over the bright planet rim +
52
+ // gold trail (sits behind the canvas overlays, above the scene).
53
+ if (!document.querySelector(".ledger-scrim")) {
54
+ const scrim = document.createElement("div");
55
+ scrim.className = "ledger-scrim";
56
+ scrim.setAttribute("aria-hidden", "true");
57
+ document.body.insertBefore(scrim, document.querySelector(".vignette") || els.ledger);
58
+ }
59
+
60
  // ---------------------------------------------------------------- data
61
  async function loadIndex() {
62
  if (MOCK) {
63
  const traces = await fetch("./mock/traces.json").then((r) => r.json());
64
  const { FEED_WISH } = await import("./mock/feed.js");
65
+ const list = Object.values(traces).map((t, i) => ({
66
  wish_id: t.wish_id, text: t.text, epitaph: t.epitaph, ts: t.submitted_at,
67
+ epoch: i + 1, // grant order mirrors the API's enumerate(start=1)
68
  }));
69
  list.push({
70
  wish_id: FEED_WISH.trace.wish_id, text: FEED_WISH.trace.text,
71
+ epitaph: FEED_WISH.trace.epitaph, ts: 1718201100, epoch: list.length + 1,
72
  });
73
  return { list, traces: { ...traces, [FEED_WISH.trace.wish_id]: FEED_WISH.trace }, feed: FEED_WISH };
74
  }
 
102
  // ---------------------------------------------------------------- replay
103
  const runner = new ScriptRunner({});
104
  let phase = "reading";
105
+ let lastDescentDir = null;
106
+
107
+ // The direction a feature landed at (descent target). Sky/weather have none.
108
+ function featureDir(feature) {
109
+ const a = feature?.args || {};
110
+ if (feature?.tool === "set_sky" || feature?.tool === "set_weather") return null;
111
+ if (a.path && a.path.length) return latLonToDir(a.path[0][0], a.path[0][1]);
112
+ if (a.lat != null && a.lon != null) return latLonToDir(a.lat, a.lon);
113
+ return null;
114
+ }
115
 
116
  function replayWish(trace, allFeatures) {
117
  runner.cancel();
 
120
  els.voiceWish.textContent = `“${trace.text}”`;
121
  els.voiceDot.classList.add("is-live");
122
  phase = "reading";
123
+ lastDescentDir = null;
124
 
125
  // world at features[0..K)
126
  const mine = allFeatures.filter((f) => f.wish_id === trace.wish_id);
 
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);
154
+ if (dir) lastDescentDir = dir.clone().normalize();
155
  },
156
  granted: (e) => {
157
  els.epitaphText.textContent = e.epitaph;
158
  els.epitaph.classList.add("is-shown");
159
  els.voiceDot.classList.remove("is-live");
160
  app.world.sky.flash(0.3);
161
+ // visit it: after the final delta, swoop down over the wish site, then rise
162
+ if (lastDescentDir) {
163
+ app.rig.setTerrain?.(app.world.terrain);
164
+ app.rig.descend(lastDescentDir.clone().multiplyScalar(PLANET_R));
165
+ } else {
166
+ app.rig.release();
167
+ }
168
+ setTimeout(() => els.epitaph.classList.remove("is-shown"), 8500);
169
  },
170
  };
171
  runner.start(buildWishScript(trace, features), app.simTime);
 
177
  (async () => {
178
  try {
179
  const [ctx, state] = await Promise.all([loadIndex(), loadState()]);
180
+ // newest first a just-granted wish must appear at the TOP of the log.
181
+ // epoch is the server's monotonic grant count (start=1); ts can be 0, so
182
+ // epoch is the primary key, ts the tiebreak.
183
+ const ord = (w, i) => (w.epoch ?? w.ts ?? i) * 1e6 + (w.ts ?? 0);
184
+ const list = ctx.list
185
+ .map((w, i) => ({ ...w, _ord: ord(w, i) }))
186
+ .sort((a, b) => b._ord - a._ord);
187
+
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
 
194
  if (!list.length) {
195
  const div = document.createElement("div");
 
200
  }
201
 
202
  const buttons = new Map();
203
+ list.forEach((w) => {
204
  const btn = document.createElement("button");
205
  btn.className = "ledger__entry";
206
  btn.innerHTML = `
207
+ <div class="ledger__epoch">epoch ${w.epoch ?? "—"}</div>
208
  <div class="ledger__wish"></div>
209
  <div class="ledger__epitaph"></div>`;
210
  btn.querySelector(".ledger__wish").textContent = `“${w.text}”`;
 
237
  pause: (v) => app.pause(v),
238
  stats: () => app.stats(),
239
  replay: (id) => buttons.get(id)?.click(),
240
+ descend: (lat, lon, duration = 13) => {
241
+ app.rig.setTerrain?.(app.world.terrain);
242
+ app.rig.descend(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { duration });
243
+ },
244
  setCamera: (theta, phi, dist) => {
245
  Object.assign(app.rig, {
246
  theta, thetaT: theta, phi, phiT: phi,
web/index.html CHANGED
@@ -51,6 +51,7 @@
51
  <div class="epitaph__rule"></div>
52
  <div class="epitaph__text" id="epitaphText"></div>
53
  <div class="epitaph__label" id="epitaphLabel">the wish is granted</div>
 
54
  </div>
55
 
56
  <!-- altar: wish input -->
 
51
  <div class="epitaph__rule"></div>
52
  <div class="epitaph__text" id="epitaphText"></div>
53
  <div class="epitaph__label" id="epitaphLabel">the wish is granted</div>
54
+ <button class="epitaph__descend" id="epitaphDescend" type="button" hidden>descend&ensp;↓</button>
55
  </div>
56
 
57
  <!-- altar: wish input -->
web/main.js CHANGED
@@ -20,6 +20,7 @@ const els = {
20
  epitaph: document.getElementById("epitaph"),
21
  epitaphText: document.getElementById("epitaphText"),
22
  epitaphLabel: document.getElementById("epitaphLabel"),
 
23
  queueChip: document.getElementById("queueChip"),
24
  form: document.getElementById("wishForm"),
25
  input: document.getElementById("wishInput"),
@@ -78,7 +79,11 @@ function showEpitaph(text, label = "the wish is granted") {
78
  els.epitaphLabel.textContent = label;
79
  els.epitaph.classList.add("is-shown");
80
  clearTimeout(epitaphTimer);
81
- epitaphTimer = setTimeout(() => els.epitaph.classList.remove("is-shown"), 6500);
 
 
 
 
82
  }
83
 
84
  function setEpoch(epoch, granted = epoch) {
@@ -122,6 +127,27 @@ function callTarget(call) {
122
  return null;
123
  }
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  // ---------------------------------------------------------------- boot
126
  let app;
127
  try {
@@ -132,6 +158,16 @@ try {
132
  throw err;
133
  }
134
 
 
 
 
 
 
 
 
 
 
 
135
  const featuresByWish = (features, upto) => {
136
  if (upto == null) return features;
137
  const order = [];
@@ -241,6 +277,8 @@ async function bootLive() {
241
  tickerClear();
242
  readingPhase = true;
243
  tickerLive(m.text);
 
 
244
  break;
245
  case "thought_token":
246
  tickerPush(m.text, readingPhase ? "reading" : "thought");
@@ -255,8 +293,12 @@ async function bootLive() {
255
  if (m.feature) {
256
  stateVersion = stateVersion == null ? null : stateVersion + 1;
257
  const cue = app.world.applyFeature(m.feature, { animate: true, t: app.simTime });
258
- if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4.5 });
 
 
259
  else if (cue.wide) app.rig.gaze(null, { hold: 4 });
 
 
260
  }
261
  break;
262
  }
@@ -264,7 +306,13 @@ async function bootLive() {
264
  showEpitaph(m.epitaph || "it is made");
265
  if (m.epoch != null) setEpoch(m.epoch, m.epoch);
266
  app.world.sky.flash(0.35);
267
- app.rig.release();
 
 
 
 
 
 
268
  tickerIdle();
269
  els.status.textContent = "live · the god is listening";
270
  break;
@@ -348,16 +396,23 @@ async function bootMock() {
348
  },
349
  delta: (e) => {
350
  const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime });
351
- if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4 });
352
  else if (cue.wide) app.rig.gaze(null, { hold: 3.5 });
 
 
353
  },
354
  granted: (e) => {
355
  showEpitaph(e.epitaph);
356
  setEpoch(baseEpoch + 1, baseEpoch + 1);
357
  app.world.sky.flash(0.35);
358
- app.rig.release();
 
 
 
 
 
359
  tickerIdle();
360
- cooldown = 10; // dwell on the granted world before the loop resets
361
  },
362
  });
363
  let phase = "reading";
@@ -366,6 +421,8 @@ async function bootMock() {
366
 
367
  function startFeed(customText) {
368
  phase = "reading";
 
 
369
  const trace = customText ? { ...FEED_WISH.trace, text: customText } : FEED_WISH.trace;
370
  runner.start(buildWishScript(trace, FEED_WISH.features), app.simTime);
371
  }
@@ -446,6 +503,16 @@ window.__godseed = {
446
  app.rig.gaze(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { hold });
447
  },
448
  gazeWide: () => app.rig.release(),
 
 
 
 
 
 
 
 
 
 
449
  setSky: (p, stars = 0.8, moons = 1) => app.world.sky.set(p, stars, moons, { animate: false }),
450
  setWeather: (k, i = 0.6) => app.world.weather.set(k, i, { animate: false }),
451
  setCamera: (theta, phi, dist) => {
 
20
  epitaph: document.getElementById("epitaph"),
21
  epitaphText: document.getElementById("epitaphText"),
22
  epitaphLabel: document.getElementById("epitaphLabel"),
23
+ epitaphDescend: document.getElementById("epitaphDescend"),
24
  queueChip: document.getElementById("queueChip"),
25
  form: document.getElementById("wishForm"),
26
  input: document.getElementById("wishInput"),
 
79
  els.epitaphLabel.textContent = label;
80
  els.epitaph.classList.add("is-shown");
81
  clearTimeout(epitaphTimer);
82
+ // hold long enough for the eye to find the "descend ↓" affordance
83
+ epitaphTimer = setTimeout(() => {
84
+ els.epitaph.classList.remove("is-shown");
85
+ if (els.epitaphDescend) els.epitaphDescend.hidden = true;
86
+ }, 9000);
87
  }
88
 
89
  function setEpoch(epoch, granted = epoch) {
 
127
  return null;
128
  }
129
 
130
+ // The direction on the surface a feature landed at (for the descent target).
131
+ // Sky/weather have no place, so they don't count toward "where to visit".
132
+ function featureDir(feature) {
133
+ const a = feature?.args || {};
134
+ if (feature?.tool === "set_sky" || feature?.tool === "set_weather") return null;
135
+ if (a.path && a.path.length) return latLonToDir(a.path[0][0], a.path[0][1]);
136
+ if (a.lat != null && a.lon != null) return latLonToDir(a.lat, a.lon);
137
+ return null;
138
+ }
139
+
140
+ // "descend ↓" affordance on the epitaph card → swoop down over the wish site.
141
+ function armDescent(dir) {
142
+ if (!els.epitaphDescend) return;
143
+ if (dir) {
144
+ lastDescentDir = dir.clone();
145
+ els.epitaphDescend.hidden = false;
146
+ } else if (!lastDescentDir) {
147
+ els.epitaphDescend.hidden = true;
148
+ }
149
+ }
150
+
151
  // ---------------------------------------------------------------- boot
152
  let app;
153
  try {
 
158
  throw err;
159
  }
160
 
161
+ // last surface direction a wish landed at — the descent target.
162
+ let lastDescentDir = null;
163
+ if (els.epitaphDescend) {
164
+ els.epitaphDescend.addEventListener("click", () => {
165
+ if (!lastDescentDir) return;
166
+ app.rig.setTerrain?.(app.world.terrain); // clear live terrain analytically
167
+ app.rig.descend(lastDescentDir.clone().multiplyScalar(PLANET_R));
168
+ });
169
+ }
170
+
171
  const featuresByWish = (features, upto) => {
172
  if (upto == null) return features;
173
  const order = [];
 
277
  tickerClear();
278
  readingPhase = true;
279
  tickerLive(m.text);
280
+ lastDescentDir = null;
281
+ if (els.epitaphDescend) els.epitaphDescend.hidden = true;
282
  break;
283
  case "thought_token":
284
  tickerPush(m.text, readingPhase ? "reading" : "thought");
 
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);
301
+ if (dir) lastDescentDir = dir.clone().normalize();
302
  }
303
  break;
304
  }
 
306
  showEpitaph(m.epitaph || "it is made");
307
  if (m.epoch != null) setEpoch(m.epoch, m.epoch);
308
  app.world.sky.flash(0.35);
309
+ armDescent(lastDescentDir);
310
+ // hold a beat on the wish site, THEN release to the wide shot
311
+ if (lastDescentDir && !app.rig.descending) {
312
+ app.rig.gaze(lastDescentDir.clone().multiplyScalar(PLANET_R), { hold: 3.6 });
313
+ } else {
314
+ app.rig.release();
315
+ }
316
  tickerIdle();
317
  els.status.textContent = "live · the god is listening";
318
  break;
 
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);
402
+ if (dir) lastDescentDir = dir.clone().normalize();
403
  },
404
  granted: (e) => {
405
  showEpitaph(e.epitaph);
406
  setEpoch(baseEpoch + 1, baseEpoch + 1);
407
  app.world.sky.flash(0.35);
408
+ armDescent(lastDescentDir);
409
+ if (lastDescentDir && !app.rig.descending) {
410
+ app.rig.gaze(lastDescentDir.clone().multiplyScalar(PLANET_R), { hold: 3.6 });
411
+ } else {
412
+ app.rig.release();
413
+ }
414
  tickerIdle();
415
+ cooldown = 12; // dwell on the granted world (descend affordance) before reset
416
  },
417
  });
418
  let phase = "reading";
 
421
 
422
  function startFeed(customText) {
423
  phase = "reading";
424
+ lastDescentDir = null;
425
+ if (els.epitaphDescend) els.epitaphDescend.hidden = true;
426
  const trace = customText ? { ...FEED_WISH.trace, text: customText } : FEED_WISH.trace;
427
  runner.start(buildWishScript(trace, FEED_WISH.features), app.simTime);
428
  }
 
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 });
509
+ },
510
+ descendLast: (duration = 13) => {
511
+ if (!lastDescentDir) return false;
512
+ app.rig.setTerrain?.(app.world.terrain);
513
+ app.rig.descend(lastDescentDir.clone().multiplyScalar(PLANET_R), { duration });
514
+ return true;
515
+ },
516
  setSky: (p, stars = 0.8, moons = 1) => app.world.sky.set(p, stars, moons, { animate: false }),
517
  setWeather: (k, i = 0.6) => app.world.weather.set(k, i, { animate: false }),
518
  setCamera: (theta, phi, dist) => {
web/mock/feed.js CHANGED
@@ -2,49 +2,65 @@
2
  // on a loop: reading tokens → tool_calls → world_deltas → granted. The world
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
  export const FEED_WISH = {
7
  trace: {
8
- wish_id: "w_000011",
9
- text: "let the storm find its way home",
10
  moderation: { allowed: true, category: null },
11
  reading:
12
- "A storm, asked home. Storms are not strays they are rivers that lost their bed. " +
13
- "I will raise high ground in the cold northwest for it to circle, light grass beneath " +
14
- "so it can see where it lands, and then I will whistle.",
15
  turns: [
16
  {
17
- thought: "First, a place worth striking. Raise the storm-fells: high, broken, proud.",
18
- call: { tool: "raise_terrain", args: { lat: 48, lon: -150, radius_deg: 16, height: 0.075, roughness: 0.85 } },
19
- observation: "ok: mountains risen at (48,-150)",
20
  },
21
  {
22
- thought: "Grass that glows storm-blue, so the lightning has a mirror.",
23
- call: { tool: "spawn_flora", args: { lat: 46, lon: -144, radius_deg: 9, kind: "glowgrass", density: 0.7, hue: 185 } },
24
- observation: "ok: flora seeded at (46,-144)",
25
  },
26
  {
27
- thought: "Now the whistle. Come home.",
28
- call: { tool: "set_weather", args: { kind: "storm", intensity: 0.8 } },
29
- observation: "ok: weather set to storm",
30
  },
31
  {
32
- thought: "And a ring of words above, so it never forgets the way again. No — so it always finds it.",
33
- call: { tool: "inscribe_wish", args: { text: "every storm is a wish with thunder in it", style: "orbit" } },
34
- observation: "ok: words set in orbit",
35
  },
36
- { thought: "It rains where I wanted it to rain. That is the whole of godhood.", call: null, observation: "" },
 
 
 
 
 
 
 
 
 
 
37
  ],
38
- epitaph: "The storm came home, and the hills wear it well.",
39
- feature_ids: ["f_000028", "f_000029", "f_000030", "f_000031"],
40
  model: "nemotron-3-nano (mock)",
41
  backend: "mock",
42
- ms_total: 52840,
43
  },
44
  features: [
45
- { id: "f_000028", wish_id: "w_000011", tool: "raise_terrain", args: { lat: 48, lon: -150, radius_deg: 16, height: 0.075, roughness: 0.85 }, seed: 1011863426, t: 1718201100.0 },
46
- { id: "f_000029", wish_id: "w_000011", tool: "spawn_flora", args: { lat: 46, lon: -144, radius_deg: 9, kind: "glowgrass", density: 0.7, hue: 185 }, seed: 1263075092, t: 1718201103.0 },
47
- { id: "f_000030", wish_id: "w_000011", tool: "set_weather", args: { kind: "storm", intensity: 0.8 }, seed: 1380036270, t: 1718201106.0 },
48
- { id: "f_000031", wish_id: "w_000011", tool: "inscribe_wish", args: { text: "every storm is a wish with thunder in it", style: "orbit" }, seed: 625385016, t: 1718201109.0 },
 
 
49
  ],
50
  };
 
2
  // on a loop: reading tokens → tool_calls → world_deltas → granted. The world
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
  };
web/mock/world.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "world": {
3
- "version": 28,
4
- "epoch": 10,
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},
@@ -40,11 +40,21 @@
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
  },
46
  "queue": { "length": 0, "current": null },
47
  "wishes_recent": [
 
48
  {"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},
49
  {"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},
50
  {"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},
 
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},
 
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},
web/planet/camera.js CHANGED
@@ -1,14 +1,24 @@
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.
 
 
 
4
  import * as THREE from "three";
5
- import { PLANET_R, clamp, lerp } from "./util.js";
6
 
7
  const WIDE_DIST = PLANET_R * 3.3;
8
  const GAZE_DIST = PLANET_R * 2.35;
9
  const MIN_DIST = PLANET_R * 1.45;
10
  const MAX_DIST = PLANET_R * 7.0;
11
 
 
 
 
 
 
 
 
12
  export class CameraRig {
13
  constructor(camera, dom) {
14
  this.camera = camera;
@@ -23,13 +33,20 @@ export class CameraRig {
23
  this.idleSpeed = 0.05;
24
  this.userHold = 0; // seconds left of user-control override
25
  this.gazeHold = 0; // seconds left of god's-gaze hold
 
 
26
  this._bind(dom);
27
  }
28
 
 
 
 
29
  _bind(dom) {
30
  if (!dom) return;
31
  let dragging = false, px = 0, py = 0;
32
  dom.addEventListener("pointerdown", (e) => {
 
 
33
  dragging = true; px = e.clientX; py = e.clientY;
34
  dom.setPointerCapture?.(e.pointerId);
35
  this.userHold = 6;
@@ -47,24 +64,33 @@ export class CameraRig {
47
  dom.addEventListener("pointercancel", end);
48
  dom.addEventListener("wheel", (e) => {
49
  e.preventDefault();
 
50
  this.distT = clamp(this.distT * (1 + e.deltaY * 0.0011), MIN_DIST, MAX_DIST);
51
  this.userHold = 6;
52
  }, { passive: false });
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  }
54
 
55
  /**
56
  * Ease toward a world point (the demo money-shot). `point` null ⇒ wide shot.
57
  */
58
  gaze(point, { hold = 4.5 } = {}) {
59
- if (this.userHold > 0) return; // a human hand is on the camera don't fight it
60
  if (point) {
61
  const dir = point.clone().normalize();
62
- let th = Math.atan2(dir.z, dir.x) + 0.32; // feature sits just off-center
63
- const ph = clamp(Math.acos(clamp(dir.y, -1, 1)) - 0.14, 0.3, Math.PI - 0.3);
64
- // unwrap to nearest revolution so we take the short way around
65
- const tau = Math.PI * 2;
66
- while (th - this.theta > Math.PI) th -= tau;
67
- while (th - this.theta < -Math.PI) th += tau;
68
  this.thetaT = th;
69
  this.phiT = ph;
70
  this.distT = GAZE_DIST;
@@ -78,12 +104,119 @@ export class CameraRig {
78
 
79
  /** Drift back to the slow contemplative orbit. */
80
  release() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  this.gazeHold = 0;
 
 
 
 
 
 
 
82
  this.distT = WIDE_DIST;
83
  this.lookT.set(0, 0, 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  }
85
 
86
  update(dt) {
 
 
87
  if (this.userHold > 0) this.userHold -= dt;
88
  if (this.gazeHold > 0) {
89
  this.gazeHold -= dt;
@@ -108,3 +241,27 @@ export class CameraRig {
108
  this.camera.lookAt(this.look);
109
  }
110
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
18
+ const GLIDE_ARC = 0.42; // radians swept over the target (~24°)
19
+ const PHI_FLOOR = 0.26;
20
+ const PHI_CEIL = Math.PI - 0.26;
21
+
22
  export class CameraRig {
23
  constructor(camera, dom) {
24
  this.camera = camera;
 
33
  this.idleSpeed = 0.05;
34
  this.userHold = 0; // seconds left of user-control override
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;
47
  dom.addEventListener("pointerdown", (e) => {
48
+ // a deliberate click during a descent is the "take me back" gesture
49
+ if (this.descent && !dragging) this.cancelDescent();
50
  dragging = true; px = e.clientX; py = e.clientY;
51
  dom.setPointerCapture?.(e.pointerId);
52
  this.userHold = 6;
 
64
  dom.addEventListener("pointercancel", end);
65
  dom.addEventListener("wheel", (e) => {
66
  e.preventDefault();
67
+ if (this.descent) this.cancelDescent();
68
  this.distT = clamp(this.distT * (1 + e.deltaY * 0.0011), MIN_DIST, MAX_DIST);
69
  this.userHold = 6;
70
  }, { passive: false });
71
+ // ESC returns to orbit (camera liturgy, not a trap)
72
+ this._onKey = (e) => { if (e.key === "Escape" && this.descent) this.cancelDescent(); };
73
+ window.addEventListener("keydown", this._onKey);
74
+ }
75
+
76
+ /** Spherical angles (theta, phi) that frame a unit direction off-center. */
77
+ _anglesFor(dir) {
78
+ let th = Math.atan2(dir.z, dir.x) + 0.32; // feature sits just off-center
79
+ const ph = clamp(Math.acos(clamp(dir.y, -1, 1)) - 0.14, 0.3, Math.PI - 0.3);
80
+ const tau = Math.PI * 2;
81
+ while (th - this.theta > Math.PI) th -= tau;
82
+ while (th - this.theta < -Math.PI) th += tau;
83
+ return { th, ph };
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;
95
  this.phiT = ph;
96
  this.distT = GAZE_DIST;
 
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
+ /**
114
+ * Cinematic descent: swoop from orbit down to just above the surface over
115
+ * `targetDir`, glide a slow arc with gentle look-ahead, then rise back to the
116
+ * orbit framing. `targetDir` is a (not necessarily unit) world direction —
117
+ * typically a granted wish's last landed feature. Sim-time deterministic.
118
+ */
119
+ descend(targetDir, { duration = 13 } = {}) {
120
+ if (!targetDir) return;
121
+ const dir = targetDir.clone().normalize();
122
+ // a tangent basis around the target so the glide sweeps along the surface
123
+ const up = Math.abs(dir.y) > 0.93 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
124
+ const east = new THREE.Vector3().crossVectors(up, dir).normalize();
125
+ const north = new THREE.Vector3().crossVectors(dir, east).normalize();
126
+ // approach from a little "south & up" of the target, glide toward "north"
127
+ const start = dir.clone()
128
+ .multiplyScalar(Math.cos(GLIDE_ARC * 0.9))
129
+ .addScaledVector(north, -Math.sin(GLIDE_ARC * 0.9))
130
+ .normalize();
131
+ const end = dir.clone()
132
+ .multiplyScalar(Math.cos(GLIDE_ARC * 1.1))
133
+ .addScaledVector(north, Math.sin(GLIDE_ARC * 1.1))
134
+ .normalize();
135
+ this.descent = {
136
+ dir, north, start, end,
137
+ t: 0,
138
+ duration,
139
+ // phase fractions: dive → glide → rise
140
+ dive: 0.34, glide: 0.40,
141
+ };
142
  this.gazeHold = 0;
143
+ this.userHold = 0;
144
+ }
145
+
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
+
157
+ get descending() { return !!this.descent; }
158
+
159
+ /** Eye distance that keeps us safely above any terrain along the descent dir. */
160
+ _groundClear(dir) {
161
+ let h = 0;
162
+ if (this._terrain?.heightAt) {
163
+ try { h = this._terrain.heightAt(dir) || 0; } catch { h = 0; }
164
+ }
165
+ // ride a hair above the highest plausible relief beneath us
166
+ return Math.max(DESCENT_DIST, PLANET_R + h + 0.10 * PLANET_R);
167
+ }
168
+
169
+ _updateDescent(dt) {
170
+ const d = this.descent;
171
+ d.t += dt;
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;
184
+ const dir = slerp(d.start, d.end, k);
185
+ dist = this._groundClear(dir);
186
+ const ahead = slerp(d.start, d.end, Math.min(1, k + 0.16)); // gentle look-ahead
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();
199
+ // keep the eye slightly behind the look target so we look forward/down
200
+ const phi = clamp(Math.acos(clamp(eye.y, -1, 1)), PHI_FLOOR, PHI_CEIL);
201
+ const theta = Math.atan2(eye.z, eye.x);
202
+ this.theta = theta; this.thetaT = theta;
203
+ this.phi = phi; this.phiT = phi;
204
+ this.dist = dist; this.distT = dist;
205
+ const sp = Math.sin(phi);
206
+ this.camera.position.set(
207
+ dist * sp * Math.cos(theta),
208
+ dist * Math.cos(phi),
209
+ dist * sp * Math.sin(theta)
210
+ );
211
+ this.look.copy(lookDir).multiplyScalar(lookR);
212
+ this.lookT.copy(this.look);
213
+ this.camera.lookAt(this.look);
214
+ if (u >= 1) this.cancelDescent();
215
  }
216
 
217
  update(dt) {
218
+ if (this.descent) { this._updateDescent(dt); return; }
219
+
220
  if (this.userHold > 0) this.userHold -= dt;
221
  if (this.gazeHold > 0) {
222
  this.gazeHold -= dt;
 
241
  this.camera.lookAt(this.look);
242
  }
243
  }
244
+
245
+ // --- local helpers (sim-time pure) ------------------------------------------
246
+
247
+ /** Spherical interpolation between two unit vectors (short way). */
248
+ function slerp(a, b, t) {
249
+ const dot = clamp(a.dot(b), -1, 1);
250
+ const omega = Math.acos(dot);
251
+ if (omega < 1e-4) return a.clone();
252
+ const so = Math.sin(omega);
253
+ return a.clone().multiplyScalar(Math.sin((1 - t) * omega) / so)
254
+ .addScaledVector(b, Math.sin(t * omega) / so)
255
+ .normalize();
256
+ }
257
+
258
+ /**
259
+ * On the rise, pull the eye up and away from the surface dir toward a higher
260
+ * latitude so we re-frame the whole planet, not the ground we just skimmed.
261
+ */
262
+ function slerpToOrbit(surfaceDir, k) {
263
+ const up = new THREE.Vector3(0, 1, 0);
264
+ // a vantage offset from the surface dir, lifted toward the pole for the wide
265
+ const high = surfaceDir.clone().lerp(up, 0.34).normalize();
266
+ return slerp(surfaceDir, high, easeInOutCubic(k));
267
+ }
web/planet/life.js ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // GODSEED — life. Moving inhabitants for the spawn_life tool (#9): carts that
2
+ // glide along the nearest stream, birds that circuit low over the land, and
3
+ // fireflies that drift near the ground. Every flock is an InstancedMesh driven
4
+ // by a deterministic seeded path sampled at tick(simTime) — no rAF, no Date,
5
+ // no per-frame accumulation — so replay and headless capture are byte-stable.
6
+ import * as THREE from "three";
7
+ import { mulberry32 } from "./rng.js";
8
+ import {
9
+ PLANET_R, DEG, clamp, easeOutCubic, randomDirInCap, slerpDirs, hsl, angleBetween,
10
+ } from "./util.js";
11
+
12
+ const REVEAL = 1.6; // fade-in of a fresh flock
13
+
14
+ // A closed great-circle-ish loop of unit directions around `center`, used as
15
+ // the fallback path for carts and the flight path for birds. Deterministic.
16
+ function seededLoop(center, radiusRad, rng, n = 48, wobble = 0.35) {
17
+ const t1 = new THREE.Vector3(0, 1, 0);
18
+ if (Math.abs(center.y) > 0.93) t1.set(1, 0, 0);
19
+ const t2 = new THREE.Vector3().crossVectors(center, t1).normalize();
20
+ t1.crossVectors(t2, center).normalize();
21
+ const rad = radiusRad * (0.45 + rng() * 0.3);
22
+ const phase = rng() * Math.PI * 2;
23
+ const lobe = 1 + Math.floor(rng() * 2); // 1 or 2 lobes → oval / peanut loops
24
+ const dir = new THREE.Vector3();
25
+ const pts = [];
26
+ for (let i = 0; i < n; i++) {
27
+ const a = (i / n) * Math.PI * 2;
28
+ const r = rad * (1 + wobble * Math.sin(a * (lobe + 1) + phase) * 0.5);
29
+ dir.copy(center).multiplyScalar(Math.cos(r))
30
+ .addScaledVector(t2, Math.sin(r) * Math.cos(a + phase))
31
+ .addScaledVector(t1, Math.sin(r) * Math.sin(a + phase))
32
+ .normalize();
33
+ pts.push(dir.clone());
34
+ }
35
+ return pts;
36
+ }
37
+
38
+ // Resample a polyline of unit dirs into an arc-length-even closed loop so motion
39
+ // reads at constant speed regardless of how the waypoints were spaced.
40
+ function evenLoop(points, samples = 80) {
41
+ if (points.length < 2) return points.slice();
42
+ const segLen = [];
43
+ let total = 0;
44
+ for (let i = 0; i < points.length; i++) {
45
+ const a = points[i], b = points[(i + 1) % points.length];
46
+ const l = Math.max(1e-5, angleBetween(a, b));
47
+ segLen.push(l);
48
+ total += l;
49
+ }
50
+ const out = [];
51
+ const step = total / samples;
52
+ let seg = 0, acc = 0, target = 0;
53
+ const tmp = new THREE.Vector3();
54
+ for (let s = 0; s < samples; s++) {
55
+ while (acc + segLen[seg] < target && seg < points.length - 1) { acc += segLen[seg]; seg++; }
56
+ const local = clamp((target - acc) / segLen[seg], 0, 1);
57
+ slerpDirs(points[seg], points[(seg + 1) % points.length], local, tmp);
58
+ out.push(tmp.clone());
59
+ target += step;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ class Flock {
65
+ constructor(scene, terrain, water, f, t0, animate) {
66
+ const a = f.args;
67
+ this.f = f;
68
+ this.t0 = t0;
69
+ this.animate = animate;
70
+ this.terrain = terrain;
71
+ this.kind = ["carts", "birds", "fireflies"].includes(a.kind) ? a.kind : "fireflies";
72
+ this.center = new THREE.Vector3();
73
+ const la = clamp(a.lat ?? 0, -90, 90) * DEG, lo = (a.lon ?? 0) * DEG;
74
+ this.center.set(Math.cos(la) * Math.cos(lo), Math.sin(la), Math.cos(la) * Math.sin(lo));
75
+ this.radiusRad = clamp(a.radius_deg ?? 6, 1, 20) * DEG;
76
+ this.count = clamp(Math.round(a.count ?? 6), 1, 12);
77
+ this.hue = a.hue ?? 40;
78
+ const rng = mulberry32(f.seed);
79
+ this.rng = rng;
80
+ this.group = new THREE.Group();
81
+ scene.add(this.group);
82
+ this.scene = scene;
83
+
84
+ if (this.kind === "carts") this._buildCarts(rng, water);
85
+ else if (this.kind === "birds") this._buildBirds(rng);
86
+ else this._buildFireflies(rng);
87
+ }
88
+
89
+ // ---- carts: lantern sprites gliding along the nearest stream ------------
90
+ _buildCarts(rng, water) {
91
+ let path = water && water.streamPathNear
92
+ ? water.streamPathNear(this.center, this.radiusRad + 6 * DEG)
93
+ : null;
94
+ if (path && path.length >= 2) {
95
+ // an open stream → close it into a there-and-back loop so carts glide the
96
+ // length of the river and return, instead of teleporting end-to-start.
97
+ const back = path.slice(1, -1).reverse().map((d) => d.clone());
98
+ path = path.concat(back);
99
+ } else {
100
+ path = seededLoop(this.center, this.radiusRad, rng, 48, 0.25);
101
+ }
102
+ this.path = evenLoop(path, 96);
103
+ const color = hsl(this.hue, 0.85, 0.62);
104
+ const geo = new THREE.SphereGeometry(0.013, 7, 6);
105
+ this.mat = new THREE.MeshBasicMaterial({ color: color.clone().multiplyScalar(1.9) });
106
+ this.mesh = new THREE.InstancedMesh(geo, this.mat, this.count);
107
+ this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
108
+ this.mesh.frustumCulled = false;
109
+ this.group.add(this.mesh);
110
+ this.agents = [];
111
+ for (let i = 0; i < this.count; i++) {
112
+ this.agents.push({
113
+ offset: i / this.count + (rng() - 0.5) * 0.01, // spaced along the path
114
+ speed: 0.018 + rng() * 0.008, // loops/sec along the path
115
+ dir2: rng() < 0.5 ? 1 : -1, // some carts travel each way
116
+ bob: rng() * 6.28,
117
+ size: 0.8 + rng() * 0.5,
118
+ });
119
+ }
120
+ }
121
+
122
+ // ---- birds: a small flock circuiting low over the land -----------------
123
+ _buildBirds(rng) {
124
+ this.path = evenLoop(seededLoop(this.center, this.radiusRad, rng, 56, 0.4), 96);
125
+ this.altitude = 0.05 + rng() * 0.04; // world units above the surface
126
+ const color = hsl(this.hue, 0.55, 0.66);
127
+ // a tiny shallow chevron — flat double triangle reads as a bird at distance
128
+ const geo = new THREE.ConeGeometry(0.009, 0.02, 3);
129
+ geo.rotateX(Math.PI / 2);
130
+ geo.scale(1, 0.35, 1);
131
+ this.mat = new THREE.MeshBasicMaterial({ color: color.clone().multiplyScalar(1.5) });
132
+ this.mesh = new THREE.InstancedMesh(geo, this.mat, this.count);
133
+ this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
134
+ this.mesh.frustumCulled = false;
135
+ this.group.add(this.mesh);
136
+ this.agents = [];
137
+ for (let i = 0; i < this.count; i++) {
138
+ this.agents.push({
139
+ offset: i / this.count * 0.18 + (rng() - 0.5) * 0.03, // tight V, slight spread
140
+ speed: 0.05 + rng() * 0.02,
141
+ bob: rng() * 6.28,
142
+ bobAmp: 0.012 + rng() * 0.01,
143
+ lateral: (rng() - 0.5) * 0.03,
144
+ size: 0.85 + rng() * 0.4,
145
+ });
146
+ }
147
+ }
148
+
149
+ // ---- fireflies: drifting glow points near the ground -------------------
150
+ _buildFireflies(rng) {
151
+ const n = this.count * 3; // a small drifting cloud of discrete lamps
152
+ const color = hsl(this.hue, 0.9, 0.62);
153
+ const geo = new THREE.SphereGeometry(0.0042, 6, 5);
154
+ this.mat = new THREE.MeshBasicMaterial({
155
+ color: color.clone().multiplyScalar(2.0), transparent: true, opacity: 1,
156
+ blending: THREE.AdditiveBlending, depthWrite: false,
157
+ });
158
+ this.mesh = new THREE.InstancedMesh(geo, this.mat, n);
159
+ this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
160
+ this.mesh.instanceColor = null;
161
+ this.mesh.frustumCulled = false;
162
+ this.group.add(this.mesh);
163
+ this.agents = [];
164
+ for (let i = 0; i < n; i++) {
165
+ const home = randomDirInCap(this.center, this.radiusRad, rng, new THREE.Vector3());
166
+ this.agents.push({
167
+ home,
168
+ // each firefly hovers in a small slow Lissajous around its home dir
169
+ ax: 0.004 + rng() * 0.006, az: 0.004 + rng() * 0.006,
170
+ fx: 0.18 + rng() * 0.3, fz: 0.18 + rng() * 0.3,
171
+ px: rng() * 6.28, pz: rng() * 6.28,
172
+ lift: 0.02 + rng() * 0.05,
173
+ blink: rng() * 6.28, blinkSpeed: 1.2 + rng() * 2.0,
174
+ size: 0.8 + rng() * 0.7,
175
+ });
176
+ }
177
+ }
178
+
179
+ // tangent basis cached lazily for cheap lateral offsets along a path
180
+ _basisAt(dir, out1, out2) {
181
+ out1.set(0, 1, 0);
182
+ if (Math.abs(dir.y) > 0.93) out1.set(1, 0, 0);
183
+ out2.crossVectors(dir, out1).normalize();
184
+ out1.crossVectors(out2, dir).normalize();
185
+ }
186
+
187
+ _pathPoint(u, out) {
188
+ const n = this.path.length;
189
+ const f = ((u % 1) + 1) % 1 * n;
190
+ const i = Math.floor(f);
191
+ slerpDirs(this.path[i], this.path[(i + 1) % n], f - i, out);
192
+ return out.normalize();
193
+ }
194
+
195
+ update(dt, t) {
196
+ const reveal = this.animate ? easeOutCubic((t - this.t0) / REVEAL) : 1;
197
+ if (this.kind === "fireflies") this._updateFireflies(t, reveal);
198
+ else this._updatePath(t, reveal);
199
+ }
200
+
201
+ _updatePath(t, reveal) {
202
+ const m = new THREE.Matrix4(), p = new THREE.Vector3(), q = new THREE.Quaternion();
203
+ const fwd = new THREE.Vector3(), nextDir = new THREE.Vector3(), curDir = new THREE.Vector3();
204
+ const up = new THREE.Vector3(), s = new THREE.Vector3();
205
+ const t1 = new THREE.Vector3(), t2 = new THREE.Vector3();
206
+ const isBird = this.kind === "birds";
207
+ for (let i = 0; i < this.agents.length; i++) {
208
+ const ag = this.agents[i];
209
+ const u = ag.offset + t * ag.speed * (ag.dir2 ?? 1);
210
+ this._pathPoint(u, curDir);
211
+ this._pathPoint(u + 0.012 * (ag.dir2 ?? 1), nextDir);
212
+ // lateral spread off the path centreline (birds spread into a flock)
213
+ if (ag.lateral) {
214
+ this._basisAt(curDir, t1, t2);
215
+ curDir.addScaledVector(t2, ag.lateral).normalize();
216
+ }
217
+ const ground = this.terrain.heightAt(curDir);
218
+ const bob = isBird ? Math.sin(t * 2.2 + ag.bob) * ag.bobAmp : Math.sin(t * 1.4 + ag.bob) * 0.004;
219
+ const alt = isBird ? this.altitude + bob : ground + 0.012 + bob;
220
+ p.copy(curDir).multiplyScalar(PLANET_R + (isBird ? ground + alt : alt));
221
+ // orient along travel, with local up = surface normal
222
+ up.copy(curDir);
223
+ fwd.copy(nextDir).sub(curDir).normalize();
224
+ if (fwd.lengthSq() < 1e-8) fwd.crossVectors(up, new THREE.Vector3(1, 0, 0)).normalize();
225
+ const right = t1.crossVectors(fwd, up).normalize();
226
+ fwd.crossVectors(up, right).normalize();
227
+ const basis = new THREE.Matrix4().makeBasis(right, up, fwd);
228
+ q.setFromRotationMatrix(basis);
229
+ const sc = Math.max(0.0001, ag.size * reveal);
230
+ s.setScalar(sc);
231
+ m.compose(p, q, s);
232
+ this.mesh.setMatrixAt(i, m);
233
+ }
234
+ this.mesh.instanceMatrix.needsUpdate = true;
235
+ }
236
+
237
+ _updateFireflies(t, reveal) {
238
+ const m = new THREE.Matrix4(), p = new THREE.Vector3(), dir = new THREE.Vector3();
239
+ const q = new THREE.Quaternion();
240
+ const t1 = new THREE.Vector3(), t2 = new THREE.Vector3();
241
+ const s = new THREE.Vector3();
242
+ for (let i = 0; i < this.agents.length; i++) {
243
+ const ag = this.agents[i];
244
+ this._basisAt(ag.home, t1, t2);
245
+ const ox = Math.sin(t * ag.fx + ag.px) * ag.ax;
246
+ const oz = Math.cos(t * ag.fz + ag.pz) * ag.az;
247
+ dir.copy(ag.home).addScaledVector(t1, ox).addScaledVector(t2, oz).normalize();
248
+ const ground = this.terrain.heightAt(dir);
249
+ const lift = ag.lift + Math.sin(t * 0.6 + ag.px) * 0.01;
250
+ p.copy(dir).multiplyScalar(PLANET_R + ground + lift);
251
+ // blink: sharp pulse so most fireflies are dark at any instant → twinkle
252
+ const pulse = Math.max(0, Math.sin(t * ag.blinkSpeed + ag.blink));
253
+ const blink = 0.12 + 0.88 * pulse * pulse;
254
+ const sc = Math.max(0.0001, ag.size * blink * reveal);
255
+ s.setScalar(sc);
256
+ m.compose(p, q, s);
257
+ this.mesh.setMatrixAt(i, m);
258
+ }
259
+ this.mesh.instanceMatrix.needsUpdate = true;
260
+ }
261
+
262
+ // re-seat fireflies / re-evaluate paths when terrain under them shifts.
263
+ reseat() {
264
+ // paths are sampled against heightAt() every frame, so motion already
265
+ // follows the moving terrain; nothing to rebuild. (Hook kept for symmetry.)
266
+ }
267
+
268
+ dispose() {
269
+ this.group.removeFromParent();
270
+ this.mesh.geometry.dispose();
271
+ this.mat.dispose();
272
+ this.mesh.dispose();
273
+ }
274
+ }
275
+
276
+ export class Life {
277
+ constructor(scene, terrain, water) {
278
+ this.scene = scene;
279
+ this.terrain = terrain;
280
+ this.water = water;
281
+ this.flocks = [];
282
+ }
283
+
284
+ /** Apply a spawn_life feature. @returns the anchor direction (camera target). */
285
+ addFeature(f, { animate = true, t = 0 } = {}) {
286
+ const flock = new Flock(this.scene, this.terrain, this.water, f, t, animate);
287
+ this.flocks.push(flock);
288
+ return flock.center;
289
+ }
290
+
291
+ update(dt, t) {
292
+ for (const fl of this.flocks) fl.update(dt, t);
293
+ }
294
+
295
+ reseat(center, radiusRad) {
296
+ for (const fl of this.flocks) {
297
+ if (angleBetween(fl.center, center) < fl.radiusRad + radiusRad + 0.12) fl.reseat();
298
+ }
299
+ }
300
+
301
+ dispose() {
302
+ for (const fl of this.flocks) fl.dispose();
303
+ this.flocks = [];
304
+ }
305
+ }
web/planet/structures.js CHANGED
@@ -249,6 +249,212 @@ function buildArch(rng, color) {
249
  };
250
  }
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  const BUILDERS = {
253
  lighthouse: buildLighthouse,
254
  monolith: buildMonolith,
@@ -256,6 +462,9 @@ const BUILDERS = {
256
  village: buildVillage,
257
  beacon: buildBeacon,
258
  arch: buildArch,
 
 
 
259
  };
260
 
261
  // ---------------------------------------------------------------- manager
@@ -276,8 +485,12 @@ class Structure {
276
  this.built = builder(rng, color, { terrain, center: this.center });
277
  this.yaw = rng() * Math.PI * 2;
278
  this.group = this.built.group;
 
279
  scene.add(this.group);
280
  this.growing = animate;
 
 
 
281
  this.seat();
282
  }
283
 
@@ -305,6 +518,12 @@ class Structure {
305
  if (t - this.t0 > 1.0) { this.growing = false; this.group.scale.setScalar(this.scaleArg); }
306
  }
307
  this.built.update?.(dt, t);
 
 
 
 
 
 
308
  }
309
 
310
  dispose() {
 
249
  };
250
  }
251
 
252
+ // ---------------------------------------------------------------- city kit
253
+ // tower | warehouse | cafe — the "building games" set. Same diorama soul:
254
+ // low-poly stone/wood bodies, emissive lit windows, seeded variation, the
255
+ // window planes instanced per-structure where there are many of them.
256
+
257
+ function litWindowMesh(rng, color, rects, bright = 1.9) {
258
+ // one InstancedMesh of unit window planes; rects = [{x,y,z,ry,w,h,phase,speed}]
259
+ const geo = new THREE.PlaneGeometry(1, 1);
260
+ const mat = glow(color, bright);
261
+ const mesh = new THREE.InstancedMesh(geo, mat, rects.length);
262
+ mesh.frustumCulled = false;
263
+ const m = new THREE.Matrix4(), p = new THREE.Vector3(), q = new THREE.Quaternion();
264
+ const e = new THREE.Euler(), s = new THREE.Vector3();
265
+ rects.forEach((r, i) => {
266
+ e.set(0, r.ry, 0);
267
+ q.setFromEuler(e);
268
+ p.set(r.x, r.y, r.z);
269
+ s.set(r.w, r.h, 1);
270
+ m.compose(p, q, s);
271
+ mesh.setMatrixAt(i, m);
272
+ });
273
+ mesh.instanceMatrix.needsUpdate = true;
274
+ return { mesh, mat, rects, _ownGeo: geo };
275
+ }
276
+
277
+ function buildTower(rng, color, ctx) {
278
+ const g = new THREE.Group();
279
+ const tiers = 3 + Math.floor(rng() * 2); // 3-4 stepped tiers
280
+ const baseW = 0.05 + rng() * 0.014;
281
+ const tierH = 0.072 + rng() * 0.02;
282
+ let y = 0, w = baseW;
283
+ const rects = [];
284
+ for (let tr = 0; tr < tiers; tr++) {
285
+ const d = w * (0.82 + rng() * 0.12);
286
+ const body = new THREE.Mesh(new THREE.BoxGeometry(w, tierH, d), tr % 2 ? STONE : STONE_DARK);
287
+ body.position.y = y + tierH / 2;
288
+ body.rotation.y = tr === 0 ? rng() * 0.4 : 0;
289
+ g.add(body);
290
+ // dense lit windows on all four faces, gridded by tier
291
+ const cols = 3, rows = 3;
292
+ for (const [nx, nz, ry, half] of [[0, 1, 0, d], [0, -1, Math.PI, d], [1, 0, Math.PI / 2, w], [-1, 0, -Math.PI / 2, w]]) {
293
+ for (let c = 0; c < cols; c++) {
294
+ for (let rr = 0; rr < rows; rr++) {
295
+ if (rng() < 0.18) continue; // a few dark windows
296
+ const u = (c + 0.5) / cols - 0.5;
297
+ const wx = nx !== 0 ? nx * (w / 2 + 0.0007) : u * w * 0.82;
298
+ const wz = nz !== 0 ? nz * (d / 2 + 0.0007) : u * d * 0.82;
299
+ rects.push({
300
+ x: wx, y: y + (rr + 0.5) / rows * tierH, z: wz, ry,
301
+ w: 0.0078, h: 0.011, phase: rng() * 6.28, speed: 2 + rng() * 5,
302
+ });
303
+ }
304
+ }
305
+ }
306
+ y += tierH;
307
+ w *= 0.74 + rng() * 0.08;
308
+ }
309
+ // crown + slow-pulsing rooftop light (the "big building" signature)
310
+ const capMat = glow(color, 1.6);
311
+ const cap = new THREE.Mesh(new THREE.CylinderGeometry(w * 0.32, w * 0.42, 0.012, 6), STONE_DARK);
312
+ cap.position.y = y + 0.006;
313
+ g.add(cap);
314
+ const beaconMat = glow(color, 2.0);
315
+ const beacon = new THREE.Mesh(new THREE.OctahedronGeometry(0.011, 0), beaconMat);
316
+ beacon.position.y = y + 0.026;
317
+ g.add(beacon);
318
+ const light = new THREE.PointLight(color.getHex(), 0.6, 1.0, 1.9);
319
+ light.position.y = y + 0.026;
320
+ g.add(light);
321
+ const wins = litWindowMesh(rng, color, rects, 1.85);
322
+ g.add(wins.mesh);
323
+ g.add(groundHalo(color, 0.07, 0.16));
324
+ const phase = rng() * 6.28;
325
+ return {
326
+ group: g,
327
+ accents: [capMat, beaconMat, wins.mat],
328
+ ownGeo: [wins._ownGeo],
329
+ update: (dt, t) => {
330
+ const pulse = 1.5 + Math.sin(t * 1.1 + phase) * 0.5;
331
+ beaconMat.color.copy(color).multiplyScalar(pulse);
332
+ light.intensity = 0.4 + Math.sin(t * 1.1 + phase) * 0.22;
333
+ // gentle flicker of the window block as a whole (cheap: tint the shared mat)
334
+ wins.mat.color.copy(color).multiplyScalar(1.55 + Math.sin(t * 2.3 + phase) * 0.22);
335
+ },
336
+ };
337
+ }
338
+
339
+ function buildWarehouse(rng, color, ctx) {
340
+ const g = new THREE.Group();
341
+ const len = 0.12 + rng() * 0.05;
342
+ const wide = 0.05 + rng() * 0.014;
343
+ const wallH = 0.03 + rng() * 0.008;
344
+ const yaw = rng() * Math.PI;
345
+ const hall = new THREE.Group();
346
+ hall.rotation.y = yaw;
347
+ // long low body
348
+ const body = new THREE.Mesh(new THREE.BoxGeometry(len, wallH, wide), WOOD);
349
+ body.position.y = wallH / 2;
350
+ hall.add(body);
351
+ // gabled roof — a long triangular prism via a scaled, rotated box pair
352
+ const roofH = 0.022 + rng() * 0.006;
353
+ for (const s of [1, -1]) {
354
+ const slope = new THREE.Mesh(new THREE.BoxGeometry(len, 0.003, wide * 0.62), ROOF);
355
+ slope.position.set(0, wallH + roofH / 2, s * wide * 0.25);
356
+ slope.rotation.x = s * Math.atan2(roofH, wide * 0.5);
357
+ hall.add(slope);
358
+ }
359
+ const ridge = new THREE.Mesh(new THREE.BoxGeometry(len, 0.004, 0.004), STONE_DARK);
360
+ ridge.position.y = wallH + roofH;
361
+ hall.add(ridge);
362
+ // one big glowing door on a gable end
363
+ const doorMat = glow(color, 1.7);
364
+ const door = new THREE.Mesh(new THREE.PlaneGeometry(wide * 0.5, wallH * 0.78), doorMat);
365
+ door.position.set(len / 2 + 0.0008, wallH * 0.39, 0);
366
+ door.rotation.y = Math.PI / 2;
367
+ hall.add(door);
368
+ const doorLight = new THREE.PointLight(color.getHex(), 0.5, 0.5, 2.0);
369
+ doorLight.position.set(len / 2 + 0.02, wallH * 0.4, 0);
370
+ hall.add(doorLight);
371
+ // sparse small windows down the long sides
372
+ const rects = [];
373
+ const n = 3 + Math.floor(rng() * 3);
374
+ for (let i = 0; i < n; i++) {
375
+ const side = i % 2 ? 1 : -1;
376
+ const u = ((i + 0.5) / n - 0.5);
377
+ rects.push({
378
+ x: u * len * 0.82, y: wallH * 0.6, z: side * (wide / 2 + 0.0007),
379
+ ry: side > 0 ? 0 : Math.PI, w: 0.009, h: 0.009, phase: rng() * 6.28, speed: 2 + rng() * 3,
380
+ });
381
+ }
382
+ const wins = litWindowMesh(rng, color, rects, 1.5);
383
+ hall.add(wins.mesh);
384
+ g.add(hall);
385
+ g.add(groundHalo(color, 0.075, 0.14));
386
+ const phase = rng() * 6.28;
387
+ return {
388
+ group: g,
389
+ accents: [doorMat, wins.mat],
390
+ ownGeo: [wins._ownGeo],
391
+ update: (dt, t) => {
392
+ doorMat.color.copy(color).multiplyScalar(1.45 + Math.sin(t * 1.6 + phase) * 0.28);
393
+ doorLight.intensity = 0.4 + Math.sin(t * 1.6 + phase) * 0.16;
394
+ wins.mat.color.copy(color).multiplyScalar(1.3 + Math.sin(t * 3.4 + phase) * 0.2);
395
+ },
396
+ };
397
+ }
398
+
399
+ function buildCafe(rng, color, ctx) {
400
+ const g = new THREE.Group();
401
+ const w = 0.04 + rng() * 0.01, d = 0.034 + rng() * 0.01, h = 0.026 + rng() * 0.006;
402
+ const yaw = rng() * Math.PI * 2;
403
+ const box = new THREE.Group();
404
+ box.rotation.y = yaw;
405
+ const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), WOOD);
406
+ body.position.y = h / 2;
407
+ box.add(body);
408
+ const roof = new THREE.Mesh(new THREE.BoxGeometry(w * 1.05, 0.004, d * 1.05), ROOF);
409
+ roof.position.y = h + 0.002;
410
+ box.add(roof);
411
+ // warm front window + door, hue-tinted glow
412
+ const warm = color.clone().lerp(new THREE.Color("#ffcf8a"), 0.45);
413
+ const glassMat = glow(warm, 1.7);
414
+ const glass = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.66, h * 0.5), glassMat);
415
+ glass.position.set(0, h * 0.5, d / 2 + 0.0008);
416
+ box.add(glass);
417
+ // striped awning plane jutting over the front
418
+ const awningMat = new THREE.MeshStandardMaterial({ color: warm.clone().multiplyScalar(0.6), roughness: 0.8, side: THREE.DoubleSide });
419
+ const awning = new THREE.Mesh(new THREE.PlaneGeometry(w * 1.04, d * 0.5), awningMat);
420
+ awning.position.set(0, h * 0.86, d / 2 + d * 0.22);
421
+ awning.rotation.x = -Math.PI / 2 + 0.5;
422
+ box.add(awning);
423
+ // spill of warm light on the ground in front of the door
424
+ const spill = new THREE.Mesh(new THREE.CircleGeometry(0.05, 20), beamMat(warm, 0.4));
425
+ spill.rotation.x = -Math.PI / 2;
426
+ spill.position.set(0, 0.004, d * 0.55);
427
+ spill.scale.set(1, 1.5, 1); // stretch the pool of light outward from the door
428
+ box.add(spill);
429
+ const spillLight = new THREE.PointLight(warm.getHex(), 0.55, 0.45, 1.9);
430
+ spillLight.position.set(0, 0.02, d * 0.5);
431
+ box.add(spillLight);
432
+ // 2-3 tiny table dots outside under the awning
433
+ const tableMat = glow(warm, 1.3);
434
+ const tables = [];
435
+ const nt = 2 + Math.floor(rng() * 2);
436
+ for (let i = 0; i < nt; i++) {
437
+ const tx = (rng() - 0.5) * w * 0.9;
438
+ const tz = d * 0.55 + rng() * 0.018;
439
+ const table = new THREE.Mesh(new THREE.CylinderGeometry(0.0035, 0.0035, 0.008, 6), tableMat);
440
+ table.position.set(tx, 0.004, tz);
441
+ box.add(table);
442
+ tables.push(table);
443
+ }
444
+ g.add(box);
445
+ const phase = rng() * 6.28;
446
+ return {
447
+ group: g,
448
+ accents: [glassMat],
449
+ update: (dt, t) => {
450
+ const flick = 1.4 + Math.sin(t * 2.6 + phase) * 0.18 + Math.sin(t * 7.3 + phase) * 0.06;
451
+ glassMat.color.copy(warm).multiplyScalar(flick);
452
+ spillLight.intensity = 0.46 + Math.sin(t * 2.6 + phase) * 0.12;
453
+ spill.material.opacity = 0.32 + Math.sin(t * 2.6 + phase) * 0.08;
454
+ },
455
+ };
456
+ }
457
+
458
  const BUILDERS = {
459
  lighthouse: buildLighthouse,
460
  monolith: buildMonolith,
 
462
  village: buildVillage,
463
  beacon: buildBeacon,
464
  arch: buildArch,
465
+ tower: buildTower,
466
+ warehouse: buildWarehouse,
467
+ cafe: buildCafe,
468
  };
469
 
470
  // ---------------------------------------------------------------- manager
 
485
  this.built = builder(rng, color, { terrain, center: this.center });
486
  this.yaw = rng() * Math.PI * 2;
487
  this.group = this.built.group;
488
+ this.color = color;
489
  scene.add(this.group);
490
  this.growing = animate;
491
+ // emissive "spawn pop": flash the structure's glow accents on placement so a
492
+ // new building is unmistakable the instant it lands (verify-fleet visibility).
493
+ this.flash = animate && this.built.accents ? 1 : 0;
494
  this.seat();
495
  }
496
 
 
518
  if (t - this.t0 > 1.0) { this.growing = false; this.group.scale.setScalar(this.scaleArg); }
519
  }
520
  this.built.update?.(dt, t);
521
+ // spawn pop: a fast emissive bloom on the glow accents, eased out over ~1.1s
522
+ if (this.flash > 0 && this.built.accents) {
523
+ this.flash = Math.max(0, this.flash - dt / 1.1);
524
+ const boost = 1 + this.flash * this.flash * 2.4;
525
+ for (const mat of this.built.accents) mat.color.multiplyScalar(boost);
526
+ }
527
  }
528
 
529
  dispose() {
web/planet/terrain.js CHANGED
@@ -3,10 +3,15 @@
3
  // New features lerp in over ~2s so the land visibly rises out of the dark.
4
  import * as THREE from "three";
5
  import { fbm3 } from "./rng.js";
6
- import { PLANET_R, DEG, clamp, easeInOutCubic, hsl, angleBetween } from "./util.js";
7
 
8
  const RISE_SECONDS = 2.2;
9
  const GLOBAL_RELIEF = 0.012; // faint seeded micro-relief so genesis isn't a billiard ball
 
 
 
 
 
10
 
11
  // ---------------------------------------------------------------- icosphere
12
  function makeIcosphere(detail) {
@@ -47,6 +52,42 @@ function makeIcosphere(detail) {
47
  return { dirs, index, count: verts.length };
48
  }
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  // ---------------------------------------------------------------- terrain
51
  export class Terrain {
52
  /**
@@ -61,6 +102,8 @@ export class Terrain {
61
  this.features = []; // {f, sign, center, radiusRad, h, rough, k}
62
  this.anims = []; // {rec, idx, val, t}
63
  this.tints = []; // flora tint sources {center, radiusRad, color, w}
 
 
64
  this._colorsDirty = true;
65
 
66
  // genesis micro-relief
@@ -153,6 +196,7 @@ export class Terrain {
153
  const sparse = { rec, idx: Uint32Array.from(idx), val: Float32Array.from(val), t: 0 };
154
  if (animate) {
155
  this.anims.push(sparse);
 
156
  } else {
157
  this._fold(sparse);
158
  }
@@ -173,11 +217,74 @@ export class Terrain {
173
  this._colorsDirty = true;
174
  }
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  get animating() { return this.anims.length > 0; }
177
 
178
  /** @returns list of {center, radiusRad} that changed this frame (for reseating). */
179
  update(dt) {
180
  const changed = [];
 
181
  if (this.anims.length) {
182
  const done = [];
183
  for (const a of this.anims) {
@@ -199,6 +306,26 @@ export class Terrain {
199
  return changed;
200
  }
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  _writeAll() {
203
  const pos = this.geometry.attributes.position.array;
204
  const d = this.dirs;
@@ -276,6 +403,12 @@ export class Terrain {
276
  }
277
 
278
  dispose() {
 
 
 
 
 
 
279
  this.mesh.removeFromParent();
280
  this.geometry.dispose();
281
  this.material.dispose();
 
3
  // New features lerp in over ~2s so the land visibly rises out of the dark.
4
  import * as THREE from "three";
5
  import { fbm3 } from "./rng.js";
6
+ import { PLANET_R, DEG, clamp, easeInOutCubic, easeOutCubic, hsl, angleBetween } from "./util.js";
7
 
8
  const RISE_SECONDS = 2.2;
9
  const GLOBAL_RELIEF = 0.012; // faint seeded micro-relief so genesis isn't a billiard ball
10
+ // Delta "landing bloom": a transient emissive cap that floods the new ground in
11
+ // biome light so wishes read at GIF scale (verify-fleet: deltas were invisible).
12
+ const BLOOM_MIN_RAD = 0.085; // never smaller than ~8% of the planet — always obvious
13
+ const BLOOM_SECONDS = 2.6; // ramp through the rise, then settle to a faint stain glow
14
+ const RAISE_HUE = 38, LOWER_HUE = 205; // warm uplift / cool basin — biome-coloured
15
 
16
  // ---------------------------------------------------------------- icosphere
17
  function makeIcosphere(detail) {
 
52
  return { dirs, index, count: verts.length };
53
  }
54
 
55
+ // ---------------------------------------------------------------- delta bloom
56
+ // Additive emissive spherical-cap that hugs the surface over the delta site.
57
+ // uReveal grows the lit disc; uGlow ramps its brightness during the landing.
58
+ function deltaBloomMat(color) {
59
+ return new THREE.ShaderMaterial({
60
+ transparent: true,
61
+ depthWrite: false,
62
+ blending: THREE.AdditiveBlending,
63
+ side: THREE.DoubleSide,
64
+ uniforms: {
65
+ uTime: { value: 0 },
66
+ uReveal: { value: 0 },
67
+ uGlow: { value: 0 },
68
+ uColor: { value: color },
69
+ },
70
+ vertexShader: /* glsl */ `
71
+ varying vec2 vUv;
72
+ void main(){ vUv = uv; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
73
+ fragmentShader: /* glsl */ `
74
+ varying vec2 vUv;
75
+ uniform float uTime, uReveal, uGlow;
76
+ uniform vec3 uColor;
77
+ void main(){
78
+ float r = length(vUv - 0.5) * 2.0;
79
+ if (r > uReveal) discard;
80
+ // bright shockwave ring at the leading edge, soft core inside
81
+ float ring = smoothstep(uReveal, uReveal - 0.16, r);
82
+ float core = smoothstep(1.0, 0.0, r);
83
+ float pulse = 0.78 + 0.22 * sin(uTime * 3.2 - r * 7.0);
84
+ float a = (0.30 * core + 0.95 * (1.0 - ring)) * uGlow * pulse;
85
+ vec3 c = uColor * (1.1 + 1.7 * (1.0 - ring));
86
+ gl_FragColor = vec4(c, a);
87
+ }`,
88
+ });
89
+ }
90
+
91
  // ---------------------------------------------------------------- terrain
92
  export class Terrain {
93
  /**
 
102
  this.features = []; // {f, sign, center, radiusRad, h, rough, k}
103
  this.anims = []; // {rec, idx, val, t}
104
  this.tints = []; // flora tint sources {center, radiusRad, color, w}
105
+ this.blooms = []; // transient landing-bloom caps {mesh, mat, t, dur}
106
+ this._scene = scene;
107
  this._colorsDirty = true;
108
 
109
  // genesis micro-relief
 
196
  const sparse = { rec, idx: Uint32Array.from(idx), val: Float32Array.from(val), t: 0 };
197
  if (animate) {
198
  this.anims.push(sparse);
199
+ this._spawnDeltaBloom(rec);
200
  } else {
201
  this._fold(sparse);
202
  }
 
217
  this._colorsDirty = true;
218
  }
219
 
220
+ /**
221
+ * Spawn a transient emissive cap over a landing terrain delta so the wish is
222
+ * unmistakable at GIF scale: a biome-coloured shockwave that floods the new
223
+ * ground, ramps bright through the rise (~2.6s), then fades to nothing while
224
+ * a faint permanent biome stain stays folded into the vertex colours.
225
+ */
226
+ _spawnDeltaBloom(rec) {
227
+ const radiusRad = Math.max(rec.radiusRad * 1.25, BLOOM_MIN_RAD);
228
+ const hue = rec.sign > 0 ? RAISE_HUE : LOWER_HUE;
229
+ const mat = deltaBloomMat(hsl(hue, 0.8, 0.6));
230
+ const mesh = new THREE.Mesh(this._capGeo(rec.center, radiusRad, 0.018), mat);
231
+ mesh.frustumCulled = false;
232
+ this._scene.add(mesh);
233
+ this.blooms.push({ mesh, mat, t: 0, dur: BLOOM_SECONDS, center: rec.center, radiusRad });
234
+ // a soft, lasting biome stain so the raised land never reads as flat obsidian
235
+ this.tints.push({ center: rec.center, radiusRad: rec.radiusRad, color: hsl(hue, 0.42, 0.21), w: 0.55 });
236
+ this._colorsDirty = true;
237
+ }
238
+
239
+ /** Spherical-cap disc grid hugging the planet, lifted `lift` above the surface. */
240
+ _capGeo(center, radiusRad, lift) {
241
+ const rings = 16, segs = 40;
242
+ const positions = [], uvs = [], index = [];
243
+ const dir = new THREE.Vector3(), t1 = new THREE.Vector3(), t2 = new THREE.Vector3();
244
+ t1.set(0, 1, 0);
245
+ if (Math.abs(center.y) > 0.93) t1.set(1, 0, 0);
246
+ t2.crossVectors(center, t1).normalize();
247
+ t1.crossVectors(t2, center).normalize();
248
+ for (let r = 0; r <= rings; r++) {
249
+ const ang = (r / rings) * radiusRad;
250
+ const n = r === 0 ? 1 : segs;
251
+ for (let s = 0; s < n; s++) {
252
+ const rot = (s / n) * Math.PI * 2;
253
+ dir.copy(center).multiplyScalar(Math.cos(ang))
254
+ .addScaledVector(t2, Math.sin(ang) * Math.cos(rot))
255
+ .addScaledVector(t1, Math.sin(ang) * Math.sin(rot))
256
+ .normalize();
257
+ const h = this.heightAt(dir);
258
+ const level = PLANET_R + h + lift;
259
+ positions.push(dir.x * level, dir.y * level, dir.z * level);
260
+ uvs.push(0.5 + (r / rings) * 0.5 * Math.cos(rot), 0.5 + (r / rings) * 0.5 * Math.sin(rot));
261
+ }
262
+ }
263
+ const ringStart = (r) => (r === 0 ? 0 : 1 + (r - 1) * segs);
264
+ for (let r = 0; r < rings; r++) {
265
+ for (let s = 0; s < segs; s++) {
266
+ const sn = (s + 1) % segs;
267
+ const a = r === 0 ? 0 : ringStart(r) + s;
268
+ const a2 = r === 0 ? 0 : ringStart(r) + sn;
269
+ const b = ringStart(r + 1) + s;
270
+ const b2 = ringStart(r + 1) + sn;
271
+ if (r === 0) index.push(0, b, b2);
272
+ else index.push(a, b, b2, a, b2, a2);
273
+ }
274
+ }
275
+ const geo = new THREE.BufferGeometry();
276
+ geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
277
+ geo.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
278
+ geo.setIndex(index);
279
+ return geo;
280
+ }
281
+
282
  get animating() { return this.anims.length > 0; }
283
 
284
  /** @returns list of {center, radiusRad} that changed this frame (for reseating). */
285
  update(dt) {
286
  const changed = [];
287
+ if (this.blooms.length) this._updateBlooms(dt);
288
  if (this.anims.length) {
289
  const done = [];
290
  for (const a of this.anims) {
 
306
  return changed;
307
  }
308
 
309
+ _updateBlooms(dt) {
310
+ this._bloomClock = (this._bloomClock ?? 0) + dt;
311
+ const done = [];
312
+ for (const b of this.blooms) {
313
+ b.t += dt;
314
+ const k = clamp(b.t / b.dur, 0, 1);
315
+ // reveal sweeps out fast, brightness ramps with the rise then fades out
316
+ b.mat.uniforms.uReveal.value = easeOutCubic(b.t / (b.dur * 0.42));
317
+ b.mat.uniforms.uGlow.value = Math.sin(Math.min(k, 1) * Math.PI) * 1.0;
318
+ b.mat.uniforms.uTime.value = this._bloomClock;
319
+ if (b.t >= b.dur) done.push(b);
320
+ }
321
+ for (const b of done) {
322
+ this.blooms.splice(this.blooms.indexOf(b), 1);
323
+ b.mesh.removeFromParent();
324
+ b.mesh.geometry.dispose();
325
+ b.mat.dispose();
326
+ }
327
+ }
328
+
329
  _writeAll() {
330
  const pos = this.geometry.attributes.position.array;
331
  const d = this.dirs;
 
403
  }
404
 
405
  dispose() {
406
+ for (const b of this.blooms) {
407
+ b.mesh.removeFromParent();
408
+ b.mesh.geometry.dispose();
409
+ b.mat.dispose();
410
+ }
411
+ this.blooms = [];
412
  this.mesh.removeFromParent();
413
  this.geometry.dispose();
414
  this.material.dispose();
web/planet/water.js CHANGED
@@ -234,6 +234,31 @@ export class Water {
234
  for (const w of this.items) w.update(dt, t);
235
  }
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  reseat(center, radiusRad) {
238
  for (const w of this.items) {
239
  if (angleBetween(w.center, center) < w.reachRad + radiusRad) w.reseat();
 
234
  for (const w of this.items) w.update(dt, t);
235
  }
236
 
237
+ /**
238
+ * Read-only: unit-dir waypoints of the nearest stream whose body comes within
239
+ * `maxRad` of `center`, else null. Used by life.js carts to glide along an
240
+ * existing river before falling back to a seeded loop. Deterministic.
241
+ */
242
+ streamPathNear(center, maxRad) {
243
+ let best = null, bestAng = Infinity;
244
+ for (const w of this.items) {
245
+ if (!(w instanceof Stream)) continue;
246
+ const ang = angleBetween(w.center, center);
247
+ if (ang - w.reachRad < maxRad && ang < bestAng) { bestAng = ang; best = w; }
248
+ }
249
+ if (!best) return null;
250
+ // densify the great-circle path between waypoints into a smooth dir list
251
+ const dirs = [];
252
+ for (let i = 0; i < best.waypoints.length - 1; i++) {
253
+ const a = best.waypoints[i], b = best.waypoints[i + 1];
254
+ const steps = Math.max(4, Math.ceil(angleBetween(a, b) / (3 * DEG)));
255
+ for (let s = i === 0 ? 0 : 1; s <= steps; s++) {
256
+ dirs.push(slerpDirs(a, b, s / steps, new THREE.Vector3()).clone());
257
+ }
258
+ }
259
+ return dirs.length >= 2 ? dirs : null;
260
+ }
261
+
262
  reseat(center, radiusRad) {
263
  for (const w of this.items) {
264
  if (angleBetween(w.center, center) < w.reachRad + radiusRad) w.reseat();
web/planet/world.js CHANGED
@@ -6,6 +6,7 @@ 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 { Weather } from "./weather.js";
10
  import { Sky } from "./sky.js";
11
  import { Inscriptions } from "./inscriptions.js";
@@ -27,6 +28,7 @@ export class World {
27
  this.flora = new Flora(this.scene, this.terrain, this.quality);
28
  this.structures = new Structures(this.scene, this.terrain);
29
  this.water = new Water(this.scene, this.terrain);
 
30
  this.inscriptions = new Inscriptions(this.scene, this.terrain);
31
  if (this._px) this.setPxScale(this._px);
32
  }
@@ -60,6 +62,9 @@ export class World {
60
  case "place_water":
61
  target = this.water.addFeature(f, { animate, t });
62
  break;
 
 
 
63
  case "set_weather":
64
  this.weather.set(f.args.kind, clamp(f.args.intensity ?? 0.5, 0, 1), { animate });
65
  wide = true;
@@ -85,6 +90,7 @@ export class World {
85
  this.flora.dispose();
86
  this.structures.dispose();
87
  this.water.dispose();
 
88
  this.inscriptions.dispose();
89
  this.terrain.dispose();
90
  this.features = [];
@@ -104,11 +110,13 @@ export class World {
104
  this.flora.reseat(c.center, c.radiusRad);
105
  this.structures.reseat(c.center, c.radiusRad);
106
  this.water.reseat(c.center, c.radiusRad);
 
107
  this.inscriptions.reseat(c.center, c.radiusRad);
108
  }
109
  this.flora.update(dt, t);
110
  this.structures.update(dt, t);
111
  this.water.update(dt, t);
 
112
  this.weather.update(dt, t);
113
  this.sky.update(dt, t);
114
  this.inscriptions.update(dt, t);
 
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";
11
  import { Sky } from "./sky.js";
12
  import { Inscriptions } from "./inscriptions.js";
 
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);
33
  if (this._px) this.setPxScale(this._px);
34
  }
 
62
  case "place_water":
63
  target = this.water.addFeature(f, { animate, t });
64
  break;
65
+ case "spawn_life":
66
+ target = this.life.addFeature(f, { animate, t });
67
+ break;
68
  case "set_weather":
69
  this.weather.set(f.args.kind, clamp(f.args.intensity ?? 0.5, 0, 1), { animate });
70
  wide = true;
 
90
  this.flora.dispose();
91
  this.structures.dispose();
92
  this.water.dispose();
93
+ this.life.dispose();
94
  this.inscriptions.dispose();
95
  this.terrain.dispose();
96
  this.features = [];
 
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);
121
  this.sky.update(dt, t);
122
  this.inscriptions.update(dt, t);
web/style.css CHANGED
@@ -110,10 +110,16 @@ html, body {
110
  /* ---------- the god speaks ---------- */
111
  .voice {
112
  position: fixed; left: 42px; bottom: 42px; z-index: 10;
113
- width: min(390px, calc(100vw - 84px));
114
  border-left: 1px solid var(--hairline);
115
- padding-left: 20px;
116
  pointer-events: none;
 
 
 
 
 
 
117
  }
118
  .voice__head {
119
  display: flex; align-items: baseline; gap: 10px;
@@ -138,26 +144,29 @@ html, body {
138
  }
139
  .voice__stream {
140
  font-family: var(--mono);
141
- font-size: 12.5px;
142
- line-height: 1.75;
143
- color: var(--ivory-dim);
144
- max-height: 168px;
 
 
145
  overflow: hidden;
146
  display: flex;
147
  flex-direction: column;
148
  justify-content: flex-end;
149
  white-space: pre-wrap;
150
  word-break: break-word;
 
151
  mask-image: linear-gradient(to bottom, transparent, black 28%);
152
  -webkit-mask-image: linear-gradient(to bottom, transparent, black 28%);
153
  }
154
  .voice__innertext { display: inline-block; white-space: pre-wrap; }
155
  .voice__stream .tone-reading { color: var(--ivory); }
156
- .voice__stream .tone-call { color: var(--gold); }
157
- .voice__stream .tone-obs { color: var(--violet); opacity: 0.85; }
158
  .voice__caret {
159
- display: inline-block; width: 7px; height: 13px;
160
- background: var(--gold); vertical-align: -2px; margin-left: 2px;
161
  animation: caret 1.1s steps(2) infinite;
162
  }
163
  @keyframes caret { 50% { opacity: 0; } }
@@ -191,6 +200,31 @@ html, body {
191
  font-size: 10px; letter-spacing: 0.34em; text-transform: uppercase;
192
  color: var(--gold);
193
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  /* ---------- altar (wish input) ---------- */
196
  .altar {
@@ -289,6 +323,19 @@ html, body {
289
  color: var(--ivory-faint);
290
  }
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  .ledger {
293
  position: fixed; top: 168px; bottom: 36px; left: 42px; z-index: 10;
294
  width: min(360px, calc(100vw - 64px));
@@ -353,7 +400,8 @@ html, body {
353
  @media (max-width: 760px) {
354
  .almanac { right: 20px; top: 28px; }
355
  .voice { left: 20px; bottom: 130px; width: calc(100vw - 40px); max-width: none; }
356
- .voice__stream { max-height: 96px; font-size: 11.5px; }
 
357
  .altar { bottom: 18px; }
358
  .status { display: none; }
359
  .brand { top: 22px; }
 
110
  /* ---------- the god speaks ---------- */
111
  .voice {
112
  position: fixed; left: 42px; bottom: 42px; z-index: 10;
113
+ width: min(440px, calc(100vw - 84px));
114
  border-left: 1px solid var(--hairline);
115
+ padding: 16px 20px 16px 20px;
116
  pointer-events: none;
117
+ /* a subtle dark scrim so the god's voice reads over the bright planet rim —
118
+ the ticker is the second-largest text while granting and must not wash out */
119
+ background: linear-gradient(105deg, rgba(4, 5, 9, 0.72), rgba(4, 5, 9, 0.32) 72%, transparent);
120
+ border-radius: 2px;
121
+ -webkit-backdrop-filter: blur(3px);
122
+ backdrop-filter: blur(3px);
123
  }
124
  .voice__head {
125
  display: flex; align-items: baseline; gap: 10px;
 
144
  }
145
  .voice__stream {
146
  font-family: var(--mono);
147
+ /* second-largest text while granting — readable on the live screenshots */
148
+ font-size: 18px;
149
+ line-height: 1.62;
150
+ letter-spacing: 0.01em;
151
+ color: rgba(236, 229, 216, 0.78);
152
+ max-height: 232px;
153
  overflow: hidden;
154
  display: flex;
155
  flex-direction: column;
156
  justify-content: flex-end;
157
  white-space: pre-wrap;
158
  word-break: break-word;
159
+ text-shadow: 0 1px 10px rgba(2, 3, 6, 0.65);
160
  mask-image: linear-gradient(to bottom, transparent, black 28%);
161
  -webkit-mask-image: linear-gradient(to bottom, transparent, black 28%);
162
  }
163
  .voice__innertext { display: inline-block; white-space: pre-wrap; }
164
  .voice__stream .tone-reading { color: var(--ivory); }
165
+ .voice__stream .tone-call { color: var(--gold-bright); font-weight: 400; }
166
+ .voice__stream .tone-obs { color: var(--violet); opacity: 0.92; }
167
  .voice__caret {
168
+ display: inline-block; width: 8px; height: 17px;
169
+ background: var(--gold); vertical-align: -3px; margin-left: 2px;
170
  animation: caret 1.1s steps(2) infinite;
171
  }
172
  @keyframes caret { 50% { opacity: 0; } }
 
200
  font-size: 10px; letter-spacing: 0.34em; text-transform: uppercase;
201
  color: var(--gold);
202
  }
203
+ .epitaph__descend[hidden] { display: none; }
204
+ .epitaph__descend {
205
+ display: inline-block;
206
+ margin-top: 20px;
207
+ padding: 8px 22px;
208
+ font-family: var(--serif);
209
+ font-style: italic;
210
+ font-size: 17px;
211
+ letter-spacing: 0.05em;
212
+ color: var(--gold);
213
+ background: rgba(7, 8, 14, 0.5);
214
+ border: 1px solid var(--hairline);
215
+ border-radius: 999px;
216
+ cursor: pointer;
217
+ pointer-events: auto;
218
+ -webkit-backdrop-filter: blur(6px);
219
+ backdrop-filter: blur(6px);
220
+ transition: color 0.25s, border-color 0.25s, box-shadow 0.25s, transform 0.25s;
221
+ }
222
+ .epitaph__descend:hover {
223
+ color: var(--gold-bright);
224
+ border-color: var(--gold);
225
+ box-shadow: 0 0 28px rgba(217, 179, 108, 0.18);
226
+ transform: translateY(-1px);
227
+ }
228
 
229
  /* ---------- altar (wish input) ---------- */
230
  .altar {
 
323
  color: var(--ivory-faint);
324
  }
325
 
326
+ /* a left-side dark gradient scrim so the log column reads over the bright
327
+ planet rim + gold trail — sits behind the entries, fades to clear on the
328
+ right so the planet still breathes. */
329
+ .ledger-scrim {
330
+ position: fixed; left: 0; top: 0; bottom: 0; z-index: 9;
331
+ width: min(560px, 56vw);
332
+ pointer-events: none;
333
+ background: linear-gradient(to right,
334
+ rgba(3, 4, 8, 0.82) 0%,
335
+ rgba(3, 4, 8, 0.66) 34%,
336
+ rgba(3, 4, 8, 0.34) 62%,
337
+ transparent 100%);
338
+ }
339
  .ledger {
340
  position: fixed; top: 168px; bottom: 36px; left: 42px; z-index: 10;
341
  width: min(360px, calc(100vw - 64px));
 
400
  @media (max-width: 760px) {
401
  .almanac { right: 20px; top: 28px; }
402
  .voice { left: 20px; bottom: 130px; width: calc(100vw - 40px); max-width: none; }
403
+ .voice__stream { max-height: 118px; font-size: 15px; line-height: 1.5; }
404
+ .ledger-scrim { width: 100vw; }
405
  .altar { bottom: 18px; }
406
  .status { display: none; }
407
  .brand { top: 22px; }