policesquadquest/tools/sprites.py
type-two 34c1f18335 Case Seven: From the Files (1988) — the umpire problem
Act II, full colour EGA. Six rooms, 100 points, verified end-to-end headlessly
at 100/100 with the win firing, plus two authored Sierra deaths.

The mechanic: Drebin is fired, so he has no power to search anyone — but a home
plate umpire can crouch beside a professional athlete, take his bat off him and
inspect his equipment, and nobody in a stadium of sixty thousand thinks twice.
So every frisk needs an umpire-legitimate pretext, in order: brush the plate,
inspect the mitt, check the bat for pine tar, then Number 44. Announcing
yourself as police is right there, and correct, and fatal.

Matching inversion at the reception: tackling a head of state is correct
procedure, it ends your career, and being fired is the only way to progress.

Two authoring traps found and documented for future cases:
- Object names must not be prefixes of each other — 'ludwigdesk' shadowed
  'ludwig' in the loose parser match, so 'talk ludwig' addressed the furniture.
- Flag-gated edge exits need walkable floor into the trigger zone (y<=21 for
  north); the upper deck sat behind a painted Wall band and was unreachable on
  foot. Walk every exit to verify — goto_room hides this.

tools/sprites.py now retries farm 429/401 with backoff (that status is the
guest concurrency cap, not auth) and honours MB_POOL.

LoRA finding recorded in DESIGN.md §9: the farm's style LoRAs are FLUX.1-dev
architecture and are a silent no-op on the only ungated model, and the pixel
look comes from the nearest-neighbour downscale anyway, not the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:29:27 +10:00

178 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""Generate MRPGI sprites on the MODELBEAST farm.
python3 tools/sprites.py cases/case-01-substantial-gift # all missing
python3 tools/sprites.py cases/... --only ego,johnny # named
python3 tools/sprites.py cases/... --force # redo existing
Chain per sprite: flux_local (text->image) -> bg_remove_local (RMBG-2.0 cutout)
-> Pillow nearest-neighbour downscale to the sprite's target height. MRPGI
quantizes to EGA itself on load (MANUAL SS14), so we only owe it a small
transparent PNG with the subject's feet on the bottom edge.
Sprite prompts live in sprites.json next to the game's game.json.
Token: MB_TOKEN env, else ~/Documents/backnforth/.env.
"""
import io
import json
import os
import sys
import time
import urllib.request
import uuid
from concurrent.futures import ThreadPoolExecutor
from PIL import Image
HOST = os.environ.get("MB_HOST", "http://100.89.131.57:8777")
# ponytail: guest token allows 4 active jobs; 3 leaves headroom for other users.
# Drop to MB_POOL=1 if the farm starts returning 401s (that's the cap, not auth).
POOL = int(os.environ.get("MB_POOL", "3"))
STYLE = (
"1980s Sierra AGI adventure game pixel art sprite, EGA 16-colour palette, "
"chunky low-resolution pixels, flat solid colour blocks, hard black outline, "
"single subject centred and complete, full body head to feet, no cropping, "
"plain flat white background, no text, no logo, no shadow, no border"
)
def token():
t = os.environ.get("MB_TOKEN")
if t:
return t
for line in open(os.path.expanduser("~/Documents/backnforth/.env")):
if line.startswith("MB_TOKEN="):
return line.split("=", 1)[1].strip()
sys.exit("no MB_TOKEN")
def req(path, data=None, headers=None, raw=False):
h = {"Authorization": f"Bearer {token()}"}
h.update(headers or {})
# The guest token caps concurrent jobs; over it the farm returns 429 (and
# occasionally a transient 401/5xx). Back off rather than losing the sprite.
for attempt in range(6):
try:
body = urllib.request.urlopen(
urllib.request.Request(HOST + path, data=data, headers=h), timeout=300
).read()
break
except urllib.error.HTTPError as e:
if e.code not in (401, 429, 500, 502, 503) or attempt == 5:
raise
time.sleep(5 * (attempt + 1))
if raw:
return body
# Job logs carry raw control chars that break json.loads (known farm quirk).
s = body.decode("utf-8", "replace")
return json.loads("".join(c if c >= " " or c == "\t" else " " for c in s))
def upload(name, blob):
b = uuid.uuid4().hex
body = (
f'--{b}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
f"Content-Type: image/png\r\n\r\n".encode()
+ blob
+ f"\r\n--{b}--\r\n".encode()
)
a = req("/api/assets", data=body,
headers={"Content-Type": f"multipart/form-data; boundary={b}"})
return a.get("id") or (a.get("items") or [a])[0].get("id")
def run_job(payload):
"""Submit, poll to completion, return the output asset's bytes."""
j = req("/api/jobs", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
jid = j["id"]
for _ in range(150): # 150 * 4s = 10 min ceiling
time.sleep(4)
st = req(f"/api/jobs/{jid}").get("status")
if st in ("done", "error", "cancelled"):
break
if st != "done":
raise RuntimeError(f"job {jid} ended {st}")
items = req("/api/assets?limit=100")
items = items if isinstance(items, list) else items.get("items", [])
# parent_job is the only reliable link back — newest-asset races other users.
mine = [x for x in items if x.get("parent_job") == jid]
if not mine:
raise RuntimeError(f"job {jid} produced no linked asset")
return req(f"/api/assets/{mine[0]['id']}/file", raw=True)
def trim_and_scale(blob, height):
"""Crop to the subject's alpha bounds, then nearest-neighbour to `height`.
Trimming first is what puts the feet on the bottom edge, which is how MRPGI
positions and depth-sorts a sprite (MANUAL SS14).
"""
img = Image.open(io.BytesIO(blob)).convert("RGBA")
box = img.getbbox()
if box:
img = img.crop(box)
w = max(1, round(img.width * height / img.height))
img = img.resize((w, height), Image.NEAREST)
out = io.BytesIO()
img.save(out, "PNG")
return out.getvalue()
def make(name, spec, outdir):
prompt = f"{spec['prompt']}. {STYLE}"
png = run_job({
"operator": "flux_local",
"params": {"prompt": prompt, "model": "flux2-klein-4b",
"steps": 4, "width": 512, "height": 512,
# Stable seed per sprite name => reruns are reproducible.
"seed": abs(hash(name)) % 100000},
})
aid = upload(f"{name}_raw.png", png)
cut = run_job({"operator": "bg_remove_local", "asset_id": aid,
"params": {"resolution": 1024, "background": "transparent"}})
path = os.path.join(outdir, f"{name}.png")
with open(path, "wb") as f:
f.write(trim_and_scale(cut, spec.get("h", 22)))
return path
def main():
game = sys.argv[1].rstrip("/")
only = None
if "--only" in sys.argv:
only = set(sys.argv[sys.argv.index("--only") + 1].split(","))
force = "--force" in sys.argv
specs = json.load(open(os.path.join(game, "sprites.json")))
outdir = os.path.join(game, "sprites")
os.makedirs(outdir, exist_ok=True)
todo = {
n: s for n, s in specs.items()
if (only is None or n in only)
and (force or not os.path.exists(os.path.join(outdir, f"{n}.png")))
}
if not todo:
print("nothing to do")
return
print(f"[mb] {len(todo)} sprites -> {outdir}", flush=True)
def one(item):
name, spec = item
try:
make(name, spec, outdir)
print(f"[mb] ok {name}", flush=True)
except Exception as e: # keep the batch alive; report at the end
print(f"[mb] FAIL {name}: {e}", flush=True)
return name
with ThreadPoolExecutor(POOL) as ex:
failed = [f for f in ex.map(one, todo.items()) if f]
print(f"[mb] done. failed: {failed or 'none'}")
if __name__ == "__main__":
main()