#!/usr/bin/env python3 """Turn a YAML plan spec (plans/*.yaml) into a PRUNplanner plan, via the Api-Key API. DEFAULT IS DRY RUN. Guardrails (docs/decisions.md): only plans named '[PuGa] ...' are created/updated; deletes only [PuGa] plans and only when asked; --update refuses if the existing plan's name does not start with '[PuGa]'. Show the dry-run to the user and get a yes before --apply. tools/plan_push.py list tools/plan_push.py plans/examples/base_plus_hwp.yaml # dry run: validated payload summary tools/plan_push.py plans/examples/base_plus_hwp.yaml --json # full JSON payload tools/plan_push.py plans/examples/base_plus_hwp.yaml --apply # create (after user says yes) tools/plan_push.py plans/examples/base_plus_hwp.yaml --apply --update tools/plan_push.py delete # only [PuGa] plans, only when the user asks Spec: name, planet (natural id), permits, cogc (e.g. METALLURGY or null), hq, experts {METALLURGY: 2}, lux {pioneer: [true,true]}, infrastructure {HB1: 4, HB2: 1}, buildings: [{building: SME, amount: 5, recipes: ["ALO,FLX,C,O=>4AL"|"AL,STL,HE=>BHP"|"EXT#ALO"]}] A recipe is an exact PRUNplanner recipe_id, an extraction id like EXT#ALO, or 'IN,IN=>OUT' (ticker sets, matched within the building). """ import argparse, json, sys from pathlib import Path import yaml sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from puga import prunplanner as pp EXPERTS = ["Agriculture", "Chemistry", "Construction", "Electronics", "Food_Industries", "Fuel_Refining", "Manufacturing", "Metallurgy", "Resource_Extraction"] COGC = {"AGRICULTURE", "CHEMISTRY", "CONSTRUCTION", "ELECTRONICS", "FOOD_INDUSTRIES", "FUEL_REFINING", "MANUFACTURING", "METALLURGY", "RESOURCE_EXTRACTION", "PIONEERS", "SETTLERS", "TECHNICIANS", "ENGINEERS", "SCIENTISTS"} TIERS = ["pioneer", "settler", "technician", "engineer", "scientist"] PREFIX = "[PuGa]" def resolve_recipe(spec: str, building: str, recipes: list[dict]) -> str: rs = [r for r in recipes if r["building_ticker"] == building] if any(r["recipe_id"] == spec for r in rs) or spec.startswith(building + "#") and "=>" not in spec: return spec # exact id, or extraction id (EXT#ALO) if "=>" not in spec: raise ValueError(f"cannot parse recipe '{spec}'") left, right = spec.split("=>") want_in = {x.strip().split("x")[-1] if x.strip()[0].isdigit() and "x" in x else x.strip() for x in left.split(",") if x.strip()} want_out = {x.strip().split("x")[-1] if x.strip()[0].isdigit() and "x" in x else x.strip() for x in right.split(",") if x.strip()} # tolerate amount prefixes like '4AL' / '4xAL' strip = lambda s: {"".join(ch for ch in x.lstrip("0123456789x")) for x in s} want_in, want_out = strip(want_in), strip(want_out) hits = [r for r in rs if {i["material_ticker"] for i in r["inputs"]} == want_in and {o["material_ticker"] for o in r["outputs"]} == want_out] if len(hits) != 1: raise ValueError(f"recipe '{spec}' at {building}: {len(hits)} matches" + (": " + ", ".join(h["recipe_id"] for h in hits) if hits else "")) return hits[0]["recipe_id"] def build_payload(spec: dict, recipes: list[dict], building_tickers: set[str]) -> dict: name = spec["name"] if not name.startswith(PREFIX): raise ValueError(f"plan name must start with '{PREFIX}' (guardrail); got '{name}'") planet = spec["planet"] if len(planet) != 7: raise ValueError("planet natural id must be 7 chars, e.g. ZV-759c") permits = int(spec.get("permits", 1)) if not 0 <= permits <= 3: raise ValueError("permits must be 0..3") cogc = spec.get("cogc") if cogc is not None and cogc.upper() not in COGC: raise ValueError(f"cogc '{cogc}' not one of {sorted(COGC)}") experts = {k.upper(): v for k, v in (spec.get("experts") or {}).items()} lux = spec.get("lux") or {} buildings = [] for b in spec["buildings"]: tk = b["building"] if tk not in building_tickers: raise ValueError(f"unknown building {tk}") recs = [{"recipeid": resolve_recipe(r, tk, recipes), "amount": 1} for r in b.get("recipes", [])] buildings.append({"name": tk, "amount": int(b["amount"]), "active_recipes": recs}) infra = [{"building": k, "amount": int(v)} for k, v in (spec.get("infrastructure") or {}).items()] return { "plan_name": name, "planet_natural_id": planet, "plan_permits_used": permits, "plan_cogc": cogc.upper() if cogc else None, "plan_corphq": bool(spec.get("hq", False)), "plan_data": { "experts": [{"type": e, "amount": int(experts.get(e.upper(), 0))} for e in EXPERTS], "workforce": [{"type": t, "lux1": bool(lux.get(t, [True, True])[0]), "lux2": bool(lux.get(t, [True, True])[1])} for t in TIERS], "infrastructure": infra, "buildings": buildings}} def main(): ap = argparse.ArgumentParser() ap.add_argument("spec", help="plans/*.yaml, 'list', or 'delete'") ap.add_argument("target", nargs="?", help="uuid for 'delete'") ap.add_argument("--apply", action="store_true", help="actually write to PRUNplanner (creates a new plan)") ap.add_argument("--update", metavar="UUID", help="with --apply: update this existing [PuGa] plan instead of creating") ap.add_argument("--json", action="store_true") a = ap.parse_args() if a.spec == "list": for p in pp.request("GET", "/planning/plan/"): print(p["uuid"], p.get("plan_name"), p.get("planet_natural_id")) return if a.spec == "delete": # delete is allowed only on request, and only for plans this tool made ([PuGa] prefix). cur = pp.request("GET", f"/planning/plan/{a.target}/") if not str(cur.get("plan_name", "")).startswith(PREFIX): sys.exit(f"REFUSED: '{cur.get('plan_name')}' is not a {PREFIX} plan") pp.request("DELETE", f"/planning/plan/{a.target}/") print("DELETED:", cur["plan_name"], a.target) return spec = yaml.safe_load(Path(a.spec).read_text()) payload = build_payload(spec, pp.recipes(), {b["building_ticker"] for b in pp.buildings()}) d = payload["plan_data"] print(f"PLAN {payload['plan_name']} planet {payload['planet_natural_id']} permits {payload['plan_permits_used']} cogc {payload['plan_cogc']} hq {payload['plan_corphq']}") print("experts:", {e["type"]: e["amount"] for e in d["experts"] if e["amount"]} or "-") print("infrastructure:", {i["building"]: i["amount"] for i in d["infrastructure"]} or "-") for b in d["buildings"]: print(f" {b['amount']:>3} x {b['name']:4}", [r["recipeid"] for r in b["active_recipes"]]) if a.json: print(json.dumps(payload, indent=1)) if not a.apply: print("\nDRY RUN: nothing sent. Re-run with --apply after the user confirms.") return if a.update: cur = pp.request("GET", f"/planning/plan/{a.update}/") if not str(cur.get("plan_name", "")).startswith(PREFIX): sys.exit(f"REFUSED: existing plan '{cur.get('plan_name')}' is not a {PREFIX} plan") out = pp.request("PUT", f"/planning/plan/{a.update}/", payload) else: out = pp.request("POST", "/planning/plan/", payload) print("WRITTEN:", out.get("uuid") if isinstance(out, dict) else out) if __name__ == "__main__": main()