Files
magnus919_agent-skills/ascii-city-engine/scripts/validate-city-pack.py
Magnus HedemarkandGitHub 44809386a5 feat(ascii-city-engine): deep enrichment — street furniture, signage, dense Raleigh (#322)
* feat(ascii-city-engine): deep enrichment — street furniture, signage, dense Raleigh

Enriches the merged v0.1 skill with a dense, real-data streetscape.

Schema (backward-compatible): props gain optional label + provenance; new
optional signs layer for street-name text; buildings gain name/address/use.
world.schema.json admits signs; all v0.1 packs still validate.

Validator: per-kind prop glyph map with unknown-kind flag; sign validation;
signs included in content-bounds; v0.1 guards (isinstance crash-guard,
O(n^2) DoS caps) intact.

Engine: spatially-indexed prop billboards (signals, trees, crossings, transit
stops, bollards, benches, hydrants) depth-tested at terrain(x,y);
perspective-projected street-name sign text as an overlay pass; road
surface-material and lit ground styling; crosswalk bands; wayfinding HUD
naming the current street and the building faced. Reads spawn + first tile
from manifest.json.

Raleigh sample regenerated dense (same authoritative bbox): 159 buildings
(64 named, addresses, uses), 899 surfaces (86 named, surface/lit/lanes),
298 props across 7 kinds, 29 real street-name signs. 520K, under 2 MB.

Docs + evals: contract/engine-architecture/gis-ingestion/raleigh-poc updated
for the new layers and acceptance checks; 2 new evals (no fabricated signage;
props anchored to terrain).

Verified: validator 1410 PASS / 0 FAIL; broken fixture + AttributeError repro
exit 1; 5000-vertex DoS rejected in 31 ms; node --check OK; HTTP smoke 200
on engine/manifest/tile; validate-skills.rb 154 green; eval-coverage passes;
blocklist clean; headless-Chrome render shows readable in-world 'North
Wilmington Street' sign, signals, crosswalks, and on-street HUD.

AI-assisted contribution (Hermes Agent, spec-driven-development pipeline).

* fix(ascii-city-engine): address droid-review findings on PR #322

1. [P1] facingBuilding(): wrap the heading delta to [-pi,pi] before taking
   abs, so the Facing: HUD stops reporting a behind-the-camera building after
   the player turns past ~180 degrees.
2. [P2] buildIndices(): rasterize surface polyline edges into grid cells so
   long straight road segments register for surface styling and the On:
   HUD street name, instead of only indexing endpoint vertices.
3. [P1] validator: guard the new props kinds comprehension and the props/
   signs loops in all_points() against null, so 'props: null' or 'signs: null'
   produce a structured FAIL instead of an uncaught TypeError traceback.
4. [P3][security] validator: enforce the previously-declared
   MAX_FEATURES_PER_TILE (buildings+surfaces+props+signs) to keep the O(n^2)
   geometry and duplicate-ID paths bounded in aggregate.
5. [P3] engine: paint marked crossings as a distinct ground band (=) instead
   of a floating billboard, matching the documented crosswalk rendering.
6. [P3] eval: align prop-null-terrain expected_output with the render-time
   skip behavior actually implemented.

Verified: dense pack 1411/0 exit 0; broken/crash/dos/null packs all exit 1
with clean FAIL reports (no tracebacks); JS node --check OK; validate-skills
154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 2 on PR #322

1. [P1] Engine: skip crossing props in the billboard loop so crosswalks
   render only as the documented ground band (no more floating '=' above
   each of the 168 crossings — a regression from the prior fix).
2. [P3] Engine: nearestStreet() filters to kind==='road' so the HUD 'On:'
   line names the street, not a named plaza/sidewalk ('Market Plaza' etc).
3. [P2] Validator: require each sign's text to be a recorded road name
   (collected from surfaces), enforcing the documented never-invented
   signage contract. A fabricated 'Made Up Avenue' sign now FAILs.
4. [P2][security] Validator: short-circuit the tile loop when
   MAX_FEATURES_PER_TILE is exceeded, and replace O(n^2) duplicate-id
   .count() scans with single-pass Counters, bounding the quadratic paths.

Verified: dense pack 1411/0 exit 0; fabricated-sign repro flags only the
injected sign and exits 1; broken/crash/dos/null packs all exit 1 with no
tracebacks; JS node --check OK; validate-skills 154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 3 on PR #322

1. [P2] Validator: emit the signs rule unconditionally so a null/non-list
   'signs' value FAILs instead of passing silently (was gated on a truthy
   list check).
2. [P2] Validator: validate sign text against a pack-wide road-name set
   gathered across all tiles, so a sign in one tile may name a road whose
   surface lives in another (the documented multi-tile case).
3. [P2] Schema: require non-empty id/kind/text (minLength 1) on props and
   signs so the schema and validator agree on empty-string rejection.
4. [P3] Engine: drop dead signGrid/IX.key (the sign overlay iterates
   world.signs directly); cap edge-rasterization steps so a degenerate
   resolution (0) or pathologically long edge cannot spin unboundedly.
5. [P3] Validator: unknown prop kinds now pass with a reported fallback-'?'
   note instead of hard-failing, matching the documented fallback glyph and
   the engine's behavior.

Verified: dense pack 1411/0 exit 0; signs:null FAILs; fabricated sign FAILs;
multi-tile sign-to-road reference PASSes; broken/crash/dos/null all exit 1;
JS node --check OK; validate-skills 154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 4 on PR #322

1. [P2] Engine: render props as once-per-frame perspective-projected one-cell
   billboards in an overlay pass (like signs) instead of during the ray march,
   eliminating the multi-row vertical streak a close prop produced. Verified in
   a live browser: signals/trees/crosswalks now render as discrete single cells.
2. [P3][security] Engine: bound aggregate rasterization in buildIndices() —
   cap surfaces (5000) and cells per surface (40000) so a crafted pack cannot
   freeze the tab on load (the validator's caps are not applied client-side).
3. [P2][security] Engine: guard sign text (missing/non-string text now skips
   the sign instead of throwing in the rAF loop and freezing the view).
4. [P3] Validator: still collect building/surface IDs for oversized tiles so
   pack-wide uniqueness detection runs even when the per-feature geometry checks
   are short-circuited (duplicates in an over-cap tile are no longer hidden).

Verified: dense pack 1411/0 exit 0; live browser render shows discrete props
(no streaks); broken/crash/dos/null/nullsign/fabric all exit 1, valid
multi-tile pack exit 0; JS node --check OK; validate-skills 154 green;
blocklist clean.

* fix(ascii-city-engine): address droid-review round 5 on PR #322

1. [P2][security] Engine: guard terrain() against non-finite x/y and guard the
   prop/sign overlay passes against non-array, non-object entries, so a
   malformed pack (missing y, signs=42, null entries) degrades gracefully
   instead of throwing in the rAF loop and freezing the view. Verified in a
   live browser: a pack with signs=42 + a prop missing y renders with the
   frame loop alive and no console errors beyond the favicon 404.
2. [P3] Engine: per-surface 'seen' set now dedupes cells across edges (was
   per-edge), eliminating the repeated linear includes() scan that made the
   rasterizer quadratic in the worst case.
3. [P3] Engine: raise the per-edge step cap to 20000 since the per-surface
   cell cap bounds total work, so long edges are fully sampled at the 2-5 m
   resolutions raleigh-poc.md recommends (fixes road-styling drops).
4. [P3] Validator: lower MAX_FEATURES_PER_TILE to 50,000 (shipped pack is
   1,385), bounding the quadratic pair tests more tightly.
5. [P3] raleigh-poc: correct walkthrough step 3 — East Hargett sign is ~141 m
   behind the spawn, not ahead; only North Wilmington is ahead. HUD count
   guards signs/props as arrays.

Verified: dense pack 1411/0 exit 0; broken/crash/dos/null/nullsign/fabric all
exit 1, valid multi-tile pack exit 0; malformed-pack live render survives;
JS node --check OK; validate-skills 154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 6 on PR #322

1. [P1] Engine: props/signs overlay passes now use the corrected perpendicular
   distance (d*cos(ray_angle-heading)) for row projection, distance scaling,
   and the depth test — matching the ray march — so FOV-edge objects project to
   the right row and no longer falsely occlude or poison later depth tests.
2. [P2][security] Engine: terrain() guards malformed terrain metadata (missing
   terrain/resolution, non-positive resolution, missing origin, null elevations),
   so a crafted pack degrades to a clean error instead of freezing the tab.
   Verified live: a resolution-0/null-elevations pack shows 'Cannot load...'
   with no page errors.
3. [P3] raleigh-poc: walkthrough step 3 corrected — W/S only translate, so a
   167-deg-off sign needs A/D rotation, not 'hold S'.
4. [P3][security] Validator: all_points() guards buildings/surfaces/props/signs
   against truthy non-iterables (e.g. props=42), matching the other null guards,
   so malformed packs report structured FAIL instead of an uncaught TypeError.

Verified: dense pack 1411/0 exit 0; crash/dos/null/nullsign/fabric/props42/
broken all exit 1 (no tracebacks); valid multi-tile exit 0; malformed-terrain
live render shows clean error, no freeze; JS node --check OK; validate-skills
154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 7 on PR #322

1. [P2] Engine: spatial-index buildings (footprint bbox -> grid cells) so the
   render loop and collision test find nearby buildings in O(nearby) instead of
   scanning the whole O(buildings) list per ray sample. Browser-measured frame
   cost dropped ~62ms (16 FPS) to 12.5ms mean (~80 FPS) on the dense pack.
2. [P2][security] Engine: terrain() guards null/ragged elevation rows, so a
   pack with a null row degrades to a clean error instead of freezing the tab
   (live-verified: null-row pack shows 'Cannot load...', no page errors).
3. [P2][security] Engine: cap sign text at 80 chars in the overlay pass, so a
   pathological pack-supplied sign cannot drive an unbounded per-frame loop.
4. [P3] Validator: reference FALLBACK_GLYPH constant (was dead) in the
   unknown-kinds report message.
5. [P3] raleigh-poc: fix stale expected validator tail (was 30/25; actual is
   159/899, rules_passed=1411).

Verified: dense pack 1411/0 exit 0; crash/dos/null/nullsign/fabric/props42/
broken all exit 1, valid multi-tile exit 0; null-row pack shows clean error,
no freeze; ~80 FPS browser-measured on dense pack; JS node --check OK;
validate-skills 154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 8 on PR #322

1. [P1][security] Engine: bound the building spatial-index rasterization with
   MAX_BUILDING_PTS (2000) and MAX_BUILDING_CELLS (40000) and require >=3 finite
   footprint points, so a ~100-byte crafted footprint cannot drive a ~1e10-
   iteration synchronous hang on load (the surface rasterizer's cap, applied
   to the building index I added in round 7).
2. [P2][security] Engine: facingBuilding() filters footprints to finite points
   before reducing, so a building with a null element in its footprint no
   longer throws in the rAF loop on frame 1.
3. [P2][security] Engine: collides() and pointNearPolyline() filter footprints/
   polylines to valid array points before edge tests, so null footprint points
   no longer throw once the player enters those cells.
4. [P3] Validator: correct the feature-cap comment to 'buildings + surfaces +
   props + signs combined' (signs were already counted).

Verified: dense pack 1411/0 exit 0; ~85 FPS browser-measured (perf fix intact);
badfoot pack (null-point + missing-footprint buildings) renders with zero page
errors and frame loop alive; crash/dos/null/nullsign/fabric/props42/broken all
exit 1, valid multi-tile exit 0; JS node --check OK; validate-skills 154 green;
blocklist clean.
2026-08-16 05:51:25 -04:00

213 lines
15 KiB
Python

#!/usr/bin/env python3
"""Validate an ASCII city pack using only the Python standard library."""
from __future__ import annotations
import json
import math
import re
import sys
from datetime import date
from pathlib import Path
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
# Resource caps: bound CPU/memory spent on any single pack so a crafted or
# corrupt pack cannot trigger unbounded work in the O(n^2) geometry checks.
MAX_POLYGON_VERTICES = 2000 # per building footprint or surface geometry
MAX_ELEVATION_CELLS = 4_000_000 # per terrain grid (e.g. 2000x2000)
MAX_FEATURES_PER_TILE = 50_000 # buildings + surfaces + props + signs combined
# Documented per-kind prop glyph map (city-provider-contract.md). One glyph per kind.
PROP_GLYPHS = {"traffic_signal": "T", "street_lamp": "i", "tree": "t", "bus_stop": "B",
"bench": "b", "bollard": "o", "fire_hydrant": "f", "crossing": "="}
FALLBACK_GLYPH = "?"
class Report:
def __init__(self): self.passed = 0; self.failed = 0
def rule(self, name, ok, detail=""):
self.passed += bool(ok); self.failed += not ok
suffix = f" — {detail}" if detail else ""
print(f"{'PASS' if ok else 'FAIL'} {name}{suffix}")
def load_json(path, report, label):
try:
data = json.loads(path.read_text(encoding="utf-8"))
report.rule(label, True, str(path))
return data
except Exception as exc:
report.rule(label, False, f"{path}: {exc}")
return None
def valid_date(value):
if not isinstance(value, str) or not DATE_RE.fullmatch(value): return False
try: date.fromisoformat(value); return True
except ValueError: return False
def finite_number(value): return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def point(value): return isinstance(value, list) and len(value) == 2 and all(finite_number(v) for v in value)
def orient(a,b,c): return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])
def on_segment(a,b,p): return min(a[0],b[0]) <= p[0] <= max(a[0],b[0]) and min(a[1],b[1]) <= p[1] <= max(a[1],b[1])
def intersects(a,b,c,d):
o1,o2,o3,o4 = orient(a,b,c),orient(a,b,d),orient(c,d,a),orient(c,d,b)
if ((o1>0 and o2<0) or (o1<0 and o2>0)) and ((o3>0 and o4<0) or (o3<0 and o4>0)): return True
return any(abs(o)<1e-9 and on_segment(x,y,p) for o,x,y,p in ((o1,a,b,c),(o2,a,b,d),(o3,c,d,a),(o4,c,d,b)))
def simple_polygon(poly):
if not isinstance(poly,list) or len(poly)<3 or not all(point(p) for p in poly): return False
if len(poly) > MAX_POLYGON_VERTICES: return False
pts = poly[:-1] if poly[0] == poly[-1] else poly
if len(pts)<3 or len({tuple(p) for p in pts})<3 or abs(sum(pts[i][0]*pts[(i+1)%len(pts)][1]-pts[(i+1)%len(pts)][0]*pts[i][1] for i in range(len(pts))))<1e-9: return False
n=len(pts)
for i in range(n):
for j in range(i+1,n):
if j in (i,(i+1)%n) or i in (j,(j+1)%n): continue
if i==0 and j==n-1: continue
if intersects(pts[i],pts[(i+1)%n],pts[j],pts[(j+1)%n]): return False
return True
def valid_provenance(p, manifest=False):
if not isinstance(p,dict): return False
source_key = "name" if manifest else "source"
required=(source_key,"url","license","retrieved")
if not all(isinstance(p.get(k),str) and p[k] for k in required): return False
if not p["url"].startswith("https://") or not valid_date(p["retrieved"]): return False
if not manifest and not (finite_number(p.get("confidence")) and 0 <= p["confidence"] <= 1): return False
if "confidence" in p and not (finite_number(p["confidence"]) and 0 <= p["confidence"] <= 1): return False
return True
def inside(bounds,x,y): return bounds["min_x"] <= x <= bounds["max_x"] and bounds["min_y"] <= y <= bounds["max_y"]
def all_points(tile):
for b in (tile.get("buildings") if isinstance(tile.get("buildings"),list) else []):
if isinstance(b, dict):
yield from b.get("footprint", [])
for s in (tile.get("surfaces") if isinstance(tile.get("surfaces"),list) else []):
if isinstance(s, dict):
yield from s.get("geometry", [])
for p in (tile.get("props") if isinstance(tile.get("props"),list) else []):
if isinstance(p, dict):
yield [p.get("x"), p.get("y")]
for g in (tile.get("signs") if isinstance(tile.get("signs"),list) else []):
if isinstance(g, dict):
yield [g.get("x"), g.get("y")]
def main(argv):
report=Report(); pack=Path(argv[1]).resolve() if len(argv)==2 else None
report.rule("pack.directory", bool(pack and pack.is_dir()), str(pack) if pack else "usage: validate-city-pack.py <pack-dir>")
if not pack or not pack.is_dir(): return 1
root=Path(__file__).resolve().parents[1]
ms=load_json(root/"templates/city-pack-manifest.schema.json",report,"schema.manifest.load")
ws=load_json(root/"templates/world.schema.json",report,"schema.world.load")
report.rule("schema.manifest.required-contract", bool(ms and set(("name","version","crs","bounds","tiles","provenance")) <= set(ms.get("required",[]))))
report.rule("schema.world.required-contract", bool(ws and set(("terrain","buildings","surfaces","props")) <= set(ws.get("required",[]))))
manifest=load_json(pack/"manifest.json",report,"manifest.parse")
if not isinstance(manifest,dict): return 1
required=("name","version","crs","bounds","tiles","provenance")
report.rule("manifest.required", all(k in manifest for k in required), ", ".join(required))
report.rule("manifest.identity", isinstance(manifest.get("name"),str) and bool(manifest["name"]) and isinstance(manifest.get("version"),str) and bool(re.fullmatch(r"\d+\.\d+\.\d+",manifest["version"])) and isinstance(manifest.get("crs"),str) and bool(manifest["crs"]))
b=manifest.get("bounds")
bounds_ok=isinstance(b,dict) and all(finite_number(b.get(k)) for k in ("min_x","min_y","max_x","max_y")) and b["min_x"]<b["max_x"] and b["min_y"]<b["max_y"]
report.rule("manifest.bounds",bounds_ok)
prov=manifest.get("provenance")
report.rule("manifest.provenance",isinstance(prov,list) and bool(prov) and all(valid_provenance(p,True) for p in prov),"source URLs, licenses, ISO dates, confidence")
tiles=manifest.get("tiles")
report.rule("manifest.tiles.nonempty",isinstance(tiles,list) and bool(tiles) and all(isinstance(t,str) and t for t in tiles or []))
if not isinstance(tiles,list): tiles=[]
tile_data=[]
for idx,rel in enumerate(tiles):
safe=isinstance(rel,str) and not Path(rel).is_absolute()
path=(pack/rel).resolve() if safe else pack
safe=safe and (pack==path or pack in path.parents)
report.rule(f"tile[{idx}].path",safe and path.is_file(),str(rel))
data=load_json(path,report,f"tile[{idx}].parse") if safe and path.is_file() else None
if isinstance(data,dict): tile_data.append((idx,rel,data))
# pack-wide road-name set so a sign in one tile may reference a road whose
# name-carrying surface lives in another tile (the contract supports multi-tile)
pack_road_names=set()
for _idx,_rel,_tile in tile_data:
for _s in (_tile.get("surfaces",[]) if isinstance(_tile.get("surfaces"),list) else []):
if isinstance(_s,dict) and isinstance(_s.get("name"),str) and _s.get("name"):
pack_road_names.add(_s["name"])
building_ids=[]; surface_ids=[]; building_count=0; surface_count=0; terrain_extents=[]
for idx,rel,tile in tile_data:
report.rule(f"tile[{idx}].required",all(k in tile for k in ("terrain","buildings","surfaces","props")),rel)
n_b=len(tile.get("buildings",[])) if isinstance(tile.get("buildings"),list) else 0
n_s=len(tile.get("surfaces",[])) if isinstance(tile.get("surfaces"),list) else 0
n_p=len(tile.get("props",[])) if isinstance(tile.get("props"),list) else 0
n_g=len(tile.get("signs",[])) if isinstance(tile.get("signs"),list) else 0
total_features=n_b+n_s+n_p+n_g
report.rule(f"tile[{idx}].feature-count",total_features<=MAX_FEATURES_PER_TILE,f"{total_features} features")
if total_features>MAX_FEATURES_PER_TILE:
# still collect IDs for pack-wide uniqueness even though we skip the
# expensive per-feature geometry checks (so duplicates in an oversized
# tile are not silently accepted)
for _j,it in enumerate(tile.get("buildings",[]) if isinstance(tile.get("buildings"),list) else []):
if isinstance(it,dict) and isinstance(it.get("id"),str): building_ids.append(it["id"])
for _j,it in enumerate(tile.get("surfaces",[]) if isinstance(tile.get("surfaces"),list) else []):
if isinstance(it,dict) and isinstance(it.get("id"),str): surface_ids.append(it["id"])
continue # short-circuit quadratic work below
terrain=tile.get("terrain",{}); elev=terrain.get("elevations"); res=terrain.get("resolution_m"); origin=terrain.get("origin")
rectangular=isinstance(elev,list) and len(elev)>=2 and all(isinstance(row,list) and len(row)>=2 for row in elev) and len({len(row) for row in elev})==1 and all(v is None or finite_number(v) for row in elev for v in row)
cells=sum(len(row) for row in elev) if isinstance(elev,list) else 0
report.rule(f"tile[{idx}].terrain.cells",cells<=MAX_ELEVATION_CELLS,f"{cells} cells")
terrain_ok=finite_number(res) and res>0 and point(origin) and rectangular and cells<=MAX_ELEVATION_CELLS and valid_provenance(terrain.get("provenance"))
report.rule(f"tile[{idx}].terrain",terrain_ok,"rectangular grid, meter resolution, provenance")
if terrain_ok:
ext=(origin[0],origin[1],origin[0]+(len(elev[0])-1)*res,origin[1]+(len(elev)-1)*res); terrain_extents.append(ext)
report.rule(f"tile[{idx}].terrain.bounds",bool(bounds_ok and inside(b,ext[0],ext[1]) and inside(b,ext[2],ext[3])),str(ext))
buildings=tile.get("buildings",[]); building_count += len(buildings) if isinstance(buildings,list) else 0
b_ok=isinstance(buildings,list)
for j,item in enumerate(buildings if isinstance(buildings,list) else []):
ok=isinstance(item,dict) and isinstance(item.get("id"),str) and bool(item["id"]) and simple_polygon(item.get("footprint")) and finite_number(item.get("base_elev_m")) and finite_number(item.get("height_m")) and item["height_m"]>0 and isinstance(item.get("color"),str) and bool(COLOR_RE.fullmatch(item["color"])) and valid_provenance(item.get("provenance"))
report.rule(f"tile[{idx}].building[{j}]",ok,item.get("id","missing id") if isinstance(item,dict) else "not object"); b_ok &= ok
if isinstance(item,dict) and isinstance(item.get("id"),str): building_ids.append(item["id"])
report.rule(f"tile[{idx}].buildings",b_ok,f"count={len(buildings) if isinstance(buildings,list) else 0}")
surfaces=tile.get("surfaces",[]); surface_count += len(surfaces) if isinstance(surfaces,list) else 0
s_ok=isinstance(surfaces,list)
for j,item in enumerate(surfaces if isinstance(surfaces,list) else []):
ok=isinstance(item,dict) and isinstance(item.get("id"),str) and bool(item["id"]) and isinstance(item.get("kind"),str) and bool(item["kind"]) and isinstance(item.get("walkable"),bool) and isinstance(item.get("geometry"),list) and 2<=len(item["geometry"])<=MAX_POLYGON_VERTICES and all(point(p) for p in item["geometry"]) and valid_provenance(item.get("provenance"))
report.rule(f"tile[{idx}].surface[{j}]",ok,item.get("id","missing id") if isinstance(item,dict) else "not object"); s_ok &= ok
if isinstance(item,dict) and isinstance(item.get("id"),str): surface_ids.append(item["id"])
report.rule(f"tile[{idx}].surfaces",s_ok,f"count={len(surfaces) if isinstance(surfaces,list) else 0}")
props=tile.get("props",[])
p_ok=isinstance(props,list)
for j,item in enumerate(props if isinstance(props,list) else []):
ok=isinstance(item,dict) and isinstance(item.get("id"),str) and bool(item["id"]) and isinstance(item.get("kind"),str) and bool(item["kind"]) and finite_number(item.get("x")) and finite_number(item.get("y"))
if ok and "provenance" in item: ok = ok and valid_provenance(item.get("provenance"))
report.rule(f"tile[{idx}].prop[{j}]",ok,f"{item.get('kind','?')} ({item.get('id','?')})" if isinstance(item,dict) else "not object"); p_ok &= ok
known=sorted({p["kind"] for p in (props if isinstance(props,list) else []) if isinstance(p,dict) and isinstance(p.get("kind"),str) and p["kind"] in PROP_GLYPHS})
unknown=sorted({p["kind"] for p in (props if isinstance(props,list) else []) if isinstance(p,dict) and isinstance(p.get("kind"),str) and p["kind"] not in PROP_GLYPHS})
report.rule(f"tile[{idx}].props",p_ok,f"count={len(props) if isinstance(props,list) else 0} kinds={len(known)}")
# unknown kinds are permitted: the engine renders them with the documented
# fallback glyph '?'. Report them (so a misspelled kind is visible) but do not
# fail the pack on their presence.
if unknown: report.rule(f"tile[{idx}].props.unknown-kinds",True,f"rendered with fallback '{FALLBACK_GLYPH}': {', '.join(unknown)}")
signs=tile.get("signs",[])
g_ok=isinstance(signs,list)
for j,item in enumerate(signs if isinstance(signs,list) else []):
ok=isinstance(item,dict) and isinstance(item.get("id"),str) and bool(item["id"]) and isinstance(item.get("text"),str) and bool(item["text"]) and item["text"] in pack_road_names and finite_number(item.get("x")) and finite_number(item.get("y"))
if ok and "provenance" in item: ok = ok and valid_provenance(item.get("provenance"))
report.rule(f"tile[{idx}].sign[{j}]",ok,f"{item.get('text','?')} ({item.get('id','?')})" if isinstance(item,dict) else "not object"); g_ok &= ok
# emit the signs rule unconditionally: a non-list/null signs value must FAIL,
# matching how props is reported regardless of type
signs_ok = isinstance(signs,list)
if signs_ok: signs_ok = g_ok
report.rule(f"tile[{idx}].signs",signs_ok,f"count={len(signs) if isinstance(signs,list) else 0}")
extents_ok=bool(bounds_ok) and all(point(p) and inside(b,p[0],p[1]) for p in all_points(tile))
report.rule(f"tile[{idx}].content.bounds",extents_ok,rel)
from collections import Counter
bc=Counter(building_ids); sc=Counter(surface_ids)
duplicates=sorted(x for x,n in bc.items() if n>1)
report.rule("buildings.unique-ids",not duplicates,", ".join(duplicates))
surface_dupes=sorted(x for x,n in sc.items() if n>1)
report.rule("surfaces.unique-ids",not surface_dupes,", ".join(surface_dupes))
if isinstance(manifest.get("spawn"),dict) and bounds_ok:
s=manifest["spawn"]; report.rule("manifest.spawn.bounds",finite_number(s.get("x")) and finite_number(s.get("y")) and finite_number(s.get("heading_deg")) and inside(b,s["x"],s["y"]))
if terrain_extents:
minx=min(e[0] for e in terrain_extents); miny=min(e[1] for e in terrain_extents); maxx=max(e[2] for e in terrain_extents); maxy=max(e[3] for e in terrain_extents)
extent=f"[{minx:.1f}, {miny:.1f}]..[{maxx:.1f}, {maxy:.1f}]"
else: extent="none"
print(f"SUMMARY rules_passed={report.passed} rules_failed={report.failed} buildings={building_count} terrain_extent={extent} surfaces={surface_count}")
return 0 if report.failed==0 else 1
if __name__ == "__main__": raise SystemExit(main(sys.argv))