destroyulator/tools/import_store.py
Monster Robot Party e588508436 LANE10: the whole shop — four rooms, stitched by its own portal graph
The hip-hop room has stock (7 racks, 47 bins, 2,865 records); the entry hall and
back room are empty. Import all four anyway: the empty rooms are the connective
tissue, and they are the only floor in the building big enough to get an office
chair up to speed.

A portal pair is one doorway seen from both sides, so the importer walks the graph
and places each room by making its portal land exactly on its neighbour's. Every
pair here sits on parallel walls with opposite facings, so each join is a pure
translation — and when one isn't, it says so rather than quietly getting it wrong.
It then recentres the union, emits the interior walls with a gap at each doorway,
and puts the spawn in the entry hall, because that is the way in.

  hiphop-room <-> entry-hall <-> shop-floor <-> back-room
  12.96 x 15.41 m - 36 racks - 325 bins - 302 real genre labels - 24,024 records

Levels.record_store() now takes the walls and rooms from the import, hangs the
street glazing on the entry hall's outer wall, and lays a light grid across
whatever footprint the rooms came out as.

probe_store.gd also checks the new facts: five doorways exist, and the spawn has
nothing in it (excluding the player, who is supposed to be standing there — that
false positive was the probe's bug, not the level's).
All gates green: smoke clean, 7/7 sites 0.00 m/s, overlap CLEAN, all probes pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 02:48:39 +10:00

393 lines
17 KiB
Python

#!/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_<id>.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())