godseed / engine /summary.py
AndresCarreon's picture
Town Mode: close town view by default, build_district + place_road, bank/market/house, grow-one-town steering, behold-the-world reveal, tamed needle
b0d758d verified
Raw
History Blame Contribute Delete
7.42 kB
"""Compact world description for the LLM prompt (<600 chars, always).
Covers: the town (name + center + building tally), epoch, terrain count +
notable coords, flora kinds, structures with coords, current sky/weather, last
2 epitaphs. Duck-types over World so this module stays import-free of
engine.world (no cycles).
"""
from __future__ import annotations
import math
import zlib
from typing import Any
MAX_LEN = 599 # contract: strictly under 600 chars
_NOTABLE_TERRAIN = 2
_MAX_FLORA_KINDS = 6
_MAX_STRUCTURES = 4
_EPITAPH_CLIP = 70
# --------------------------------------------------------------------------
# Town anchor — the seeded-weighted centroid of the built world (v1.2)
# --------------------------------------------------------------------------
#
# townCenter is a CROSS-COMPONENT contract: the JS renderer's camera computes
# the SAME value to frame the default TOWN view, so this math must be matched
# byte-for-byte. It is the unit-vector mean of every non-genesis place_structure
# + build_district direction, renormalized back to (lat, lon). With nothing
# built yet it falls back to the genesis monolith.
# The genesis monolith (f_000002) — the town's seed when nothing else is built.
GENESIS_MONOLITH = (14.0, 38.0)
# Town-anchoring tools (their lat/lon define where the town sits).
_TOWN_TOOLS = ("place_structure", "build_district")
# A curated name pool; crc32(round(lat), round(lon)) selects one deterministically.
TOWN_NAMES: tuple[str, ...] = (
"Lowmere", "Ashford", "Tessen", "Harrowfen", "Mossgate", "Brighthollow",
"Stilwater", "Emberlyn", "Caldmoor", "Vesper", "Thornwick", "Glasswend",
"Dunmarrow", "Sorrel", "Wyndholt", "Pellan",
)
def town_center(world: Any) -> tuple[float, float]:
"""Return the town anchor (lat, lon) for a world.
The unit-vector mean of all non-genesis ``place_structure`` +
``build_district`` directions, renormalized to a (lat, lon) pair. Falls back
to the genesis monolith at (14, 38) when nothing has been built. The result
lat/lon are rounded to 4 places (stable across the engine/renderer split).
"""
features = list(getattr(world, "features", ()) or ())
x = y = z = 0.0
n = 0
for f in features:
if f.wish_id == "genesis" or f.tool not in _TOWN_TOOLS:
continue
lat = f.args.get("lat")
lon = f.args.get("lon")
if lat is None or lon is None:
continue
rlat = math.radians(float(lat))
rlon = math.radians(float(lon))
cos_lat = math.cos(rlat)
x += cos_lat * math.cos(rlon)
y += cos_lat * math.sin(rlon)
z += math.sin(rlat)
n += 1
if n == 0:
return GENESIS_MONOLITH
norm = math.sqrt(x * x + y * y + z * z)
if norm == 0.0: # antipodal directions cancel; fall back rather than divide by zero
return GENESIS_MONOLITH
x, y, z = x / norm, y / norm, z / norm
lat = math.degrees(math.asin(max(-1.0, min(1.0, z))))
lon = math.degrees(math.atan2(y, x))
return round(lat, 4), round(lon, 4)
def town_name(lat: float, lon: float) -> str:
"""Deterministic town name from its rounded center (crc32 -> curated pool)."""
key = f"{round(lat)},{round(lon)}".encode("utf-8")
return TOWN_NAMES[zlib.crc32(key) % len(TOWN_NAMES)]
def _coord(args: dict) -> str:
lat, lon = args.get("lat"), args.get("lon")
if lat is None or lon is None:
path = args.get("path") or []
if path and isinstance(path[0], (list, tuple)) and len(path[0]) >= 2:
lat, lon = path[0][0], path[0][1]
if lat is None or lon is None:
return ""
return f"({round(float(lat))},{round(float(lon))})"
def _clip(text: str, limit: int) -> str:
text = str(text)
return text if len(text) <= limit else text[: limit - 3] + "..."
_A_OR_AN_VOWEL = "aeiou"
_MAX_TALLY_KINDS = 5 # cap the building tally so the line stays short
def _count_phrase(noun: str, n: int) -> str:
"""'3 towers' / 'a cafe' (singular gets an article)."""
if n == 1:
article = "an" if noun[:1].lower() in _A_OR_AN_VOWEL else "a"
return f"{article} {noun}"
plural = noun + ("es" if noun.endswith(("s", "x", "z", "ch", "sh")) else "s")
return f"{n} {plural}"
def _town_tally(features: list) -> str:
"""A short building/house tally, e.g. '3 towers, a cafe, 2 districts'."""
counts: dict[str, int] = {}
order: list[str] = []
for f in features:
if f.wish_id == "genesis":
continue
if f.tool == "place_structure":
kind = f.args.get("kind", "building")
elif f.tool == "build_district":
kind = "district"
else:
continue
if kind not in counts:
order.append(kind)
counts[kind] = counts.get(kind, 0) + 1
if not counts:
return ""
bits = [_count_phrase(kind, counts[kind]) for kind in order[:_MAX_TALLY_KINDS]]
if len(order) > _MAX_TALLY_KINDS:
bits.append("more")
return ", ".join(bits)
def summarize(world: Any) -> str:
features = list(getattr(world, "features", ()) or ())
# The town — where the god should keep building (v1.2 town mode).
lat, lon = town_center(world)
name = town_name(lat, lon)
tally = _town_tally(features)
town_line = f"The town of {name} stands near ({round(lat)},{round(lon)})"
town_line += f": {tally}." if tally else "; nothing built yet — start it here."
parts: list[str] = [town_line, f"epoch {getattr(world, 'epoch', 0)}"]
# terrain
raised = [f for f in features if f.tool == "raise_terrain"]
lowered = [f for f in features if f.tool == "lower_terrain"]
terrain = f"terrain: {len(raised)} risen, {len(lowered)} carved"
notable = [_coord(f.args) for f in raised[-_NOTABLE_TERRAIN:] if _coord(f.args)]
if notable:
terrain += " at " + ",".join(notable)
parts.append(terrain)
# flora kinds (unique, first-seen order)
kinds: list[str] = []
for f in features:
if f.tool == "spawn_flora":
kind = f.args.get("kind")
if kind and kind not in kinds:
kinds.append(kind)
parts.append("flora: " + (", ".join(kinds[:_MAX_FLORA_KINDS]) if kinds else "none"))
# structures with coords (most recent few)
structures = [f for f in features if f.tool == "place_structure"]
if structures:
bits = [f"{f.args.get('kind', '?')}{_coord(f.args)}" for f in structures[-_MAX_STRUCTURES:]]
extra = len(structures) - _MAX_STRUCTURES
tail = f" +{extra} more" if extra > 0 else ""
parts.append("structures: " + ", ".join(bits) + tail)
else:
parts.append("structures: none")
# sky / weather (last-write-wins views)
sky = getattr(world, "sky", None)
if sky:
moons = sky.get("moons", 0)
parts.append(f"sky: {sky.get('palette', '?')}, {moons} moon{'s' if moons != 1 else ''}")
weather = getattr(world, "weather", None)
if weather:
parts.append(f"weather: {weather.get('kind', 'clear')}")
# last 2 epitaphs
epitaphs = list(getattr(world, "epitaphs", ()) or ())[-2:]
if epitaphs:
quoted = " / ".join(f"\"{_clip(e, _EPITAPH_CLIP)}\"" for e in epitaphs)
parts.append("last words: " + quoted)
text = " | ".join(parts)
if len(text) > MAX_LEN:
text = text[: MAX_LEN - 3] + "..."
return text