nemocity / engine /placement.py
AndresCarreon's picture
NEMOCITY v0 — mock backend, gradio 6.16.0 (pre-SSR)
d72231c verified
Raw
History Blame Contribute Delete
11.9 kB
"""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