Initial PuGa toolkit: data layer, econ, depth-aware scan, state sync, plan push
- puga/: cached FIO + PRUNplanner clients, market view with order-book walk, econ formulas ported from PRUNplanner (tested against its suite and live FIO), saturation model v1 (reviewed by Opus) - tools/: scan (depth-aware), price, book, chain, state sync, plan_push (dry run default, [PuGa]-prefixed plans only), legacy prun_scan/prun_cxarb - docs/: mechanics (PRUNplanner is source of truth), roadmap, decisions, saturation design, archived handoff - secrets stay in .env (gitignored); ref/ holds PRUNplanner source (ignored) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/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; never delete;
|
||||
--update refuses if the existing plan's name does not start with '[PuGa]'. Show the dry-run to Dominik and get a yes before --apply.
|
||||
|
||||
tools/plan_push.py list
|
||||
tools/plan_push.py plans/deimos_bhp.yaml # dry run: validated payload summary
|
||||
tools/plan_push.py plans/deimos_bhp.yaml --json # full JSON payload
|
||||
tools/plan_push.py plans/deimos_bhp.yaml --apply # create (after user says yes)
|
||||
tools/plan_push.py plans/deimos_bhp.yaml --apply --update <uuid>
|
||||
|
||||
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 or 'list'")
|
||||
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
|
||||
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 Dominik 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()
|
||||
Reference in New Issue
Block a user