"""NEMOCITY placement — deterministic, NEVER fails, Python-only. The LLM gives {kind, name, near, floors, hue}; this module turns it into exact cells: anchor resolution from the "near" text, a scored candidate search over an expanding Chebyshev radius, and an auto-routed road connector when the chosen footprint has no frontage. Output is partial event dicts ({"tool", "args"}) — world.apply assigns id/seed/t. All randomness is crc32-seeded from (wish_id, indices); no wall clock here. """ from __future__ import annotations import re import zlib from collections import deque from typing import Any, Optional from . import constants as C from .city import WATER_CELLS, Building, Cell, CityState, in_bounds, neighbors4 from .tools import street_name_for _CENTER_WORDS = frozenset(("center", "centre", "downtown", "middle", "heart")) _RIVER_WORDS = frozenset(("river", "water", "waterfront", "riverside", "shore")) _STREET_SUFFIXES = frozenset(("st", "ave", "rd", "ln", "blvd", "way", "ct", "row")) _STOP = frozenset(("the", "a", "an", "of", "near", "by", "next", "to", "at", "in", "on")) _R_MAX = 2 * C.GRID # covers the whole grid from any anchor def _u01(key: str) -> float: return (zlib.crc32(key.encode()) & 0xFFFFFFFF) / 0xFFFFFFFF def _tokens(text: str) -> list[str]: return [ t for t in re.findall(r"[a-z0-9']+", str(text or "").lower()) if t not in _STOP ] def _round_cell(x: float, z: float) -> Cell: cx = min(max(int(round(x)), C.COORD_MIN), C.COORD_MAX) cz = min(max(int(round(z)), C.COORD_MIN), C.COORD_MAX) return (cx, cz) def resolve_anchor(city: CityState, near: Any) -> Cell: """Fuzzy anchor: named building > street > center > river > kind word > centroid of all buildings > (0,0).""" text = str(near or "").strip().lower() toks = _tokens(text) if toks: for b in reversed(city.buildings): name_toks = set(_tokens(b.name)) if name_toks and (set(toks) & name_toks): return _round_cell(*b.centroid) for name, cells in city.street_cells.items(): low = name.lower() name_toks = set(_tokens(low)) - _STREET_SUFFIXES if low in text or (name_toks and set(toks) & name_toks): return cells[len(cells) // 2] if set(toks) & _CENTER_WORDS: return (0, 0) if set(toks) & _RIVER_WORDS: developed = set(city.roads) | set(city.building_cells) banks = sorted( (c for c in developed if any(n in WATER_CELLS for n in neighbors4(c))), key=lambda c: (max(abs(c[0]), abs(c[1])), c[1], c[0]), ) if banks: return banks[0] for t in toks: kind = t if t in C.BUILDINGS else C.SYNONYMS.get(t) if kind: for b in reversed(city.buildings): if b.kind == kind: return _round_cell(*b.centroid) if city.buildings: xs = [b.centroid[0] for b in city.buildings] zs = [b.centroid[1] for b in city.buildings] return _round_cell(sum(xs) / len(xs), sum(zs) / len(zs)) return (0, 0) # ----------------------------------------------------------------- connectors def _road_distance_field(city: CityState, max_dist: int) -> dict[Cell, int]: """Multi-source BFS over EMPTY cells from the road network. dist=1 means 'this empty cell is adjacent to a road' (= a 1-cell connector).""" dist: dict[Cell, int] = {} queue: deque[Cell] = deque() seeds = set() for road_cell in city.roads: for n in neighbors4(road_cell): if city.is_empty(n) and n not in seeds: seeds.add(n) for cell in sorted(seeds, key=lambda c: (c[1], c[0])): dist[cell] = 1 queue.append(cell) while queue: cell = queue.popleft() d = dist[cell] if d >= max_dist: continue for n in neighbors4(cell): if n not in dist and city.is_empty(n): dist[n] = d + 1 queue.append(n) return dist def _connector_path( city: CityState, footprint: set[Cell], max_len: int ) -> Optional[list[Cell]]: """Shortest Manhattan path of NEW road cells from the footprint ring to a cell adjacent to the existing road network. Deterministic BFS order.""" starts = sorted( { n for cell in footprint for n in neighbors4(cell) if city.is_empty(n) and n not in footprint }, key=lambda c: (c[1], c[0]), ) prev: dict[Cell, Optional[Cell]] = {} queue: deque[tuple[Cell, int]] = deque() for s in starts: prev[s] = None queue.append((s, 1)) while queue: cell, length = queue.popleft() if any(n in city.roads for n in neighbors4(cell)): path = [cell] while prev[path[-1]] is not None: path.append(prev[path[-1]]) path.reverse() return path if length >= max_len: continue for n in neighbors4(cell): if n not in prev and city.is_empty(n) and n not in footprint: prev[n] = cell queue.append((n, length + 1)) return None # -------------------------------------------------------------------- scoring def _rect_cheb(b: Building, cx: int, cz: int, w: int, d: int) -> int: """Exact min Chebyshev distance between a building and a candidate rect.""" dx = max(0, b.cx - (cx + w - 1), cx - (b.cx + b.w - 1)) dz = max(0, b.cz - (cz + d - 1), cz - (b.cz + b.d - 1)) return max(dx, dz) def _affinity(city: CityState, kind: str, cx: int, cz: int, w: int, d: int) -> float: res = ind = amen = park = 10 ** 6 for b in city.buildings: dist = _rect_cheb(b, cx, cz, w, d) if b.kind in C.RESIDENTIAL_KINDS: res = min(res, dist) if b.kind in C.INDUSTRIAL_KINDS: ind = min(ind, dist) if b.kind in ("cafe", "shop", "park"): amen = min(amen, dist) if b.kind == "park": park = min(park, dist) score = 0.0 if kind in ("cafe", "shop") and res <= 3: score += C.AFF_SHOP_NEAR_HOMES if kind in ("office", "bank", "tower"): dist_c = max(abs(cx), abs(cz)) score += C.AFF_DOWNTOWN * (1 - dist_c / 12.0) if kind in C.RESIDENTIAL_KINDS: if amen <= 4: score += C.AFF_HOME_NEAR_AMENITY if ind <= 3: score += C.AFF_HOME_NEAR_INDUSTRY if kind in C.INDUSTRIAL_KINDS: if res <= 3: score += C.AFF_INDUSTRY_NEAR_HOMES if ind <= 4: score += C.AFF_INDUSTRY_CLUSTER if kind == "park" and park <= 6: score += C.AFF_PARK_SPACING return score def _search( city: CityState, kind: str, anchor: Cell, wish_id: str, connector_max: int ) -> Optional[tuple[Cell, bool]]: """Best (top_left, has_frontage) within the smallest radius that holds a valid candidate, or None.""" spec = C.BUILDINGS[kind] w, d = spec["w"], spec["d"] growth = city.growth_radius ring_limit = growth + C.GROWTH_RING_SLACK road_dist = _road_distance_field(city, max(connector_max, 1) + 1) ax, az = anchor radius = C.PLACE_R_START while radius <= _R_MAX: best: Optional[tuple[float, Cell, bool]] = None index = 0 for cz in range(az - radius, az + radius + 1): for cx in range(ax - radius, ax + radius + 1): index += 1 cells = [(cx + dx, cz + dz) for dx in range(w) for dz in range(d)] if not all(city.is_empty(c) for c in cells): continue footprint = set(cells) ring = { n for c in cells for n in neighbors4(c) if n not in footprint } frontage = any(n in city.roads for n in ring) if not frontage and not any( road_dist.get(n, 10 ** 9) <= connector_max for n in ring ): continue dist_anchor = max(abs(cx - ax), abs(cz - az)) score = C.SCORE_ANCHOR * (1 - dist_anchor / radius) if frontage: score += C.SCORE_FRONTAGE developed = sum( 1 for n in ring if n in city.roads or n in city.building_cells ) score += min(C.SCORE_NEIGHBOR * developed, C.SCORE_NEIGHBOR_CAP) score += _affinity(city, kind, cx, cz, w, d) cheb_center = max(abs(cx), abs(cz)) score -= C.SCORE_RING_PENALTY * max(0, cheb_center - ring_limit) score += _u01(f"{wish_id}:{index}") * 0.5 # seeded tie-break if best is None or score > best[0]: best = (score, (cx, cz), frontage) if best is not None: return best[1], best[2] radius += C.PLACE_R_STEP return None # ----------------------------------------------------------------------- place def place( city: CityState, kind: str, name: str, floors: Any, hue: Any, near: Any, wish_id: str, call_index: int, ) -> list[dict]: """Resolve a building request into 1-2 events (place_building + optional connector lay_road). Never fails: the radius grows, the connector cap relaxes, and a footprint that fits nowhere degrades to a house.""" if kind not in C.BUILDINGS: kind = "house" anchor = resolve_anchor(city, near) found = _search(city, kind, anchor, wish_id, C.CONNECTOR_MAX) if found is None: found = _search(city, kind, anchor, wish_id, 2 * C.CONNECTOR_MAX) if found is None and kind != "house": return place(city, "house", name, 1, hue, near, wish_id, call_index) if found is None: # a 64x64 grid with any road at all always has a slot found = _search(city, kind, anchor, wish_id, 10 ** 6) if found is None: raise RuntimeError("placement impossible: no empty cell on the grid") (cx, cz), frontage = found spec = C.BUILDINGS[kind] lo, hi = spec["floors"] base = None if isinstance(floors, (int, float)) and not isinstance(floors, bool): base = int(round(floors)) if kind in ("office", "tower"): if base is None: base = (lo + hi) // 2 dist_c = max(abs(cx), abs(cz)) base = base + round(4 * (1 - dist_c / 10.0)) # taller downtown elif base is None: base = lo + zlib.crc32(f"{wish_id}:{call_index}:floors".encode()) % (hi - lo + 1) final_floors = min(max(base, lo), hi) if isinstance(hue, (int, float)) and not isinstance(hue, bool): final_hue = int(round(hue)) % 361 else: final_hue = zlib.crc32(f"{wish_id}:{call_index}:hue".encode()) % 360 variant = zlib.crc32(f"{wish_id}:{call_index}:variant".encode()) % 8 events: list[dict] = [{ "tool": "place_building", "args": { "kind": kind, "name": str(name or ""), "cx": cx, "cz": cz, "w": spec["w"], "d": spec["d"], "floors": final_floors, "hue": final_hue, "variant": variant, }, }] if not frontage: footprint = { (cx + dx, cz + dz) for dx in range(spec["w"]) for dz in range(spec["d"]) } path = _connector_path(city, footprint, 2 * C.CONNECTOR_MAX) or \ _connector_path(city, footprint, 10 ** 6) if path: events.append({ "tool": "lay_road", "args": { "cells": [[c[0], c[1]] for c in path], "klass": "street", "name": street_name_for(f"{wish_id}:{call_index}:road"), }, }) return events