mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-12 20:16:29 +03:00
* 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.
213 lines
15 KiB
Python
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))
|