"""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