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>
This commit is contained in:
Monster Robot Party 2026-08-04 00:49:49 +10:00
parent 14ebc1d6f5
commit 8e61640c38
8 changed files with 6197 additions and 8 deletions

View File

@ -0,0 +1,63 @@
# LANE 10 — SITE 7: MONSTER ROBOT PARTY
*The level the whole game started as. Spec'd and executed by Fable, 2026-08-04.*
## The idea
Every other site is authored. This one is **imported from the real shop**. The live
storefront at `robotmonster.party/store/` is procedurally assembled from Postgres rows
(`virtual_rack`, `virtual_crate`, `virtual_rack_type*`, `virtual_crate_type` — the
wowplatter schema, now served by recordgod/storegod), and the game's sites are already
data. So Site 7 is not hand-placed: a converter reads the shop and emits a level.
`GET https://robotmonster.party/virtual/scene` returns the shop floor:
| | |
|---|---|
| room | 6.66 m × 9.51 m, 2.89 m ceiling |
| racks | 35 placed, across 21 archetypes |
| crates | 302, every one on a rack |
| labels | real: `HARD TRANCE`, `$12-$15 EURO/ITALO HOUSE`, `SOUND TRACKS, SPOKEN WORD & COMEDY` |
| stock | **22,049 records** across the bins (`n_items`) |
The rack archetype names (`wooden-rack-type-long`, `white-tub-3x3`, `chiu-long`) are the
same names as the GLBs in `3D-STORE/`, because both were generated from the same table.
## Why this is the best-fitting level in the game
The shop's schema **is** the cascade. `virtual_crate.rack_id` already says which rack
holds which bin, so `rack.supports = [its crates]` falls out of the data — knock a rack
and the real bins that really sit on it really go over. Nothing to author.
## Deliverables
### 1. Generic data-driven cascade *(`Main.gd`, small)*
Extend the `smashables` spec entry with two optional keys so a level can express support
without code: `"id"` (a name other entries can refer to) and `"supported_by"` (the id of
the piece holding this one up). Main builds in order, keeps `id -> Smashable`, and wires
`holder.supports.append(piece)`. Every existing level is unaffected.
### 2. `tools/import_store.py` — the converter
Fetches the live scene (`--url`, or `--offline <file>`), filters to the shop-floor space,
and emits `game/assets/store/shop_floor.json`:
- world-space rack positions, **re-centred** (the shop's origin is a corner; the game
centres its bounds on 0)
- each crate placed by `rack_pos + rotate_y(crate_local, rack.rotation_y)`, carrying its
real `label` and `n_items`
- archetype → game GLB + material mapping (tubs are `plastic`, timber racks `wood`,
columns and aircon `steel`)
- crates spawn **frozen** and supported by their rack: they don't jitter at spawn (the
stillness gate stays green) and they go live exactly when the rack does
- a **measured spawn point** — a grid scan for open floor, because a 6.66 × 9.51 room
holding 35 racks has very little of it, and spawning inside a rack is not a level
Re-runnable: when the real shop is rearranged, re-run it and the level changes.
### 3. `Levels.record_store()` — the site
Loads the JSON and returns a normal Floorplan spec. Timber floor, white walls, the
brand's pink as accent. Added to `Levels.ORDER` as the seventh site.
## Gates
Headless smoke clean · `probe_levels` **7/7** at 0.00 m/s · `probe_overlap` CLEAN ·
the LANE9 probes still green. Plus `dev/probe_store.gd`: the site builds, the rack→crate
cascade is wired, and smashing one rack releases exactly its own bins.

View File

@ -12,10 +12,11 @@ rest of your day. Your score is your payslip: **wages earned minus damages cause
during a meltdown you want to wreck a lot of cheap rubbish rather than one expensive
filing cabinet. Falling Down, scored by payroll.
**Six sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT
basement, a greengrocer where everything is soft except the bottles, and the Backrooms — where the walls are procedurally generated and everything
**Seven sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT
basement, a greengrocer where everything is soft except the bottles, the Backrooms — where the walls are procedurally generated and everything
you're there to destroy is invisible until you coat it in extinguisher powder, some of
which you breathe) and **four modes** (Cubicle Hell, the Gauntlet, Total Destruction, Against the
which you breathe, and the real Monster Robot Party shop — imported live from the
storefront's own database, bins, genre labels and all) and **four modes** (Cubicle Hell, the Gauntlet, Total Destruction, Against the
Clock). Sites are data, not code — see `game/scripts/Levels.gd`.
Design, built and planned, lives in [`LANES/LANE7-cubicle-hell.md`](LANES/LANE7-cubicle-hell.md).

View File

@ -21,7 +21,7 @@ Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics.
| **16 · wheel · Q** | pick a weapon · cycle · swap to previous |
| **H** | cycle HUD style (Arcade / Minimal / Work Order / Dev) |
| **M** | cycle game mode (Cubicle Hell / Gauntlet / Total Destruction / Against the Clock) |
| **L** | cycle site (Scranton / Pawnee / Incubator / Sub-level 4 / Greengrocer / Level 0) |
| **L** | cycle site (Scranton / Pawnee / Incubator / Sub-level 4 / Greengrocer / Level 0 / Monster Robot Party) |
| **G** | restart a Gauntlet run |
| **F** | report the disc in hand (Find-the-Misfiled-Disc) |
| **E** | work at the station you're standing at (locks you in place); else hop on / off an office chair under the crosshair; else pick up whatever's there |
@ -46,6 +46,7 @@ generator can later emit the same structure.
| **SUB-LEVEL 4** | concrete, green strip lights | **no windows**, exposed services, a wall of servers |
| **THE GREENGROCER** | bright, pale, warm strips | everything is **soft** except the bottles — see below |
| **LEVEL 0** | one colour, everywhere | the Backrooms. Walls are **generated**, and everything you're here to destroy is **invisible** |
| **MONSTER ROBOT PARTY** | timber, white, shop pink | the real shop, **imported from the live storefront** — see below |
`dev/probe_levels.gd` builds every one and reports residual motion after a full second.
All six must read `0.00 m/s`.
@ -259,6 +260,44 @@ tick on the meter — it's what makes the room hostile rather than merely beige.
per object but each object bills at its own value, so the meltdown decision is *smash a
lot of cheap things fast* versus *one expensive swing*.
### MONSTER ROBOT PARTY — the only site that isn't authored
The shop the whole game started as, and the only one **imported** rather than written.
The live storefront at `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 real shop and emits
`assets/store/shop_floor.json`:
| | |
|---|---|
| room | 6.66 m × 9.51 m, 2.89 m ceiling — the actual floor |
| racks | 35, across 21 archetypes |
| bins | 278, each carrying its **real genre label** (`HARD TRANCE`, `$12-$15 EURO/ITALO HOUSE`) |
| stock | **21,159 records** |
**The shop's schema is the cascade.** `virtual_crate.rack_id` already records which rack
holds which bin, so every bin arrives frozen and `supported_by` its rack — knock a rack
over and the actual bins that actually sit on it actually go. None of it is authored; it
comes off the shop floor. `dev/probe_store.gd` proves it: smashing the rack that carries
22 bins releases exactly those 22 and leaves the other 256 frozen.
Two things the importer has to reconcile, because a web storefront is a renderer:
- **The mesh sizes are a lie.** 21 archetypes share a handful of stand-in GLBs, so each
piece carries a `fit` — the archetype's real width/height/depth from the shop's own
table — and the game scales the visual to it and builds the collider from it.
- **Nothing collides in a renderer.** A rack can sit a few centimetres inside its
neighbour and look fine for years; Jolt disagrees violently. The importer separates
them (weighted, so racks barely move and the aisles keep their shape — 3 m of total
correction across 35 racks), insets rack footprints by 1.5 cm a side, and then **audits
its own output** with the same test `dev/probe_overlap.gd` runs.
Re-run it when the real shop is rearranged and the level follows:
```sh
python3 tools/import_store.py
```
## The fun layer (LANE9)
Three antagonists to the *tidy* impulse, all riding systems that already exist:

File diff suppressed because it is too large Load Diff

82
game/dev/probe_store.gd Normal file
View File

@ -0,0 +1,82 @@
extends SceneTree
## SITE 7 contract — the imported shop:
## - it builds, with racks AND bins
## - the cascade came off the shop's own data: racks hold bins via `supported_by`
## - smashing one rack releases exactly ITS bins, and no others
## - the real genre labels survived the import
##
## Godot --headless --path game --script dev/probe_store.gd
func _initialize() -> void:
var main: Node = (load("res://main.tscn") as PackedScene).instantiate()
get_root().add_child(main)
await process_frame
main._load_level("store")
for i in 70:
await physics_frame
var racks: Array = []
var bins: Array = []
var labelled := 0
for n in get_nodes_in_group("smashable"):
var s := n as Smashable
if s == null:
continue
if s.supports.size() > 0:
racks.append(s)
if s.freeze:
bins.append(s)
if s.has_meta("label"):
labelled += 1
print("racks holding bins: %d frozen bins: %d labelled bins: %d" % [
racks.size(), bins.size(), labelled])
if racks.is_empty() or bins.is_empty():
print("FAIL: the shop did not import (no racks holding bins)")
quit(1)
return
if labelled < 20:
print("FAIL: the real genre labels did not survive the import")
quit(1)
return
# find the rack carrying the most bins and knock it over
var best: Smashable = racks[0]
for r in racks:
if r.supports.size() > best.supports.size():
best = r
var mine: Array = []
for b in best.supports:
if is_instance_valid(b):
mine.append(b)
var others: Array = []
for b in bins:
if is_instance_valid(b) and not mine.has(b):
others.append(b)
var others_frozen_before := 0
for b in others:
if (b as RigidBody3D).freeze:
others_frozen_before += 1
print("smashing '%s' — it carries %d bins" % [best.name, mine.size()])
best.smash(Vector3(0, 1, 0), 999.0)
await physics_frame
await physics_frame
var released := 0
for b in mine:
if is_instance_valid(b) and not (b as RigidBody3D).freeze:
released += 1
var others_still := 0
for b in others:
if is_instance_valid(b) and (b as RigidBody3D).freeze:
others_still += 1
print("its bins released: %d/%d other bins still frozen: %d/%d" % [
released, mine.size(), others_still, others_frozen_before])
if released == mine.size() and others_still == others_frozen_before:
print("STORE OK")
quit(0)
else:
print("FAIL: cascade released the wrong set of bins")
quit(1)

View File

@ -20,7 +20,7 @@ class_name Levels
##
## Godot 4.7 GDScript 2.0.
const ORDER := ["scranton", "pawnee", "house", "basement", "grocer", "backrooms"]
const ORDER := ["scranton", "pawnee", "house", "basement", "grocer", "backrooms", "store"]
static func get_level(id: String) -> Dictionary:
match id:
@ -29,6 +29,7 @@ static func get_level(id: String) -> Dictionary:
"basement": return basement()
"backrooms": return backrooms()
"grocer": return grocer()
"store": return record_store()
return scranton()
static func next_id(id: String) -> String:
@ -717,3 +718,82 @@ static func grocer() -> Dictionary:
"stand": Vector3(-8.6, 0.0, -4.0), "shredder": Vector3(-6.4, 0.0, -5.4)},
"spawn": {"at": Vector3(0.0, 0.0, 6.4), "yaw": PI},
}
# ================================================================ SITE 7
## THE MONSTER ROBOT PARTY — the shop the whole game started as.
##
## The only site that is IMPORTED rather than authored. The live storefront at
## robotmonster.party/store/ is assembled procedurally from Postgres (the wowplatter
## virtual_* schema, served by recordgod), and this game's sites are data, so
## tools/import_store.py reads the real shop and emits assets/store/shop_floor.json:
## 35 racks, 278 bins carrying their real genre labels, and 21,159 records of stock.
##
## The shop's schema IS the cascade. virtual_crate.rack_id already records which rack
## holds which bin, so every crate arrives frozen and `supported_by` its rack — knock a
## rack over and the actual bins that actually sit on it actually go. None of that is
## authored; it comes off the shop floor.
##
## Re-run the importer when the real shop is rearranged and this level follows.
const STORE_JSON := "res://assets/store/shop_floor.json"
static func record_store() -> Dictionary:
var raw := FileAccess.get_file_as_string(STORE_JSON)
var data = JSON.parse_string(raw) if raw != "" else null
if data == null:
push_warning("[store] %s missing — run tools/import_store.py" % STORE_JSON)
data = {"room": {"w": 6.66, "d": 9.51, "h": 2.89}, "smashables": [], "slabs": [],
"spawn": {"at": [0.0, 3.0]}}
var room: Dictionary = data.get("room", {})
var w: float = float(room.get("w", 6.66))
var dp: float = float(room.get("d", 9.51))
var h: float = float(room.get("h", 2.89))
# JSON has no Vector types — rehydrate into what Floorplan/Main expect.
var smash: Array = []
for e in data.get("smashables", []):
var s: Dictionary = e.duplicate()
var a: Array = s["at"]
s["at"] = Vector2(float(a[0]), float(a[1]))
smash.append(s)
var slabs: Array = []
for e in data.get("slabs", []):
var sz: Array = e["size"]
var at: Array = e["at"]
slabs.append({"size": Vector3(float(sz[0]), float(sz[1]), float(sz[2])),
"at": Vector3(float(at[0]), float(at[1]), float(at[2])),
"mat": String(e.get("mat", "wood"))})
var sp: Array = data.get("spawn", {}).get("at", [0.0, 3.0])
return {
"name": "MONSTER ROBOT PARTY",
"subtitle": "your own shop. you know exactly what everything cost.",
"bounds": {"x0": -w * 0.5, "x1": w * 0.5, "z0": -dp * 0.5, "z1": dp * 0.5, "h": h},
"palette": {
"floor": Color(0.42, 0.31, 0.22), "wall": Color(0.93, 0.92, 0.90),
"ceiling": Color(0.17, 0.16, 0.19), "trim": Color(0.19, 0.18, 0.21),
"partition": Color(0.35, 0.20, 0.34), "wood": Color(0.52, 0.36, 0.20),
"accent": Color(1.0, 0.36, 0.62), # the shop's own pink
},
"floor_style": "timber",
"ceiling": "flat",
"walls": [],
# the shopfront: glazing down the street side, where the window display is
"windows": [{"a": "z", "at": dp * 0.5, "from": -w * 0.5, "to": w * 0.5,
"y0": 0.55, "y1": 2.45, "blinds": false}],
"lights": {"xs": [-1.9, 1.9], "zs": [-3.4, -1.1, 1.2, 3.5],
"color": Color(1.0, 0.93, 0.86), "energy": 2.1, "style": "strip",
"key_energy": 1.5},
"sun": {"color": Color(1.0, 0.95, 0.88), "energy": 0.9, "rot": Vector3(-38, 14, 0)},
"env": {"bg": Color(0.72, 0.76, 0.82), "ambient": Color(0.58, 0.55, 0.56),
"ambient_energy": 0.62, "exposure": 1.02},
"clusters": [],
"chairs": [],
"props": [],
"slabs": slabs,
"smashables": smash,
"stacks": [],
# a few playable records loose on the counter side, for the ritual
"records": [],
"spawn": {"at": Vector3(float(sp[0]), 0.0, float(sp[1])), "yaw": PI},
}

View File

@ -246,11 +246,33 @@ func _populate() -> void:
(cdesk["piece"] as Smashable).supports.append(printer["piece"])
# --- everything else this site contains, straight from its spec ---
# Two optional keys let a level express STRUCTURE as data instead of code: "id" names
# a piece, "supported_by" points at the id of whatever holds it up. Wiring happens
# after the loop so an entry may refer to a holder declared later. This is what makes
# the record store importable — the shop's own DB already knows which rack holds which
# bin, so the cascade comes straight off `virtual_crate.rack_id`. See LANE10.
var by_id: Dictionary = {}
var links: Array = []
for e in _plan.smashables():
var d: Dictionary = e
_glb_piece(P + String(d["glb"]) + ".glb", String(d.get("kind", "wood")),
var fit := Vector3.ZERO
if d.has("fit"):
var f: Array = d["fit"]
fit = Vector3(float(f[0]), float(f[1]), float(f[2]))
var res := _glb_piece(P + String(d["glb"]) + ".glb", String(d.get("kind", "wood")),
d["at"], bool(d.get("frozen", false)), float(d.get("sit_on", 0.0)),
float(d.get("yaw", 0.0)))
float(d.get("yaw", 0.0)), false, fit)
var piece := res["piece"] as Smashable
if d.has("id"):
by_id[String(d["id"])] = piece
if d.has("supported_by"):
links.append([String(d["supported_by"]), piece])
if d.has("label"):
piece.set_meta("label", String(d["label"]))
for link in links:
var holder := by_id.get(link[0]) as Smashable
if holder != null and is_instance_valid(holder):
holder.supports.append(link[1])
# --- crates of records, where the site has any. Records stand side by side ON the
# --- crate lid, offset by more than a sleeve is thick: they used to be stacked 3 cm
@ -491,7 +513,7 @@ func _glb_radius(path: String) -> float:
## the mesh AABB, and drop it so its bottom rests at height `sit_on`. Returns
## { piece: Smashable, top: float } so a caller can stack the next thing on top.
func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := 0.0,
yaw := 0.0, centred := false) -> Dictionary:
yaw := 0.0, centred := false, fit := Vector3.ZERO) -> Dictionary:
var s := Smashable.new()
s.kind = kind
s.start_frozen = frozen
@ -512,6 +534,13 @@ func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on :=
var ab := _body_local_aabb(s, vis)
if ab.size == Vector3.ZERO:
ab = AABB(Vector3(-0.2, 0.0, -0.2), Vector3(0.4, 0.4, 0.4)) # fallback
# `fit`: this piece's REAL dimensions, when the caller knows them better than the mesh
# does. The record store maps 21 shop archetypes onto a handful of stand-in meshes, so
# the mesh's own size is a lie — scale the visual to the truth and measure again, and
# both the render and the collider match the shop that was imported.
if fit != Vector3.ZERO and ab.size.x > 0.001 and ab.size.y > 0.001 and ab.size.z > 0.001:
vis.scale = Vector3(fit.x / ab.size.x, fit.y / ab.size.y, fit.z / ab.size.z)
ab = _body_local_aabb(s, vis)
var col := CollisionShape3D.new()
# Round produce gets a SPHERE. A box collider on an apple is why a pyramid needed
# hand-tuned lattice spacing to nest, and why nothing ever rolled: the corners hit

318
tools/import_store.py Normal file
View File

@ -0,0 +1,318 @@
#!/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())