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>
124 lines
4.6 KiB
Python
124 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""cf_flux.py — FLUX-1-schnell images from Cloudflare Workers AI.
|
|
|
|
python3 tools/gen/cf_flux.py <out.png> "<prompt>" [--steps 6] [--seed 42]
|
|
python3 tools/gen/cf_flux.py --probe # is there budget left today?
|
|
|
|
Workers AI gives 10,000 Neurons free every day (resets 00:00 UTC), which is the
|
|
cheapest image generation on the fleet — so it goes FIRST, ahead of the farm.
|
|
When it runs dry, callers fall back to MODELBEAST `flux_local`, which is slower
|
|
but genuinely unlimited.
|
|
|
|
The whole reason this exists rather than the one-liner in toastsim: urllib
|
|
raises HTTPError on 4xx and throws the response BODY away, so a 429 is
|
|
indistinguishable between "you have burned today's neurons, go to the farm" and
|
|
"you sent three at once, wait a second". Those want opposite responses, and
|
|
guessing wrong either wastes the free tier or wedges the batch. Here the body is
|
|
read and classified.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import creds # noqa: E402
|
|
|
|
MODEL = "@cf/black-forest-labs/flux-1-schnell"
|
|
# schnell is a distilled 4-step model; past ~8 steps you pay neurons for nothing.
|
|
DEFAULT_STEPS = 6
|
|
|
|
|
|
class Exhausted(RuntimeError):
|
|
"""Today's free neurons are gone. Fall back to the farm; retrying won't help."""
|
|
|
|
|
|
class RateLimited(RuntimeError):
|
|
"""Too fast, not too much. Backing off DOES help."""
|
|
|
|
|
|
def _post(prompt: str, steps: int, seed: int) -> bytes:
|
|
account = creds.get("CLOUDFLARE_ACCOUNT_ID")
|
|
token = creds.get("CLOUDFLARE_API_TOKEN")
|
|
req = urllib.request.Request(
|
|
f"https://api.cloudflare.com/client/v4/accounts/{account}/ai/run/{MODEL}",
|
|
data=json.dumps({"prompt": prompt, "steps": steps, "seed": seed}).encode(),
|
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
raw = urllib.request.urlopen(req, timeout=180).read()
|
|
except urllib.error.HTTPError as exc:
|
|
body = exc.read().decode(errors="ignore")[:600]
|
|
low = body.lower()
|
|
if exc.code == 429:
|
|
# Cloudflare says "capacity temporarily exceeded" for burst limits and
|
|
# names the neuron/daily allocation when the free tier is spent.
|
|
if "neuron" in low or "daily" in low or "limit" in low and "rate" not in low:
|
|
raise Exhausted(body) from None
|
|
raise RateLimited(body) from None
|
|
if exc.code in (401, 403):
|
|
raise SystemExit(f"Cloudflare rejected the token (HTTP {exc.code}): {body}")
|
|
raise SystemExit(f"Cloudflare HTTP {exc.code}: {body}") from None
|
|
|
|
payload = json.loads(raw)
|
|
if not payload.get("success"):
|
|
errors = json.dumps(payload.get("errors"))
|
|
if "neuron" in errors.lower() or "limit" in errors.lower():
|
|
raise Exhausted(errors)
|
|
raise SystemExit("Cloudflare error: " + errors)
|
|
return base64.b64decode(payload["result"]["image"])
|
|
|
|
|
|
def generate(out: Path, prompt: str, steps: int = DEFAULT_STEPS, seed: int = 0,
|
|
*, attempts: int = 4) -> Path:
|
|
"""One image to `out`. Retries burst limits, gives up fast on exhaustion."""
|
|
delay = 2.0
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_bytes(_post(prompt, steps, seed))
|
|
return out
|
|
except RateLimited:
|
|
if attempt == attempts:
|
|
raise
|
|
time.sleep(delay)
|
|
delay *= 2
|
|
raise RateLimited("exhausted retries")
|
|
|
|
|
|
def probe() -> bool:
|
|
"""Cheapest possible call — is there budget left right now?"""
|
|
try:
|
|
_post("a grey square", 1, 1)
|
|
return True
|
|
except Exhausted:
|
|
return False
|
|
except RateLimited:
|
|
# Burst-limited means the account is alive and has budget.
|
|
return True
|
|
|
|
|
|
def main() -> None:
|
|
if "--probe" in sys.argv:
|
|
ok = probe()
|
|
print("cloudflare flux: BUDGET AVAILABLE" if ok else "cloudflare flux: EXHAUSTED for today")
|
|
sys.exit(0 if ok else 3)
|
|
if len(sys.argv) < 3:
|
|
sys.exit(__doc__)
|
|
out, prompt = Path(sys.argv[1]), sys.argv[2]
|
|
steps = int(sys.argv[sys.argv.index("--steps") + 1]) if "--steps" in sys.argv else DEFAULT_STEPS
|
|
seed = int(sys.argv[sys.argv.index("--seed") + 1]) if "--seed" in sys.argv else 0
|
|
try:
|
|
print(generate(out, prompt, steps, seed))
|
|
except Exhausted as exc:
|
|
sys.exit(f"cloudflare free tier exhausted for today — use MODELBEAST flux_local.\n{exc}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|