not-tonight/tools/gen/run_batch.py
m3ultra 4caa901df2 Four venues, four rooms: the ladder stops being one map with different numbers
The venue ladder has always listed four venues in data/venues.ts, but they
shared ONE floor map and differed only by difficulty knobs — so a promotion you
survived a whole week for looked exactly like the room you had just left.

- venueMap.ts grows a FloorLayout contract (map + props + lights + posts +
  probes + palette) and the newGrid() primitives every room is painted with.
  Voltage stays IN venueMap rather than moving to layouts/, so the new rooms can
  import primitives from it without closing an import cycle.
- Three new rooms in src/scenes/floor/layouts/: The Royal (horseshoe public bar,
  pool room, pokies corner, trough + two cubicles, a beer garden that is plainly
  the nicest room in the pub, and a DJ "corner" that is a folding table because
  this pub never built a booth), Elevate (open-air rooftop — most of the grid is
  sky, the DJ is on an unwalled plinth, you arrive by lift), ROOM (concrete
  warehouse, pillars you path around, central booth, loading-dock smoking area,
  twelve lights in the whole venue).
- Nothing about a room is a module singleton any more. FloorView, sweep and
  FloorDemoScene take the layout; the door picks its street plate by venue id.
- FloorDemoScene's six hardcoded station spots (TAPS_SPOT, DECKS_SPOT, ...) were
  Voltage's tile coordinates. At four venues a hardcoded (47,3) puts the bar
  shift inside The Royal's pool room, so each layout now names its own stations
  and the scene reads them from there.
- tests/floor/layoutInvariants.ts is an executable rulebook: perimeter holds,
  every walkable tile reachable from the entry, anchors on walkable ground,
  posts not sealed (a post on a sealed tile freezes the player for the night —
  that has shipped twice), probes reachable, props on-grid. Proved against
  Voltage BEFORE the new rooms were authored against it.

Dev route #floor:<venueId> boots the floor straight into one room, because
otherwise seeing ROOM means surviving three weeks of the ladder.

Gate: lint clean, build clean, 821 tests passing (was 784).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:51:54 +10:00

196 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""run_batch.py — generate every sprite in assets.py, unattended.
python3 tools/gen/run_batch.py # everything still missing
python3 tools/gen/run_batch.py pokie jukebox # just these kinds
python3 tools/gen/run_batch.py --force # redo even if the png exists
python3 tools/gen/run_batch.py --flat-only # cheap pass, no mesh jobs
Resumable by design: a kind whose `public/props/<kind>.png` already exists is
skipped, so a run that dies at asset 30 of 47 costs 17 assets to finish, not 47.
Every intermediate (product shot, cutout, GLB) is kept in `art_incoming/` for
the same reason — re-angling a prop should never mean re-meshing it, which is
the [GLB] route's whole advantage (LANEHANDOVER, FABLE-SOLO-25).
Image tier order is Cloudflare FIRST — 10,000 free neurons a day, ~4s an image —
then MODELBEAST `flux_local`, which is slower but unmetered. The fallback is
automatic and one-way per run: once Cloudflare reports the daily allocation
spent, we stop asking it and go to the farm for the rest of the batch.
Heartbeat lands in ~/.jobs/not-tonight-assets.json per the fleet jobs
convention, so "where are we at" is answerable without reading the log.
"""
from __future__ import annotations
import concurrent.futures as cf
import json
import subprocess
import sys
import threading
import time
import traceback
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import assets as manifest # noqa: E402
import cf_flux # noqa: E402
import mb # noqa: E402
REPO = Path(__file__).resolve().parent.parent.parent
INCOMING = REPO / "art_incoming"
PROPS = REPO / "public" / "props"
HEARTBEAT = Path.home() / ".jobs" / "not-tonight-assets.json"
# Flipped the first time Cloudflare says the day's neurons are gone. One-way:
# re-probing per asset would waste a call each time to learn the same thing.
_cf_spent = False
def beat(**fields: object) -> None:
HEARTBEAT.parent.mkdir(parents=True, exist_ok=True)
payload = {"job": "not-tonight-assets", "updated": time.strftime("%Y-%m-%d %H:%M:%S"), **fields}
HEARTBEAT.write_text(json.dumps(payload, indent=2) + "\n")
def out_png(kind: str) -> Path:
return PROPS / f"{kind.replace(':', '_')}.png"
def draw(a: manifest.Asset, dest: Path) -> str:
"""One image from the cheapest tier that still has budget. Returns the tier."""
global _cf_spent
w, h = a.gen
if not _cf_spent:
try:
# Workers AI schnell has no width/height knob — it returns square.
# Fine for props; the wide plates below need the farm regardless.
if w == h:
cf_flux.generate(dest, a.prompt, steps=6, seed=a.seed)
return "cloudflare"
except cf_flux.Exhausted:
_cf_spent = True
print("[gen] cloudflare free tier spent — rest of the batch on the farm", flush=True)
except Exception as exc: # network wobble: fall through to the farm
print(f"[gen] cloudflare failed ({exc.__class__.__name__}), using farm", flush=True)
mb.image(dest, a.prompt, seed=a.seed, steps=4, width=w, height=h, label=a.kind)
return "farm"
def do_flat(a: manifest.Asset) -> None:
raw = INCOMING / f"{a.kind.replace(':', '_')}_raw.png"
if not raw.exists():
draw(a, raw)
cmd = [sys.executable, str(REPO / "tools" / "props_import.py"), str(raw), a.kind]
if not a.quantise:
cmd.append("--no-quantise")
if a.keep_bg:
cmd.append("--keep-bg")
subprocess.run(cmd, check=True)
def do_mesh(a: manifest.Asset) -> None:
stem = a.kind.replace(":", "_")
shot = INCOMING / f"{stem}_shot.png"
cut = INCOMING / f"{stem}_cut.png"
glb = INCOMING / f"{stem}.glb"
if not shot.exists():
draw(a, shot)
if not cut.exists():
# TRELLIS reconstructs markedly better from a clean cutout than from an
# object sitting on a grey sweep — the operator's own docs say so, and
# it costs one cheap local job.
try:
asset_id = mb.upload(shot)
job = mb.submit("bg_remove_local", {"resolution": 1024}, asset_id=asset_id)
if mb.wait(job, label=f"{a.kind}:cut") == "done":
got = mb.output_asset(job, ".png")
if got:
mb.fetch(got, cut)
except Exception as exc:
print(f"[gen] {a.kind}: bg_remove failed ({exc}) — meshing the raw shot", flush=True)
src = cut if cut.exists() else shot
if not glb.exists():
mb.mesh(src, glb, operator="trellis2_mlx", label=a.kind)
# Blender ortho render at the house rake, then the normal sprite import.
subprocess.run(
[sys.executable, str(REPO / "tools" / "glb_to_sprite.py"), str(glb), a.kind,
"--rake", str(a.rake), "--px", "512"],
check=True,
)
def main() -> None:
raw_args = sys.argv[1:]
force = "--force" in raw_args
flat_only = "--flat-only" in raw_args
# Drop flags AND the value that follows a value-taking flag — otherwise
# `--workers 3` leaves a bare "3" in the positional list and the batch
# filters down to the kind named "3", i.e. nothing.
argv: list[str] = []
skip_next = False
for i, tok in enumerate(raw_args):
if skip_next:
skip_next = False
continue
if tok in ("--workers",):
skip_next = True
continue
if tok.startswith("--"):
continue
argv.append(tok)
todo = [a for a in manifest.ALL if not argv or a.kind in argv]
if flat_only:
todo = [a for a in todo if a.route == "flat"]
if not force:
todo = [a for a in todo if not out_png(a.kind).exists()]
workers = int(sys.argv[sys.argv.index("--workers") + 1]) if "--workers" in sys.argv else 3
INCOMING.mkdir(exist_ok=True)
done: list[str] = []
failed: list[str] = []
print(f"[gen] {len(todo)} assets to make, {workers} at a time", flush=True)
beat(status="running", total=len(todo), done=0, failed=0)
# The farm load-balances gpu jobs across nodes, so a strictly sequential
# batch leaves most of the cluster idle — 37 meshes at ~7 min each is over
# four hours in a queue of one. Three in flight stays under the guest
# token's 4-active ceiling and keeps every node fed. Safe to overlap
# because output retrieval matches on `parent_job`, never newest-of-type
# (the 2026-07-16 race that handed one project's mesh to another).
lock = threading.Lock()
def run_one(a: manifest.Asset) -> None:
t0 = time.time()
try:
(do_flat if a.route == "flat" else do_mesh)(a)
with lock:
done.append(a.kind)
print(f"[gen] {a.kind} OK in {int(time.time() - t0)}s", flush=True)
except Exception as exc:
with lock:
failed.append(a.kind)
print(f"[gen] {a.kind} FAILED: {exc}", flush=True)
traceback.print_exc()
with lock:
beat(status="running", total=len(todo), done=len(done), failed=len(failed),
last=a.kind, still_to_go=len(todo) - len(done) - len(failed))
with cf.ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(run_one, todo))
beat(status="finished", total=len(todo), done=len(done), failed=len(failed),
failed_kinds=failed)
print(f"\n[gen] DONE {len(done)}/{len(todo)}"
+ (f" — FAILED: {', '.join(failed)}" if failed else ""))
# A partial batch is a normal outcome; the venue layouts fall back to
# placeholders for anything missing, so this must not read as total failure.
sys.exit(0 if not failed else 2)
if __name__ == "__main__":
main()