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
Executable
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Order-book ladder for one material at one exchange: price levels with cumulative units and value.
tools/book.py BHP --cx AI1 --levels 8
"""
import argparse, sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import config, fio
def ladder(orders, reverse):
lv = defaultdict(int)
for o in orders:
if o.get("ItemCount") != 0:
lv[o["ItemCost"]] += 10**9 if o.get("ItemCount") is None else o["ItemCount"] # 10**9 = market maker, unlimited
cum, out = 0, []
for price in sorted(lv, reverse=reverse):
cum += lv[price]
out.append((price, lv[price], cum))
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("ticker")
ap.add_argument("--cx", default=config.DEFAULT_CX)
ap.add_argument("--levels", type=int, default=8)
ap.add_argument("--refresh", action="store_true")
a = ap.parse_args()
t = a.ticker.upper()
ob = fio.order_book(t, a.cx, a.refresh)
asks, bids = ladder(ob["SellingOrders"], False), ladder(ob["BuyingOrders"], True)
print(f"{t}.{a.cx} ask {ob.get('Ask')} bid {ob.get('Bid')} supply {ob.get('Supply')} demand {ob.get('Demand')} "
f"MMBuy {ob.get('MMBuy')} MMSell {ob.get('MMSell')}")
print(f"{'ASKS':>8} {'units':>7} {'cum':>7} | {'BIDS':>8} {'units':>7} {'cum':>7}")
for i in range(a.levels):
l = f"{asks[i][0]:8.0f} {asks[i][1]:7d} {asks[i][2]:7d}" if i < len(asks) else " " * 24
r = f"{bids[i][0]:8.0f} {bids[i][1]:7d} {bids[i][2]:7d}" if i < len(bids) else ""
print(f"{l} | {r}")
if __name__ == "__main__":
main()
Executable
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Make-vs-buy cost tree for a material at one exchange, plus sourcing depth for every raw input.
Cost to make = inputs at min(buy, make) per output unit; wages/capex excluded unless --wages.
tools/chain.py DEC # AI1
tools/chain.py BHP --depth 3 --qty 13
"""
import argparse, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import config, econ, fio, market, saturation as sat
def main():
ap = argparse.ArgumentParser()
ap.add_argument("ticker")
ap.add_argument("--cx", default=config.DEFAULT_CX)
ap.add_argument("--depth", type=int, default=2, help="levels of make-vs-buy expansion")
ap.add_argument("--qty", type=float, default=1.0, help="units/day you would make (sourcing check)")
ap.add_argument("--wages", action="store_true", help="add wage cost per output unit (both luxuries at ask)")
ap.add_argument("--cogc")
a = ap.parse_args()
snap = market.snapshot()
Q = lambda t: snap.get((t, a.cx))
blds = {b["Ticker"]: b for b in fio.buildings()}
by_out = {}
for r in fio.recipes():
for o in r["Outputs"]:
by_out.setdefault(o["Ticker"], []).append(r)
def wage_per_day(b):
tot = 0.0
for t in econ.TIERS:
n = b[t.capitalize() + "s"]
if n:
tot += n * sum(need / 100 * (Q(tk).ask if Q(tk) and Q(tk).ask else 0) for tk, need, _, _ in econ.CONSUMPTION[t])
return tot
def buy(t):
q = Q(t)
return q.ask if q and q.ask else None
memo = {}
def make_cost(t, depth, stack=()):
"""(best unit cost, how). Compares buy vs best recipe."""
key = (t, depth)
if key in memo:
return memo[key]
b = buy(t)
best = (b if b is not None else float("inf"), "buy" if b is not None else "n/a", None)
if depth > 0 and t not in stack:
for r in by_out.get(t, []):
bld = blds.get(r["BuildingTicker"])
if not bld:
continue
cost = 0.0
for i in r["Inputs"]:
c, _, _ = make_cost(i["Ticker"], depth - 1, stack + (t,))
cost += i["Amount"] * c
outs = sum(o["Amount"] for o in r["Outputs"] if o["Ticker"] == t)
if outs == 0:
continue
if a.wages:
eff, _ = econ.building_efficiency({x.lower() + "s": bld[x + "s"] for x in ("Pioneer", "Settler", "Technician", "Engineer", "Scientist")},
{x: 1.0 for x in econ.TIERS}, expertise=bld["Expertise"] or None, cogc=a.cogc)
cost += wage_per_day(bld) * (r["TimeMs"] / 86400e3) / eff
unit = cost / outs
if unit < best[0]:
best = (unit, f"make {r['BuildingTicker']} {r['TimeMs']/3.6e6:.1f}h", r)
memo[key] = best
return best
def show(t, depth, qty, indent=0):
c, how, r = make_cost(t, depth)
q = Q(t)
pad = " " * indent
mk = f"ask {q.ask:.0f} bid {q.bid:.0f} sup {q.supply:.0f} dem {q.demand:.0f} flow {sat.tref(q.traded7, q.traded30):.1f}/d" if q and q.ask and q.bid else "no market"
print(f"{pad}{t:5} x{qty:<8.1f} best {c:9.0f}/u via {how:18} | {mk}")
if r and how.startswith("make") and depth > 0:
outs = sum(o["Amount"] for o in r["Outputs"] if o["Ticker"] == t)
for i in r["Inputs"]:
show(i["Ticker"], depth - 1, qty * i["Amount"] / outs, indent + 1)
show(a.ticker.upper(), a.depth, a.qty)
print("\n# sourcing check for buy-leaves at", a.qty, "output units/day: ask-walk price for one day of inputs")
q0 = Q(a.ticker.upper())
_, _, r = make_cost(a.ticker.upper(), a.depth)
if r:
outs = sum(o["Amount"] for o in r["Outputs"] if o["Ticker"] == a.ticker.upper())
for i in r["Inputs"]:
need = a.qty * i["Amount"] / outs
w = market.walk(i["Ticker"], a.cx, need, "buy")
x = Q(i["Ticker"])
days = x.supply / need if x and need else 0
print(f" {i['Ticker']:5} need {need:8.1f}/d ask {x.ask if x else None} walked avg {w['avg'] and round(w['avg'])} short={w['short']} standing supply = {days:.0f} days of your use, market flow {sat.tref(x.traded7, x.traded30):.1f}/d")
if __name__ == "__main__":
main()
+116
View File
@@ -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()
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Prices for materials across all exchanges, with VWAP and daily traded volume; optional fill price for a quantity.
tools/price.py BHP STL # all CX
tools/price.py BHP --cx AI1 --qty 200 # avg fill price to buy/sell 200 at AI1 (walks the live book)
"""
import argparse, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import config, market
def f(x, w=7):
return f"{x:{w}.0f}" if x is not None else f"{'-':>{w}}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("tickers", nargs="+")
ap.add_argument("--cx", help="limit to one exchange")
ap.add_argument("--qty", type=float, help="show avg fill price for this quantity (needs --cx or uses default CX)")
ap.add_argument("--refresh", action="store_true")
a = ap.parse_args()
snap = market.snapshot(a.refresh)
print(f"{'mat':5} {'cx':4} {'bid':>7} {'ask':>7} {'vwap7':>7} {'vwap30':>7} {'trd/d7':>7} {'trd/d30':>7} {'demand':>7} {'supply':>7}")
for t in map(str.upper, a.tickers):
for cx in market.CXS:
if a.cx and cx != a.cx:
continue
q = snap.get((t, cx))
if q:
print(f"{t:5} {cx:4} {f(q.bid)} {f(q.ask)} {f(q.vwap7)} {f(q.vwap30)} {q.traded7:7.1f} {q.traded30:7.1f} {q.demand:7.0f} {q.supply:7.0f}")
if a.qty:
cx = a.cx or config.DEFAULT_CX
b, s = market.walk(t, cx, a.qty, "buy"), market.walk(t, cx, a.qty, "sell")
print(f" fill {a.qty:.0f} @ {cx}: buy avg {f(b['avg'])} (worst {f(b['worst'])}, filled {b['filled']:.0f}) | "
f"sell avg {f(s['avg'])} (worst {f(s['worst'])}, filled {s['filled']:.0f})")
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Pure exchange arbitrage: for each material, cheapest ask at any CX vs best bid at any other CX.
Reports spread per unit, per ton and per m3 of cargo, and how many units the thinner side can absorb.
python prun_cxarb.py --from AI1 # only routes buying at AI1
python prun_cxarb.py --to AI1 # only routes selling at AI1
python prun_cxarb.py --minqty 200 --sort ton
"""
import argparse, json, urllib.request
FIO="https://rest.fnar.net"
def get(p):
with urllib.request.urlopen(FIO+p,timeout=60) as r: return json.load(r)
ap=argparse.ArgumentParser()
ap.add_argument("--from",dest="src",default=None); ap.add_argument("--to",dest="dst",default=None)
ap.add_argument("--minqty",type=float,default=100,help="min units both sides can absorb")
ap.add_argument("--sort",default="ton",choices=["unit","ton","m3","total","pct"]); ap.add_argument("--top",type=int,default=30)
a=ap.parse_args()
mats={m["Ticker"]:m for m in get("/material/allmaterials")}
px={}
for e in get("/exchange/all"): px.setdefault(e["MaterialTicker"],{})[e["ExchangeCode"]]=e
rows=[]
for t,cxs in px.items():
for s,es in cxs.items():
if a.src and s!=a.src: continue
if not es.get("Ask"): continue
for d,ed in cxs.items():
if d==s or (a.dst and d!=a.dst) or not ed.get("Bid"): continue
# depth: units available at ask side / wanted at bid side (order book totals)
# /exchange/all has only book totals; use min(supply at source, demand at destination) as depth proxy
qty=min(es.get("Supply") or 0, ed.get("Demand") or 0)
if qty<a.minqty: continue
spread=ed["Bid"]-es["Ask"]
if spread<=0: continue
w=mats[t]["Weight"]; v=mats[t]["Volume"]
rows.append(dict(t=t,src=s,dst=d,ask=es["Ask"],bid=ed["Bid"],unit=spread,pct=100*spread/es["Ask"],ton=spread/w if w else 0,m3=spread/v if v else 0,qty=qty,total=spread*qty))
rows.sort(key=lambda r:r[a.sort],reverse=True)
print(f"{'mat':4} {'buy@':4} {'sell@':5} {'ask':>8} {'bid':>8} {'spread':>7} {'%':>5} {'/ton':>7} {'/m3':>7} {'qty':>7} {'total':>9}")
for r in rows[:a.top]:
print(f"{r['t']:4} {r['src']:4} {r['dst']:5} {r['ask']:8.0f} {r['bid']:8.0f} {r['unit']:7.0f} {r['pct']:5.0f} {r['ton']:7.0f} {r['m3']:7.0f} {r['qty']:7.0f} {r['total']:9.0f}")
+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()
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Re-pull PRUNplanner source into ref/ (gitignored). Their code is the source of truth for game mechanics.
cd "$(dirname "$0")/.." || exit 1
rm -rf ref/frontend ref/backend
git clone -q --depth 1 https://github.com/PRUNplanner/frontend.git ref/frontend
git clone -q --depth 1 https://github.com/PRUNplanner/backend.git ref/backend
rm -rf ref/frontend/.git ref/backend/.git
echo "refs updated $(date -I)"
Executable
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Depth-aware single-step recipe scan at one exchange (saturation model v1, see docs/saturation-design.md).
For each recipe: buildings the market can absorb (N*), and ROI/day at N=1 and N* using a patient sell price
(not top-of-book) and ask-walked inputs. Excludes thin markets by default.
tools/scan.py # AI1, all tiers, top 30 by ROI at N*
tools/scan.py --cogc METALLURGY --tier S # Deimos-like: metallurgy COGC, up to settlers
tools/scan.py --sort total --min-n 3 # rank by absorbable profit/day
"""
import argparse, sys
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import ROOT, config, econ, fio, market, saturation as sat
TIER_CODE = {"pioneer": "P", "settler": "S", "technician": "T", "engineer": "E", "scientist": "Sc"}
TIER_RANK = {"P": 0, "S": 1, "T": 2, "E": 3, "Sc": 4}
def load_state():
p = ROOT / "state" / "company.yaml"
return yaml.safe_load(p.read_text()) if p.exists() else {}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--cx", default=config.DEFAULT_CX)
ap.add_argument("--tier", help="max workforce tier: P S T E Sc")
ap.add_argument("--cogc", help="COGC programme on the base, e.g. METALLURGY, or SETTLERS")
ap.add_argument("--skip", default="", help="tiers left unstaffed, e.g. technician (no housing/wages; efficiency = staffed headcount share)")
ap.add_argument("--experts", default="", help="expert counts, e.g. METALLURGY=2 (bonus x1.0306..1.284 for 1..5)")
ap.add_argument("--hq", action="store_true", help="corp HQ bonus x1.1")
ap.add_argument("--no-faction", action="store_true", help="ignore faction bonus from state/company.yaml")
ap.add_argument("--min-n", type=float, default=1.0, help="min buildings the market absorbs (default 1)")
ap.add_argument("--max-n", type=int, default=50, help="cap N* (capital/attention limit)")
ap.add_argument("--budget", type=float, help="capital limit in AIC: caps N and drops recipes whose single-building capex exceeds it")
ap.add_argument("--mcg", type=float, default=4.0, help="MCG per area (4 on normal planets)")
ap.add_argument("--sort", default="roi", choices=["roi", "total", "roi1"])
ap.add_argument("--top", type=int, default=30)
ap.add_argument("--k", type=int, default=80, help="candidates refined with order books")
ap.add_argument("--show-thin", action="store_true")
a = ap.parse_args()
snap = market.snapshot()
Q = lambda t: snap.get((t, a.cx))
ask = lambda t: (Q(t).ask if Q(t) else None)
st = load_state()
faction = None if a.no_faction else (st.get("company") or {}).get("faction")
pu, pt = (st.get("permits") or {}).get("used", 1), (st.get("permits") or {}).get("total", 2)
blds = {b["Ticker"]: b for b in fio.buildings()}
mcg = ask("MCG") or 0
def bcost(tk):
b = blds[tk]
parts = [(x["Amount"], ask(x["CommodityTicker"])) for x in b["BuildingCosts"]]
if any(p is None for _, p in parts):
return None
return sum(n * p for n, p in parts) + a.mcg * b["AreaCost"] * mcg
hab_head = {}
for tier, hab in (("pioneer", "HB1"), ("settler", "HB2"), ("technician", "HB3"), ("engineer", "HB4"), ("scientist", "HB5")):
c = bcost(hab)
hab_head[tier] = None if c is None else c / econ.HAB_CAP[hab][tier]
def wage(tier): # per worker per day, both luxuries supplied, at ask
tot = 0.0
for tk, need, _, _ in econ.CONSUMPTION[tier]:
p = ask(tk)
if p is None:
return None
tot += need / 100 * p
return tot
experts = {k.strip().upper(): int(v) for k, v in (x.split("=") for x in a.experts.split(",") if "=" in x)}
skip = {x.strip() for x in a.skip.split(",") if x.strip()}
wages = {t: wage(t) for t in econ.TIERS}
cands = []
for rec in fio.recipes():
b = blds.get(rec["BuildingTicker"])
if not b or not rec["Outputs"] or b["Ticker"].startswith("HB"):
continue
heads = {t: b[t.capitalize() + "s"] for t in econ.TIERS}
used = [t for t in econ.TIERS if heads[t] and t not in skip] # staffed tiers
if not used:
continue
top = TIER_CODE[used[-1]]
if a.tier and TIER_RANK[top] > TIER_RANK[a.tier]:
continue
if any(hab_head[t] is None or wages[t] is None for t in used):
continue
ins = {i["Ticker"]: i["Amount"] for i in rec["Inputs"]}
outs = {o["Ticker"]: o["Amount"] for o in rec["Outputs"]}
if any(not Q(t) or not Q(t).ask for t in ins) or any(not Q(t) or not Q(t).bid for t in outs):
continue
bd = {t + "s": heads[t] for t in econ.TIERS}
eff, _ = econ.building_efficiency(bd, {t: (0.0 if t in skip else 1.0) for t in econ.TIERS}, expertise=b["Expertise"] or None, cogc=a.cogc,
hq=a.hq, experts=experts, faction=faction, permits_used=pu, permits_total=pt)
io = econ.production_io([dict(time_ms=rec["TimeMs"], inputs=ins, outputs=outs)], eff, 1)
# saturation per output
n_lim, lim, thin, mm = float("inf"), "", False, {}
for t, qo in io["out"].items():
x = Q(t)
tr = sat.tref(x.traded7, x.traded30)
if x.mm_buy and x.bid and x.mm_buy >= 0.9 * x.bid:
mm[t] = x.mm_buy # market maker floor: unlimited depth at mm_buy
continue
thin |= sat.is_thin(tr, x.demand, qo)
n = sat.n_out(tr, 0, x.demand, qo) # stage 1: flow + demand only; queue penalty applied in stage 2 with the book
if n < n_lim:
n_lim, lim = n, t
if n_lim == float("inf"):
n_lim, lim = float(a.max_n), "MM"
if (thin or n_lim < a.min_n) and not a.show_thin:
continue
capex = bcost(b["Ticker"])
if capex is None:
continue
capex += sum(heads[t] * hab_head[t] for t in used)
wcost = sum(heads[t] * wages[t] for t in used)
price0 = {t: mm.get(t) or min(x for x in (Q(t).vwap7, Q(t).vwap30, Q(t).ask) if x) if (Q(t).vwap7 or Q(t).vwap30 or Q(t).ask) else Q(t).bid
for t in io["out"]}
rough = sum(io["out"][t] * price0[t] for t in io["out"]) - sum(io["in"][t] * ask(t) for t in io["in"]) - wcost
cands.append(dict(rec=rec, b=b, io=io, eff=eff, capex=capex, wcost=wcost, n_lim=n_lim, lim=lim, thin=thin, mm=mm,
rough_roi=100 * rough / capex, top=top, ins=ins, outs=outs))
cands.sort(key=lambda c: c["rough_roi"], reverse=True)
rows = []
book = {}
def asks_of(t):
if t not in book:
book[t] = sorted((o["ItemCost"], (o["ItemCount"] or 0)) for o in fio.order_book(t, a.cx)["SellingOrders"])
return book[t]
for c in cands[:a.k]:
io, mm = c["io"], c["mm"]
# stage 2: queue penalty from competing asks near market price only
n2 = float("inf")
for t, qo in io["out"].items():
if t in mm:
continue
x = Q(t)
se = sat.effective_supply(asks_of(t), x.vwap7 or x.ask)
n2 = min(n2, sat.n_out(sat.tref(x.traded7, x.traded30), se, x.demand, qo))
c["n_lim"] = float(a.max_n) if n2 == float("inf") else n2
if c["n_lim"] < a.min_n and not a.show_thin:
continue
def evaluate(N):
rev = 0.0
for t, qo in io["out"].items():
x = Q(t)
if t in mm:
p = mm[t]
else:
p = sat.p_patient(asks_of(t), sat.tref(x.traded7, x.traded30), N * qo, x.bid, x.vwap7, x.vwap30, x.ask)
rev += N * qo * p
cost = 0.0
for t, qi in io["in"].items():
w = market.walk(t, a.cx, N * qi, "buy")
if w["short"]:
return None
cost += w["total"]
return rev - cost - N * c["wcost"]
if a.budget and c["capex"] > a.budget:
continue
n_star = max(1, min(int(c["n_lim"]), a.max_n))
if a.budget:
n_star = max(1, min(n_star, int(a.budget // c["capex"])))
net1 = evaluate(1)
if net1 is None:
continue
netN = evaluate(n_star)
while netN is None and n_star > 1:
n_star = max(1, n_star // 2)
netN = evaluate(n_star)
if netN is None:
continue
rows.append(dict(roi1=100 * net1 / c["capex"], roiN=100 * netN / n_star / c["capex"], total=netN, n=n_star,
n_lim=c["n_lim"], lim=c["lim"], capex=c["capex"] * n_star, eff=c["eff"], bld=c["b"]["Ticker"],
top=c["top"], h=c["rec"]["TimeMs"] / 3.6e6, thin=c["thin"],
rec=" ".join(f"{v:g}{k}" for k, v in c["ins"].items()) + " -> " + " ".join(f"{v:g}{k}" for k, v in c["outs"].items())))
key = {"roi": "roiN", "roi1": "roi1", "total": "total"}[a.sort]
rows.sort(key=lambda r: r[key], reverse=True)
print(f"# {a.cx} tier<={a.tier or 'any'} cogc={a.cogc or '-'} faction={faction or '-'} ({pu}/{pt} permits) min-n>={a.min_n} sorted by {a.sort}")
print("# price: patient asks near VWAP (clamped), inputs ask-walked; N* = market-absorbable buildings (cap %d); all estimates" % a.max_n)
print(f"{'ROI@1':>6} {'ROI@N*':>7} {'N*':>4} {'lim':4} {'profit/d@N*':>11} {'capex@N*':>9} {'eff':>4} {'bld':4} {'tier':4} {'h':>5} recipe")
for r in rows[:a.top]:
print(f"{r['roi1']:6.1f} {r['roiN']:7.1f} {r['n']:4d} {r['lim']:4} {r['total']:11.0f} {r['capex']:9.0f} {r['eff']*100:4.0f} {r['bld']:4} {r['top']:4} "
f"{r['h']:5.1f} {r['rec']}{' [thin]' if r['thin'] else ''}")
if __name__ == "__main__":
main()
Executable
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Sync state/company.yaml from live FIO (own data via FIO_REST_KEY) and show it.
tools/state.py sync # overwrite live fields (buildings, production efficiency, storage, ships, cash, permits)
tools/state.py show
Requires the FIO extension to have uploaded recently; check `as_of`. Manual keys (company, notes) are preserved."""
import sys, datetime
from collections import Counter
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import ROOT, fio
P = ROOT / "state" / "company.yaml"
def sync():
u = fio.me()
st = yaml.safe_load(P.read_text()) if P.exists() else {}
company = fio.private(f"/company/code/{(st.get('company') or {}).get('ticker', 'GBI')}", ttl=0)
for stale in ("cash_aic",):
st.pop(stale, None)
st["as_of"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%MZ")
st["cash"] = {b["Currency"]: b["Amount"] for b in company["Balances"] if b["Amount"]}
sites = fio.private(f"/sites/{u}", ttl=0)
prod = fio.private(f"/production/{u}", ttl=0)
stores = fio.private(f"/storage/{u}", ttl=0)
ships = fio.private(f"/ship/ships/{u}", ttl=0)
st["permits"] = {"used": sites[0]["InvestedPermits"] if sites else 0, "total": sites[0]["MaximumPermits"] if sites else 0}
old = {b["planet"]: b for b in st.get("bases", [])}
bases = []
for s in sites:
b = old.get(s["PlanetIdentifier"], {})
b.update(name=s["PlanetName"], planet=s["PlanetIdentifier"], buildings=dict(Counter(x["BuildingTicker"] for x in s["Buildings"])),
avg_condition=round(sum(x["Condition"] for x in s["Buildings"]) / len(s["Buildings"]), 4))
b["production"] = [dict(type=l["Type"], count=l["Capacity"], efficiency=round(l["Efficiency"], 4), queued_orders=len(l["Orders"]))
for l in prod if l["SiteId"] == s["SiteId"]]
bases.append(b)
st["bases"] = bases
st["storage"] = [dict(type=x["Type"], name=x["Name"], weight=f"{x['WeightLoad']:.0f}/{x['WeightCapacity']:.0f}t",
items={i["MaterialTicker"]: i["MaterialAmount"] for i in x["StorageItems"] if i.get("MaterialTicker")})
for x in stores if x["StorageItems"] or x["Type"] == "SHIP_STORE"]
st["ships"] = [dict(name=x["Name"], reg=x["Registration"], mass=x["Mass"], in_flight=bool(x.get("FlightId"))) for x in ships]
P.write_text(yaml.safe_dump(st, sort_keys=False, default_flow_style=None, width=140))
print("synced", st["as_of"])
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "show"
if cmd == "sync":
sync()
print(P.read_text())