#!/usr/bin/env python3 """Turn the REAL Monster Robot Party shop into a Destroyulator level. The live storefront (robotmonster.party/store/) is assembled procedurally from Postgres rows — the wowplatter virtual_* schema, served by recordgod as GET /virtual/scene. The game's sites are data too, so Site 7 is not hand-placed: this reads the shop and emits game/assets/store/shop_floor.json, which Levels.record_store() loads. Re-run it whenever the real shop is rearranged and the level follows. python3 tools/import_store.py # fetch live python3 tools/import_store.py --offline scene.json # from a saved dump Why the shop maps onto the game so cleanly: virtual_crate.rack_id already records which rack holds which bin, so the game's support cascade (smash the rack, its bins go over) falls straight out of the data. Nothing about it is authored. """ import argparse import json import math import pathlib import sys import urllib.request URL = "https://robotmonster.party/virtual/scene" OUT = pathlib.Path(__file__).resolve().parent.parent / "game/assets/store/shop_floor.json" # --- archetype -> what the game builds ------------------------------------------------- # The shop has 21 rack archetypes; the game has a curated store-* GLB set. Map by shape # and material, not one-to-one: two timber rack sizes, tubs, columns, and the fittings. RACK_GLB = { "wooden-rack-type-long": ("store-rack-long", "wood"), "wooden-rack-type-wide": ("store-rack-wide", "wood"), "big-4x4x4-01": ("store-rack-long", "wood"), "white-tub-3x3": ("store-rack-wide", "wood"), "white-tub-3x3-long": ("store-rack-long", "wood"), "tubs-4x2-vertical": ("store-rack-wide", "wood"), "tubs-4x2-wide": ("store-rack-wide", "wood"), "3-top-4-low-tubs-on-shelf-wide": ("store-rack-wide", "wood"), "3-top-4-low-tubs-on-shelf-long": ("store-rack-long", "wood"), "chiu-short": ("store-rack-wide", "wood"), "chiu-long": ("store-rack-long", "wood"), "mos-japhop": ("store-rack-wide", "wood"), "library trolley": ("store-trolley", "steel"), "WHITE COLUMN": ("store-column-white", "steel"), "GREY COLUMN": ("store-column-white", "steel"), "AIR CON": ("store-aircon", "steel"), "STATION 1": ("store-station", "wood"), "STATION 2": ("store-station", "wood"), "floor crate": ("store-crate-blue", "plastic"), } # Bins. Tubs and crates are moulded plastic; the timber bin is wood. CRATE_GLB = { "White Tub": ("store-tub-white", "plastic"), "White Tub long": ("store-bin-long", "plastic"), "Black Tub": ("store-tub-black", "plastic"), "Black Tub long": ("store-bin-long", "plastic"), "Blue Crate": ("store-crate-blue", "plastic"), "Wooden Rack Bin": ("crate", "wood"), } CRATE_DEFAULT = ("store-crate-blue", "plastic") # Archetypes that are not objects: flat benches and zero-depth wall shelves become slabs, # and the roof beam is structure. Building them as smashable props would be wrong. AS_SLAB = {"long guy", "short guy", "wall long", "wall big", "ROOF COLUMN", "Shelf Level", "Shelf Level long"} SKIP_CRATE_TYPES = {"Shelf Level", "Shelf Level long"} # shelves, not bins def fetch(url, offline): if offline: return json.loads(pathlib.Path(offline).read_text()) with urllib.request.urlopen(url, timeout=30) as r: return json.loads(r.read()) def rot_y(x, z, deg): a = math.radians(deg or 0.0) return x * math.cos(a) + z * math.sin(a), -x * math.sin(a) + z * math.cos(a) def resolve_overlaps(pieces, iters=60, slop=0.012): """Push interpenetrating pieces apart in X/Z until nothing overlaps. The web storefront is a RENDERER — nothing collides there, so a rack can sit a few centimetres inside its neighbour and look perfectly fine for years. Godot's solver disagrees violently (that's what the game's spawn guard exists to catch). This is the seam where a physics-free layout becomes a physically valid one. Each piece has a `w` weight: racks are heavy and barely move, bins are light and give way, so aisles keep their shape and the furniture stays where the shop put it. Heights are never touched — a ceiling beam must stay on the ceiling. """ moved = 0.0 for _ in range(iters): worst = 0.0 for i in range(len(pieces)): a = pieces[i] for j in range(i + 1, len(pieces)): b = pieces[j] # vertical separation is real separation: a bin on a shelf above another # rack is not an overlap ay0, ay1 = a["y"], a["y"] + a["fit"][1] by0, by1 = b["y"], b["y"] + b["fit"][1] if ay1 <= by0 + slop or by1 <= ay0 + slop: continue dx = (a["x"] - b["x"]) dz = (a["z"] - b["z"]) ox = (a["fit"][0] + b["fit"][0]) * 0.5 - abs(dx) oz = (a["fit"][2] + b["fit"][2]) * 0.5 - abs(dz) if ox <= slop or oz <= slop: continue worst = max(worst, min(ox, oz)) # separate along the axis of LEAST penetration — the smaller correction wa, wb = a["w"], b["w"] tot = wa + wb if ox < oz: push = (ox + slop) * (1.0 if dx >= 0 else -1.0) a["x"] += push * (wb / tot) b["x"] -= push * (wa / tot) else: push = (oz + slop) * (1.0 if dz >= 0 else -1.0) a["z"] += push * (wb / tot) b["z"] -= push * (wa / tot) moved += abs(push) if worst <= slop: break return moved def audit(pieces, slop=0.004): """What still interpenetrates after resolution — the same test the game's dev/probe_overlap.gd runs, so the importer can't hand off a level it knows is bad.""" bad = [] for i in range(len(pieces)): a = pieces[i] for j in range(i + 1, len(pieces)): b = pieces[j] ay1, by1 = a["y"] + a["fit"][1], b["y"] + b["fit"][1] if ay1 <= b["y"] + slop or by1 <= a["y"] + slop: continue ox = (a["fit"][0] + b["fit"][0]) * 0.5 - abs(a["x"] - b["x"]) oz = (a["fit"][2] + b["fit"][2]) * 0.5 - abs(a["z"] - b["z"]) if ox > slop and oz > slop: bad.append((min(ox, oz), a.get("name", a["glb"]), b.get("name", b["glb"]))) return sorted(bad, reverse=True) def stitch(rooms, portals, start): """Lay every room out in one world by walking the portal graph. A portal pair is the same doorway seen from both sides, so placing room B against room A means making B's portal land exactly on A's. In this shop every pair sits on parallel walls with opposite facings, which makes each join a pure translation — anything else would need a rotation, and we say so rather than quietly getting it wrong. """ place = {start: (0.0, 0.0)} order = [start] warned = [] while order: sid = order.pop(0) ox, oz = place[sid] for p in portals.values(): if p["space_id"] != sid or p["to_space"] in place: continue other = portals.get(p["links_to_id"]) if other is None or other["space_id"] != p["to_space"]: continue fa = (p.get("facing_deg") or 0) % 360 fb = (other.get("facing_deg") or 0) % 360 if (fa + 180) % 360 != fb: warned.append((p["id"], other["id"], fa, fb)) # our portal in world space; theirs must land on it wx, wz = p["x"] + ox, p["z"] + oz place[p["to_space"]] = (wx - other["x"], wz - other["z"]) order.append(p["to_space"]) return place, warned def room_walls(sid, room, off, portals, bounds, tol=0.08): """A room's four walls, with a gap wherever a portal pierces one. Walls that sit on the level's outer boundary are skipped — Floorplan already builds the shell from `bounds`, and a second wall in the same plane just z-fights it. """ ox, oz = off x0, z0 = ox, oz x1, z1 = ox + room["room_width"], oz + room["room_depth"] bx0, bx1, bz0, bz1 = bounds mine = [p for p in portals.values() if p["space_id"] == sid] out = [] # (axis, at, from, to, which local edge this is) edges = [("x", x0, z0, z1, ("x", room and 0.0)), ("x", x1, z0, z1, ("x", room["room_width"])), ("z", z0, x0, x1, ("z", 0.0)), ("z", z1, x0, x1, ("z", room["room_depth"]))] for axis, at, a, b, (lax, lat) in edges: if axis == "x" and (abs(at - bx0) < tol or abs(at - bx1) < tol): continue if axis == "z" and (abs(at - bz0) < tol or abs(at - bz1) < tol): continue w = {"a": axis, "at": round(at, 3), "from": round(a, 3), "to": round(b, 3)} for p in mine: # does this portal sit in this wall? compare in the room's local frame local = p["x"] if lax == "x" else p["z"] if abs(local - lat) < tol: w["door"] = round((p["z"] + oz) if axis == "x" else (p["x"] + ox), 3) break out.append(w) return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--url", default=URL) ap.add_argument("--offline", help="a directory of scene_.json dumps") ap.add_argument("--out", default=str(OUT)) ap.add_argument("--start", type=int, default=2, help="space id to anchor the layout on") args = ap.parse_args() # --- every room, and the portal graph that joins them ------------------------------ first = fetch(args.url, f"{args.offline}/scene_{args.start}.json" if args.offline else None) ids = [s["id"] for s in first.get("spaces", [])] or [first["space"]["id"]] scenes = {} for sid in ids: if sid == args.start: scenes[sid] = first elif args.offline: scenes[sid] = fetch(None, f"{args.offline}/scene_{sid}.json") else: scenes[sid] = fetch(f"{args.url}?space={sid}", None) rooms = {sid: sc["space"] for sid, sc in scenes.items()} portals = {} for sc in scenes.values(): for p in sc["portals"]: portals[p["id"]] = p place, warned = stitch(rooms, portals, args.start) for a, b, fa, fb in warned: print(f"[import_store] WARNING portals {a}/{b} face {fa}/{fb} — not opposite, " f"the join may need a rotation this importer does not do") for sid in ids: if sid not in place: print(f"[import_store] WARNING space {sid} is not reachable through any portal " f"— left out of the level") # --- the union footprint, recentred on the origin ------------------------------------ xs = [place[s][0] for s in place] + [place[s][0] + rooms[s]["room_width"] for s in place] zs = [place[s][1] for s in place] + [place[s][1] + rooms[s]["room_depth"] for s in place] ux0, ux1, uz0, uz1 = min(xs), max(xs), min(zs), max(zs) cx, cz = (ux0 + ux1) * 0.5, (uz0 + uz1) * 0.5 for s in place: place[s] = (place[s][0] - cx, place[s][1] - cz) bounds = (ux0 - cx, ux1 - cx, uz0 - cz, uz1 - cz) ceiling = max(r.get("ceiling_height") or 2.8 for r in rooms.values()) pieces, slabs, footprints, walls = [], [], [], [] n_crates = stock = 0 for sid in place: sc = scenes[sid] off = place[sid] walls += room_walls(sid, rooms[sid], off, portals, bounds) rack_types = {t["id"]: t for t in sc["rack_types"]} crate_types = {c["id"]: c for c in sc["crate_types"]} racks = [r for r in sc["racks"] if r.get("visible") == "y" and r.get("space_id") == sid] crates_by_rack = {} for c in sc["crates"]: if c.get("visible") == "y" and c.get("rack_id"): crates_by_rack.setdefault(c["rack_id"], []).append(c) for r in racks: t = rack_types.get(r["rack_type_id"]) if not t: continue name = t.get("name", "") x, z = r["pos_x"] + off[0], r["pos_z"] + off[1] yaw = math.radians(r.get("rotation_y") or 0.0) tw, th, td = t.get("width", 0.5), t.get("height", 0.5), t.get("depth", 0.5) base = r.get("pos_y") or 0.0 if name in AS_SLAB: slabs.append({"size": [max(tw, 0.05), max(th, 0.04), max(td, 0.05)], "at": [round(x, 3), round(max(th, 0.04) / 2.0 + base, 3), round(z, 3)], "mat": "wood" if "guy" in name or "wall" in name else "trim"}) continue glb, kind = RACK_GLB.get(name, ("store-rack-wide", "wood")) pieces.append({"kind_of": "rack", "glb": glb, "kind": kind, "id": f"r{r['id']}", "x": x, "z": z, "y": base, "yaw": yaw, "w": 8.0, "room": sid, "fit": [max(tw, 0.05), max(th, 0.05), max(td, 0.05)], "name": r.get("name") or name, "rack": r, "rot": r.get("rotation_y") or 0.0, "crates": crates_by_rack.get(r["id"], []), "ctypes": crate_types}) moved = resolve_overlaps(pieces, iters=400) INSET = 0.03 for p in pieces: if p["kind_of"] == "rack": p["fit"][0] = max(p["fit"][0] - INSET, 0.05) p["fit"][2] = max(p["fit"][2] - INSET, 0.05) for p in [p for p in pieces if p["kind_of"] == "rack"]: r = p["rack"] for c in p["crates"]: ct = p["ctypes"].get(c["crate_type_id"], {}) cname = ct.get("name", "") if cname in SKIP_CRATE_TYPES: continue lx, lz = rot_y(c.get("pos_x") or 0.0, c.get("pos_z") or 0.0, p["rot"]) cglb, ckind = CRATE_GLB.get(cname, CRATE_DEFAULT) e = {"kind_of": "crate", "glb": cglb, "kind": ckind, "x": p["x"] + lx, "z": p["z"] + lz, "y": (r.get("pos_y") or 0.0) + (c.get("pos_y") or 0.0), "yaw": p["yaw"], "w": 1.0, "supported_by": p["id"], "room": p["room"], "fit": [max(ct.get("width") or 0.3, 0.05), max(ct.get("height") or 0.3, 0.05), max(ct.get("depth") or 0.3, 0.05)]} label = (c.get("label_text") or "").strip() if label and label != "---": e["label"] = label pieces.append(e) n_crates += 1 stock += c.get("n_items") or 0 left = audit([p for p in pieces if p["kind_of"] == "rack"]) for depth, na, nb in left[:6]: print(f"[import_store] STILL OVERLAPPING {depth * 100:.0f} cm: {na} / {nb}") smashables = [] for p in pieces: e = {"glb": p["glb"], "kind": p["kind"], "at": [round(p["x"], 3), round(p["z"], 3)], "sit_on": round(p["y"], 3), "yaw": round(p["yaw"], 4), "fit": [round(v, 3) for v in p["fit"]]} if p["kind_of"] == "rack": e["id"] = p["id"] e["name"] = p["name"] if p["y"] < 1.2: footprints.append((p["x"], p["z"], max(p["fit"][0], p["fit"][2]) * 0.5 + 0.35)) else: e["frozen"] = True e["supported_by"] = p["supported_by"] if p.get("label"): e["label"] = p["label"] smashables.append(e) # --- spawn: the entry hall is where you actually walk in ---------------------------- hub = None for sid in place: if rooms[sid]["name"].startswith("entry"): hub = sid if hub is None: hub = args.start hx, hz = place[hub] best = (hx + rooms[hub]["room_width"] * 0.5, hz + rooms[hub]["room_depth"] * 0.5) best_clear = min((((best[0] - fx) ** 2 + (best[1] - fz) ** 2) ** 0.5 - fr) for fx, fz, fr in footprints) if footprints else 9.9 out = { "_source": args.offline or args.url, "_note": "GENERATED by tools/import_store.py from the live shop. Do not hand-edit.", "room": {"w": bounds[1] - bounds[0], "d": bounds[3] - bounds[2], "h": ceiling}, "rooms": [{"id": s, "name": rooms[s]["name"], "at": [round(place[s][0], 3), round(place[s][1], 3)], "size": [rooms[s]["room_width"], rooms[s]["room_depth"]]} for s in place], "counts": {"rooms": len(place), "racks": len([p for p in pieces if p["kind_of"] == "rack"]), "crates": n_crates, "stock_records": stock, "slabs": len(slabs)}, "spawn": {"at": [round(best[0], 2), round(best[1], 2)]}, "walls": walls, "slabs": slabs, "smashables": smashables, } p = pathlib.Path(args.out) p.write_text(json.dumps(out, indent=1)) print(f"[import_store] {len(place)} rooms stitched: " + ", ".join(rooms[s]["name"] for s in place)) print(f"[import_store] footprint {out['room']['w']:.2f} x {out['room']['d']:.2f} x {ceiling}") print(f"[import_store] separation pass moved {moved:.2f} m total") print(f"[import_store] {out['counts']['racks']} racks, {n_crates} crates, " f"{len(walls)} interior walls, {stock:,} records of stock") print(f"[import_store] spawn in {rooms[hub]['name']} at ({best[0]:.2f}, {best[1]:.2f}), " f"clearance {best_clear:.2f} m") print(f"[import_store] -> {p} ({p.stat().st_size / 1024:.0f} KB)") return 0 if __name__ == "__main__": sys.exit(main())