#!/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()