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:
2026-09-18 23:00:21 +02:00
co-authored by Claude Sonnet 5
commit 7a538cb300
35 changed files with 1692 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Scan every Prosperous Universe recipe for single-step profit:
buy all inputs at exchange ask, sell all outputs at exchange bid.
Uses the public FIO REST API (rest.fnar.net), no auth needed.
Examples:
python prun_scan.py # AI1, all tiers, top 40 by ROI
python prun_scan.py --cx NC1 --tier P # Moria, pioneer-only buildings
python prun_scan.py --sort net --depth 60 --top 20
python prun_scan.py --cross # buy at cheapest CX, sell at best CX (ignores shipping)
"""
import argparse, json, urllib.request, sys
FIO = "https://rest.fnar.net"
EXCH = ["AI1", "NC1", "CI1", "IC1", "NC2", "CI2"]
TIERS = ["Pioneers", "Settlers", "Technicians", "Engineers", "Scientists"]
TIER_CODE = {"Pioneers": "P", "Settlers": "S", "Technicians": "T", "Engineers": "E", "Scientists": "Sc"}
HAB = {"Pioneers": ("HB1", 100), "Settlers": ("HB2", 75), "Technicians": ("HB3", 75),
"Engineers": ("HB4", 75), "Scientists": ("HB5", 75)}
def get(path):
with urllib.request.urlopen(f"{FIO}{path}", timeout=60) as r:
return json.load(r)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--cx", default="AI1", help="exchange code (AI1 NC1 CI1 IC1 NC2 CI2)")
ap.add_argument("--cross", action="store_true", help="buy at cheapest ask across all CX, sell at best bid across all CX")
ap.add_argument("--tier", default=None, help="max workforce tier: P, S, T, E, Sc")
ap.add_argument("--depth", type=float, default=30, help="min days of open demand for one building's output")
ap.add_argument("--sort", default="roi", choices=["roi", "net", "area"])
ap.add_argument("--top", type=int, default=40)
ap.add_argument("--mcg", type=float, default=4.0, help="MCG per area (4 on normal rocky planets)")
ap.add_argument("--understaff", action="store_true", help="also evaluate running without some worker tiers (no housing/wages for them)")
ap.add_argument("--penalty", type=float, default=1.0, help="efficiency = (staffed headcount share) ** penalty; 1 = proportional")
a = ap.parse_args()
print("fetching FIO data...", file=sys.stderr)
recipes = get("/recipes/allrecipes")
buildings = {b["Ticker"]: b for b in get("/building/allbuildings")}
exch = get("/exchange/all")
needs = get("/global/workforceneeds")
px = {}
for e in exch:
px.setdefault(e["MaterialTicker"], {})[e["ExchangeCode"]] = e
def ask(t):
if a.cross:
v = [px[t][c]["Ask"] for c in px.get(t, {}) if px[t][c].get("Ask")]
return min(v) if v else None
return (px.get(t, {}).get(a.cx) or {}).get("Ask")
def bid(t):
if a.cross:
v = [px[t][c]["Bid"] for c in px.get(t, {}) if px[t][c].get("Bid")]
return max(v) if v else None
return (px.get(t, {}).get(a.cx) or {}).get("Bid")
def demand(t):
if a.cross:
return sum((px[t][c].get("Demand") or 0) for c in px.get(t, {}))
return (px.get(t, {}).get(a.cx) or {}).get("Demand") or 0
# wages per worker per day from consumable needs at ask
wage = {}
for w in needs:
wage[w["WorkforceType"]] = sum(n["Amount"] * (ask(n["MaterialTicker"]) or 0) for n in w["Needs"]) / 100
def bcost(tk):
b = buildings[tk]
c = sum(x["Amount"] * (ask(x["CommodityTicker"]) or 0) for x in b["BuildingCosts"])
return c + a.mcg * b["AreaCost"] * (ask("MCG") or 0)
hab_cost = {t: bcost(h) / n for t, (h, n) in HAB.items()}
hab_area = {t: buildings[h]["AreaCost"] / n for t, (h, n) in HAB.items()}
tier_rank = {"P": 0, "S": 1, "T": 2, "E": 3, "Sc": 4}
rows = []
for rec in recipes:
b = buildings.get(rec["BuildingTicker"])
if not b or not rec["Outputs"]:
continue
top = "P"
for t in TIERS:
if b[t]:
top = TIER_CODE[t]
if a.tier and tier_rank[top] > tier_rank[a.tier] and not a.understaff:
continue
ins, outs = rec["Inputs"], rec["Outputs"]
if any(ask(i["Ticker"]) is None for i in ins) or any(bid(o["Ticker"]) is None for o in outs):
continue
per_day = 24 / (rec["TimeMs"] / 3.6e6)
cin = sum(i["Amount"] * ask(i["Ticker"]) for i in ins) * per_day
cout = sum(o["Amount"] * bid(o["Ticker"]) for o in outs) * per_day
outq = sum(o["Amount"] for o in outs) * per_day
dep = min(demand(o["Ticker"]) for o in outs) / outq
if dep < a.depth:
continue
used = [t for t in TIERS if b[t]]
total_heads = sum(b[t] for t in used)
import itertools
variants = [tuple(used)]
if a.understaff and len(used) > 1:
# drop any non-empty subset of tiers, but never drop all
for k in range(1, len(used)):
for keep in itertools.combinations(used, k):
variants.append(keep)
for keep in variants:
eff = (sum(b[t] for t in keep) / total_heads) ** a.penalty
wages = sum(b[t] * wage[t.upper()[:-1] if t != "Pioneers" else "PIONEER"] for t in keep)
net = (cout - cin) * eff - wages
capex = bcost(b["Ticker"]) + sum(b[t] * hab_cost[t] for t in keep)
area = b["AreaCost"] + sum(b[t] * hab_area[t] for t in keep)
staff = "".join(TIER_CODE[t] for t in keep) + ("" if len(keep) == len(used) else "-")
if a.tier and any(tier_rank[TIER_CODE[t]] > tier_rank[a.tier] for t in keep):
continue
rows.append(dict(roi=100 * net / capex, net=net, area=net / area, capex=capex, tier=staff,
bld=b["Ticker"], exp=(b["Expertise"] or "")[:5],
ins=" ".join(f"{i['Amount']}{i['Ticker']}" for i in ins),
outs=" ".join(f"{o['Amount']}{o['Ticker']}" for o in outs),
h=rec["TimeMs"] / 3.6e6, depth=dep, eff=eff))
rows.sort(key=lambda r: r[a.sort], reverse=True)
print(f"# {'cross-CX' if a.cross else a.cx} tier<={a.tier or 'any'} depth>={a.depth}d sorted by {a.sort}")
print(f"{'ROI%':>6} {'net/d':>8} {'net/area':>8} {'capex':>8} {'staff':6} {'eff':>4} {'bld':4} {'exp':5} {'h':>5} {'depth':>6} recipe")
for r in rows[:a.top]:
print(f"{r['roi']:6.1f} {r['net']:8.0f} {r['area']:8.0f} {r['capex']:8.0f} {r['tier']:6} {r['eff']*100:4.0f} {r['bld']:4} {r['exp']:5} "
f"{r['h']:5.1f} {r['depth']:6.0f} {r['ins']} -> {r['outs']}")
if __name__ == "__main__":
main()