Audit -> normalize (16 GLBs + thumbs) -> web/assets/manifest.json (the contract Lanes B/C/D read) -> facade gap-fill. Step 4 facades generated locally on the M3 Ultra via MODELBEAST flux_local (FLUX.2-klein-4B / MLX on MPS): 23 skins, free, ~5.7s each, blank signboard, eyeballed, harvested, mapped to shop types. Every registry type now has >=3 facades (stall 2->4, closing the Lane A gap). Corner-lot -side walls tagged face:"side" with empty types so no shop front ever selects a windowless wall. manifest validates green. Still gated on John (both optional -- the game runs asset-free): publish the 16 GLBs to 3GOD (needs GOD3_PW), MeshGod hero props (~$2.70). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
130 lines
4.4 KiB
Python
130 lines
4.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate web/assets/manifest.json — runs in Lane F's integration gate.
|
|
|
|
Checks: JSON parses; every referenced skin exists locally; every fitting/furniture GLB
|
|
exists locally (pipeline/_normalized) OR HEADs 200 on the depot; every thumb exists;
|
|
footprints & heights are sane; every registry shop type has >=2 facades.
|
|
|
|
python3 pipeline/validate_manifest.py # local + soft depot check
|
|
python3 pipeline/validate_manifest.py --depot # require GLBs live on the depot (post-publish)
|
|
|
|
Exit 0 = green, 1 = fail. Plain stdlib (no deps), so it runs anywhere.
|
|
"""
|
|
import json, os, sys, urllib.request, urllib.error
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
ASSETS = os.path.join(ROOT, "web", "assets")
|
|
NORM = os.path.join(ROOT, "pipeline", "_normalized")
|
|
MANIFEST = os.path.join(ASSETS, "manifest.json")
|
|
REGISTRY_TYPES = ["record", "opshop", "toy", "book", "video", "pawn", "milkbar", "dept", "stall"]
|
|
STRICT_DEPOT = "--depot" in sys.argv
|
|
|
|
errors, warnings = [], []
|
|
|
|
|
|
def err(m): errors.append(m)
|
|
def warn(m): warnings.append(m)
|
|
|
|
|
|
def head_ok(url):
|
|
# The depot contract documents GET only, not HEAD — probe with a 1-byte Range GET so we don't
|
|
# download the whole GLB and don't depend on HEAD being supported by the CDN/cache.
|
|
try:
|
|
req = urllib.request.Request(url, headers={"Range": "bytes=0-0"})
|
|
with urllib.request.urlopen(req, timeout=12) as r:
|
|
return r.status in (200, 206)
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 416: # range not satisfiable but the file exists
|
|
return True
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def check_skin(file):
|
|
p = os.path.join(ASSETS, file)
|
|
if not os.path.isfile(p):
|
|
err(f"skin missing: {file}")
|
|
|
|
|
|
def check_glb(entry, depot):
|
|
file = entry["file"]
|
|
local = os.path.join(NORM, file)
|
|
on_disk = os.path.isfile(local)
|
|
live = head_ok(f"{depot}/a/{file}")
|
|
if STRICT_DEPOT and not live:
|
|
err(f"GLB not live on depot: {file}")
|
|
elif not on_disk and not live:
|
|
err(f"GLB missing (not local, not on depot): {file}")
|
|
elif not live:
|
|
warn(f"GLB not yet published to depot (local only): {file}")
|
|
# thumb
|
|
thumb = entry.get("thumb")
|
|
if thumb and not os.path.isfile(os.path.join(ASSETS, thumb)):
|
|
err(f"thumb missing: {thumb}")
|
|
# footprint / height sanity
|
|
fp = entry.get("footprint")
|
|
if not (isinstance(fp, list) and len(fp) == 2 and all(0 < x < 12 for x in fp)):
|
|
err(f"insane footprint for {file}: {fp}")
|
|
h = entry.get("height")
|
|
if not (isinstance(h, (int, float)) and 0 < h < 8):
|
|
err(f"insane height for {file}: {h}")
|
|
|
|
|
|
def main():
|
|
try:
|
|
m = json.load(open(MANIFEST))
|
|
except Exception as e:
|
|
print(f"FAIL: manifest does not parse: {e}")
|
|
sys.exit(1)
|
|
|
|
depot = m.get("depot", "https://digalot.fyi/3god")
|
|
sk = m.get("skins", {})
|
|
|
|
# facades + type coverage
|
|
facade = sk.get("facade", {})
|
|
for k, v in facade.items():
|
|
check_skin(v["file"])
|
|
for t in REGISTRY_TYPES:
|
|
n = sum(1 for v in facade.values() if t in v.get("types", []))
|
|
if n < 2:
|
|
err(f"shop type '{t}' has only {n} facade(s) (need >=2)")
|
|
|
|
# other skin groups
|
|
for s in sk.get("sky", []):
|
|
check_skin(s["file"])
|
|
for g in sk.get("ground", {}).values():
|
|
check_skin(g["file"])
|
|
for w in sk.get("wall", []):
|
|
check_skin(w["file"])
|
|
interior = sk.get("interior", {})
|
|
for grp in ("floor", "surface"):
|
|
for it in interior.get(grp, []):
|
|
check_skin(it["file"])
|
|
for a in sk.get("awning", []):
|
|
check_skin(a["file"])
|
|
|
|
# fittings + furniture GLBs
|
|
n_glb = 0
|
|
for grp in ("fittings", "furniture"):
|
|
for entry in m.get(grp, {}).values():
|
|
check_glb(entry, depot)
|
|
n_glb += 1
|
|
|
|
print(f"manifest v{m.get('version')} — facades {len(facade)}, "
|
|
f"skins {sum(len(v) if isinstance(v, list) else (len(v) if isinstance(v, dict) else 0) for v in sk.values())} groups, "
|
|
f"GLBs {n_glb}")
|
|
for w in warnings:
|
|
print(f" WARN {w}")
|
|
if errors:
|
|
for e in errors:
|
|
print(f" ERR {e}")
|
|
print(f"\nFAIL — {len(errors)} error(s), {len(warnings)} warning(s)")
|
|
sys.exit(1)
|
|
print(f"\nOK — 0 errors, {len(warnings)} warning(s)")
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|