#!/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 main(): ap = argparse.ArgumentParser() ap.add_argument("--url", default=URL) ap.add_argument("--offline") ap.add_argument("--out", default=str(OUT)) args = ap.parse_args() d = fetch(args.url, args.offline) space = d["space"] W, D, H = space["room_width"], space["room_depth"], space["ceiling_height"] # The shop's origin is a room CORNER; the game centres bounds on 0. cx, cz = W / 2.0, D / 2.0 rack_types = {t["id"]: t for t in d["rack_types"]} crate_types = {c["id"]: c for c in d["crate_types"]} levels = {} for l in d["rack_type_levels"]: levels.setdefault(l["rack_type_id"], {})[l.get("level_index")] = l racks = [r for r in d["racks"] if r.get("visible") == "y" and r.get("space_id") == space["id"]] crates_by_rack = {} for c in d["crates"]: if c.get("visible") == "y" and c.get("rack_id"): crates_by_rack.setdefault(c["rack_id"], []).append(c) pieces, slabs, footprints = [], [], [] n_crates = 0 stock = 0 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"] - cx, r["pos_z"] - cz 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) if name in AS_SLAB: # benches, wall shelves and the roof beam: structure, not props 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 + (r.get("pos_y") or 0.0), 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")) rid = f"r{r['id']}" # "fit": the archetype's REAL dimensions. 21 archetypes share a handful of # stand-in meshes, so the mesh's own size is a lie — the shop's table is the truth. # pos_y is the piece's BOTTOM in the shop's data — the same thing sit_on means to # the game. Without it the ceiling beams, the aircon and the wall shelves all get # dropped onto the floor, where they stand inside the floor racks. base = r.get("pos_y") or 0.0 pieces.append({"kind_of": "rack", "glb": glb, "kind": kind, "id": rid, "x": x, "z": z, "y": base, "yaw": yaw, "w": 8.0, "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}) # Separate the RACKS only, before their bins are placed, so a rack and everything it # carries move together. Bins are deliberately NOT separated from their own rack: a # bin sitting in a rack is cargo inside a container, which is what the shop means and # what the game wants. They spawn frozen, and the overlap audit skips frozen bodies # precisely because a frozen body cannot be depenetrated across the room. moved = resolve_overlaps(pieces, iters=400) # A few racks in the real shop are genuinely wedged — three big units in a row that # cannot all fit without shoving the aisle around. An archetype's width is a NOMINAL # envelope, so take a centimetre and a half off each side of every rack's footprint: # invisible at any distance, and it buys the solver the clearance it needs. Heights # are untouched, so nothing sinks into the floor or leaves the ceiling. 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) left = audit(pieces) for depth, na, nb in left[:6]: print(f"[import_store] STILL OVERLAPPING {depth * 100:.0f} cm: {na} / {nb}") for p in [p for p in pieces if p["kind_of"] == "rack"]: r = p["rack"] for c in crates_by_rack.get(r["id"], []): ct = crate_types.get(c["crate_type_id"], {}) cname = ct.get("name", "") if cname in SKIP_CRATE_TYPES: continue # crate pos is LOCAL to its rack (pos_y already carries the shelf height) 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, "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)], "supported_by": p["id"]} 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 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: # frozen + supported: bins don't jitter at spawn (the stillness gate stays # green) and they go live exactly when their rack does e["frozen"] = True e["supported_by"] = p["supported_by"] if p.get("label"): e["label"] = p["label"] smashables.append(e) # --- a measured spawn point --------------------------------------------------------- # 35 racks in a 6.66 x 9.51 room leaves very little open floor, and spawning inside a # rack is not a level. Scan a grid for the clearest spot in the front half of the shop. best, best_clear = (0.0, D / 2.0 - 0.9), -1.0 step = 0.25 x0, x1 = -cx + 0.6, cx - 0.6 z0, z1 = -cz + 0.6, cz - 0.6 gx = x0 while gx <= x1: gz = z0 while gz <= z1: clear = min(((gx - fx) ** 2 + (gz - fz) ** 2) ** 0.5 - fr for fx, fz, fr in footprints) if footprints else 9.9 # prefer the front of the shop (larger z), where the door is score = clear + (gz + cz) * 0.05 if clear > 0.55 and score > best_clear: best_clear, best = score, (gx, gz) gz += step gx += step out = { "_source": args.url if not args.offline else args.offline, "_note": "GENERATED by tools/import_store.py from the live shop. Do not hand-edit.", "room": {"w": W, "d": D, "h": H}, "counts": {"racks": len(racks), "crates": n_crates, "stock_records": stock, "slabs": len(slabs)}, "spawn": {"at": [round(best[0], 2), round(best[1], 2)]}, "slabs": slabs, "smashables": smashables, } p = pathlib.Path(args.out) p.write_text(json.dumps(out, indent=1)) print(f"[import_store] room {W} x {D} x {H}") print(f"[import_store] separation pass moved {moved:.2f} m total") print(f"[import_store] {len(racks)} racks, {n_crates} crates, {len(slabs)} slabs, " f"{stock:,} records of stock") print(f"[import_store] spawn at ({best[0]:.2f}, {best[1]:.2f}), 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())