destroyulator/tools/import_store.py
Monster Robot Party 8e61640c38 LANE10: SITE 7 — the real shop, imported from its own database
The level the whole game started as, and the only one that isn't authored.

robotmonster.party/store/ is assembled procedurally from Postgres (the wowplatter
virtual_* schema, served by recordgod), and this game's sites are already data — so
tools/import_store.py reads the live shop and emits assets/store/shop_floor.json:
the actual 6.66 x 9.51 m floor, 35 racks across 21 archetypes, 278 bins carrying
their real genre labels (HARD TRANCE, $12-$15 EURO/ITALO HOUSE), 21,159 records
of stock. Levels.record_store() loads it; 'store' joins ORDER as the seventh site.

The shop's schema IS the cascade. virtual_crate.rack_id already records which rack
holds which bin, so bins arrive frozen and supported_by their rack and none of the
structure is authored. To carry that, smashables gained two optional generic keys —
'id' and 'supported_by' — so any level can express support as data. Every existing
level is unaffected.

Two reconciliations, because a storefront is a renderer:
- Mesh sizes are a lie: 21 archetypes share a few stand-in GLBs, so each piece
  carries 'fit' (the archetype's real dims from the shop's table) and _glb_piece
  scales the visual to it and builds the collider from it. Also fixed racks being
  flattened to the floor — pos_y is a piece's BOTTOM, which is what sit_on means,
  so the ceiling beams, aircon and wall shelves now stay up where they belong.
- Nothing collides in a renderer: racks that sat inside each other for years get
  separated (weighted, so racks barely move and aisles keep their shape — 3 m of
  correction across 35), rack footprints inset 1.5 cm a side for the few genuinely
  wedged units, and the importer AUDITS ITS OWN OUTPUT with the same test
  probe_overlap runs. Bins are deliberately not separated from their rack: cargo
  inside a container is what the shop means, they spawn frozen, and the audit skips
  frozen bodies because a frozen body cannot be depenetrated across the room.

dev/probe_store.gd: 22 racks hold bins, 255 labels survived, and smashing the rack
carrying 22 bins releases exactly those 22 while the other 256 stay frozen.
All gates green: smoke clean, 7/7 sites 0.00 m/s, overlap CLEAN, LANE9 probes pass.

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

319 lines
14 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 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())