policesquadquest/tools/sprites.py
type-two e429351ab3 Case Two: Ring of Fear — the paperwork is what keeps you alive
Act I, monochrome. Five rooms, 100 points, verified end-to-end at 100/100 on
the first pass, plus three verified Sierra deaths.

Frank goes under as fight manager "Frank Deacon" to break a fight-fixing racket.
The procedure gag from the bible finally has teeth: Form 22-B, the four-page
application for an authorised cover identity, is filed in Act 1 by a clerk in
Sacramento who never learns anything about it — and that form is the only thing
standing between Frank and a very short career. At the promoter's licence check
the "I'm licensed" reply is gated on having filed it; skip the form and the
single visible option is the fatal one. Bureaucracy as the thing that saves you
is the exact inversion the campaign is built on.

Act I cases now share a sprite set: 18 PNGs copied from case-01, 14 new ones
generated. tools/sprites.py skips keys beginning with "_" so sprites.json can
carry a note about which sprites are shared.

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

179 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 not n.startswith("_") # "_comment" etc. are notes, not sprites
and (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()