| |
| """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 ( |
| 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()) |
|
|