#!/usr/bin/env python3 """Generate a parkour map locally and save it to app/maps/. The CLI drives the same mapgen path that the app server uses. The LLM is optional: if MAPGEN_LLM_API_KEY (or OPENROUTER_API_KEY) is set, the LLM is asked to extract knobs/features from the vibe prompt. Otherwise the heuristic keyword extractor in app.app runs. Either way, the actual map is built by `configure_motif_mapgen(**params) + FastFloatingBeaconEnv().reset()`, so the saved JSON is in-distribution with training and any LLM OOD-ness is just extreme knob combinations. Usage: uv run python scripts/generate_local_map.py --vibe "spiral with false summits" --difficulty standard uv run python scripts/generate_local_map.py --vibe "" --difficulty cursed --seed 17 uv run python scripts/generate_local_map.py --vibe "..." --difficulty standard --llm uv run python scripts/generate_local_map.py --list uv run python scripts/generate_local_map.py --open """ from __future__ import annotations import argparse import json import os import random import sys import time import webbrowser from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from app.app import ( # noqa: E402 MAPS_DIR, _build_map_snapshot_from_env, _build_vibe_plan, _difficulty_spec, _knobs, _llm_in_use, _make_env, _mapgen_llm_report, _save_map_snapshot, ) def _list() -> None: entries = sorted(MAPS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) if not entries: print(f"(no maps in {MAPS_DIR})") return for path in entries: try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): print(f" ! {path.name}: unreadable") continue tags = ",".join(payload.get("tags", []) or []) print( f" {path.name} diff={payload.get('difficulty', '?'):>8} " f"seed={payload.get('seed', 0):>6} " f"platforms={len(payload.get('platforms', []) or []):>3} " f"tags={tags}" ) def _generate( vibe: str, difficulty: str, seed: int | None, knobs: dict[str, float], name: str | None, *, use_llm_direct: bool = False, ) -> dict[str, object]: spec = _difficulty_spec(difficulty) if seed is None: seed = int.from_bytes(os.urandom(4), "little") % 1_000_000 route_jumps = int(spec["route_jumps"]) + int(round(knobs["length"] * 4.0)) distractors = int(spec["distractors"]) + int(round(knobs["length"] * 6.0)) if use_llm_direct: from app.app import _apply_direct_platforms, _request_direct_platforms_with_llm result = _request_direct_platforms_with_llm(vibe, str(spec["key"]), route_jumps, distractors) if not result.get("ok"): raise RuntimeError(f"LLM did not produce a usable platform list: {result.get('reason')}") env = _make_env( seed=int(seed), route_jumps=route_jumps, distractors=distractors, direct_platforms=result, ) vibe_plan = { "theme": "llm direct platforms", "mood": "out of distribution", "tags": ["llm-direct", "ood"], "knobs": {}, "params": {}, "raw_vibe": str(vibe or "").strip(), "direct_platforms": { "source": "llm", "route_count": len(result.get("route", [])), "extras_count": len(result.get("extras", [])), }, } generation_mode = "llm_direct" else: vibe_plan = _build_vibe_plan(vibe, str(spec["key"]), knobs) params = vibe_plan.get("params", {}) if not params: raise RuntimeError("vibe plan produced no mapgen params") env = _make_env( seed=int(seed), route_jumps=route_jumps, distractors=distractors, motif_params=params, ) env.reset() generation_mode = "procedural" snapshot = _build_map_snapshot_from_env( env, difficulty_key=str(spec["key"]), seed=int(seed), vibe_plan=vibe_plan, name=name, ) _save_map_snapshot(snapshot) return { "map_id": snapshot["id"], "seed": int(seed), "difficulty": str(spec["key"]), "route_jumps": route_jumps, "distractors": distractors, "platforms": len(snapshot["platforms"]), "tags": snapshot["tags"], "theme": snapshot["theme"], "mood": snapshot["mood"], "knobs": vibe_plan.get("knobs", {}), "direct_platforms": vibe_plan.get("direct_platforms", {}), "vibe": vibe, "generation_mode": generation_mode, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--vibe", default="", help="Vibe prompt. Empty for the default heuristic.") parser.add_argument( "--difficulty", default="standard", choices=["rookie", "standard", "cursed", "nightmare"], ) parser.add_argument("--seed", type=int, default=None, help="Deterministic seed (default: random).") parser.add_argument("--name", default=None, help="Map display name (default: 'Difficulty seed').") parser.add_argument("--verticality", type=float, default=0.5) parser.add_argument("--traps", type=float, default=0.5) parser.add_argument("--loopiness", type=float, default=0.5) parser.add_argument("--precision", type=float, default=0.5) parser.add_argument("--length", type=float, default=0.5) parser.add_argument("--llm", action="store_true", help="Force the LLM path even if no vibe is given.") parser.add_argument("--no-llm", action="store_true", help="Force the heuristic path even if the LLM is configured.") parser.add_argument("--list", action="store_true", help="List cached maps in app/maps/ and exit.") parser.add_argument("--open", action="store_true", help="Open the saved map in the running app after generation.") parser.add_argument("--json", action="store_true", help="Emit the result as JSON to stdout.") args = parser.parse_args() if args.list: _list() return 0 if args.no_llm and "MAPGEN_LLM" not in os.environ: os.environ["MAPGEN_LLM"] = "off" if args.llm and not _llm_in_use(): report = _mapgen_llm_report() print( f"LLM not active: mode={report['mode']} configured={report['configured']} " f"model={report['model']}. Set MAPGEN_LLM_API_KEY (or OPENROUTER_API_KEY) " f"and MAPGEN_LLM=auto.", file=sys.stderr, ) if not args.vibe: print("Add --vibe 'your prompt' to give the LLM something to interpret.", file=sys.stderr) return 1 knobs = _knobs(args.verticality, args.traps, args.loopiness, args.precision, args.length) result = _generate( vibe=args.vibe, difficulty=args.difficulty, seed=args.seed, knobs=knobs, name=args.name, use_llm_direct=bool(args.llm), ) if args.json: print(json.dumps(result, indent=2, default=str)) else: mode = result.get("generation_mode", "procedural") print( f"generated map {result['map_id']} diff={result['difficulty']} seed={result['seed']} " f"route_jumps={result['route_jumps']} distractors={result['distractors']} " f"platforms={result['platforms']} mode={mode}" ) print(f" tags: {','.join(result['tags'])}") print(f" theme: {result['theme']} mood: {result['mood']}") if result.get("direct_platforms"): dp = result["direct_platforms"] print(f" direct_platforms: source={dp.get('source')} model={dp.get('model')} route={dp.get('route_count')} extras={dp.get('extras_count')}") if args.vibe: print(f" vibe: {args.vibe}") if args.open: url = os.environ.get("PARKOUR_OPEN_URL", "http://127.0.0.1:7860") map_url = f"{url}/?map={result['map_id']}" print(f"opening {map_url}") try: webbrowser.open(map_url) except Exception as exc: print(f"could not open browser: {exc}", file=sys.stderr) return 0 if __name__ == "__main__": raise SystemExit(main())