#!/usr/bin/env python3 """Cash-aware shopping list: construction gap (plan vs what's already built) plus N days of the plan's steady-state operating stock (from the simulator), priced at real order-book fill cost, checked against cash. Formalizes the by-hand calc used repeatedly for buildouts and restocks. puga buy plans/examples/hwp_buildout.yaml --days 2 # nothing built yet: full construction + 2d stock puga buy plans/examples/hwp_buildout.yaml --days 4 # once built: construction gap is empty, just restock puga buy --uuid --days 3 --cash 72200 --reserve 3000 Materials already sitting in the base's storage (empire state) are subtracted from both sections. IMPORTANT for --days (stock): pass a plan that lists EVERY production building at the base, not just a new addition, or its own production won't net against consumption (e.g. a standalone HWP plan will say "buy 42.8 AL/day" because it can't see your smelters; the full base+HWP plan nets that to ~0). Use the full plan for restocking; a partial plan is fine for construction-only. Limitation: on-hand storage in empire/state/company.yaml is not scoped per base; with more than one base this will misattribute inventory. Fine while there is only one. """ import argparse, math, sys from pathlib import Path import yaml sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from puga import config, fio, market, prunplanner as pp from puga.simulate import simulate sys.path.insert(0, str(Path(__file__).resolve().parent)) import plan_push def construction_items(plan: dict, buildings: list, built: dict) -> dict: """Materials needed for the plan's buildings/infra beyond what `built` (ticker -> count) already has.""" bl = {b["building_ticker"]: b for b in buildings} d = plan["plan_data"] need: dict[str, float] = {} for tk, amt in [(b["name"], b["amount"]) for b in d["buildings"]] + [(i["building"], i["amount"]) for i in d["infrastructure"]]: gap = amt - built.get(tk, 0) if gap <= 0: continue info = bl[tk] for c in info["costs"]: need[c["material_ticker"]] = need.get(c["material_ticker"], 0) + c["material_amount"] * gap need["MCG"] = need.get("MCG", 0) + 4 * info["area_cost"] * gap return need def stock_items(flows: dict, days: float) -> dict: """days worth of NET shortfall (consumption minus own production) from simulate()'s flows. A material the plan both consumes and produces (e.g. AL: SME makes it, HWP eats it) nets out; only buy what production doesn't cover.""" return {tk: -delta * days for tk, f in flows.items() if (delta := f["out"] - f["inp"]) < 0} def net_of_on_hand(items: dict, on_hand: dict) -> dict: return {tk: max(0.0, q - on_hand.get(tk, 0)) for tk, q in items.items()} def price(items: dict, cx: str): mat = {m["Ticker"]: m for m in fio.materials()} rows, total, weight, volume = [], 0.0, 0.0, 0.0 for tk, qty in sorted(items.items()): qty = math.ceil(qty) if qty <= 0: continue w = market.walk(tk, cx, qty, "buy") rows.append((tk, qty, w["avg"] or 0, w["total"], w["short"])) total += w["total"] m = mat.get(tk) if m: weight += qty * m["Weight"] volume += qty * m["Volume"] return rows, total, weight, volume def main(): ap = argparse.ArgumentParser() ap.add_argument("spec", nargs="?", help="plans/*.yaml describing the target setup") ap.add_argument("--uuid", help="read the plan from the PRUNplanner account instead") ap.add_argument("--days", type=float, default=3, help="days of operating stock on top of construction") ap.add_argument("--cx", default=config.DEFAULT_CX) ap.add_argument("--cash", type=float, help="override cash (default: empire state)") ap.add_argument("--reserve", type=float, default=0, help="AIC to keep unspent") ap.add_argument("--cargo", type=float, default=500.0, help="ship capacity t/m3, to flag multi-trip") a = ap.parse_args() recipes, blds = pp.recipes(), pp.buildings() if a.uuid: plan = pp.request("GET", f"/planning/plan/{a.uuid}/") elif a.spec: plan = plan_push.build_payload(yaml.safe_load(Path(a.spec).read_text()), recipes, {b["building_ticker"] for b in blds}) else: sys.exit("give a spec file or --uuid") st = yaml.safe_load(config.state_path().read_text()) cash = a.cash if a.cash is not None else st.get("cash", {}).get("AIC", 0) base = next((b for b in st.get("bases", []) if b.get("planet") == plan["planet_natural_id"]), {}) built = base.get("buildings", {}) on_hand = next((s["items"] for s in st.get("storage", []) if s.get("type") == "STORE"), {}) if not st.get("hq"): plan["plan_corphq"] = False faction = st.get("company", {}).get("faction") perm = (st.get("permits", {}).get("used", 1), st.get("permits", {}).get("total", 2)) planet = pp._g(f"/data/planet/{plan['planet_natural_id']}/", 3600) price_ask = lambda t, side="buy": (lambda q: q.ask if q else None)(market.snapshot().get((t, a.cx))) r = simulate(plan, recipes, blds, planet["resources"], planet["fertility"], price_ask, faction, perm) con = net_of_on_hand(construction_items(plan, blds, built), on_hand) stk = net_of_on_hand(stock_items(r["flows"], a.days), on_hand) con_rows, con_total, con_w, con_v = price(con, a.cx) stk_rows, stk_total, stk_w, stk_v = price(stk, a.cx) def show(title, rows): if not rows: return print(f"\n{title}") for tk, qty, avg, cost, short in rows: have = on_hand.get(tk, 0) print(f" {tk:5} {qty:8.0f} (have {have:5.0f}) avg {avg:7.0f} = {cost:9,.0f}{' SHORT BOOK' if short else ''}") show(f"CONSTRUCTION (plan vs built {built or 'nothing'})", con_rows) if con_rows: print(f" subtotal {con_total:9,.0f} ({con_w:.0f} t, {con_v:.0f} m3)") show(f"OPERATING STOCK, {a.days:g} days", stk_rows) if stk_rows: print(f" subtotal {stk_total:9,.0f} ({stk_w:.0f} t, {stk_v:.0f} m3)") total, weight, volume = con_total + stk_total, con_w + stk_w, con_v + stk_v budget = cash - a.reserve print(f"\nTOTAL {total:,.0f} AIC ({weight:.0f} t, {volume:.0f} m3{' -- fits one trip' if max(weight, volume) <= a.cargo else ' -- NEEDS MULTIPLE TRIPS'})") print(f"CASH {cash:,.0f} minus reserve {a.reserve:,.0f} = budget {budget:,.0f}") if total <= budget: print(f"AFFORDABLE, {budget - total:,.0f} left over") elif con_total <= budget: lo, hi = 0.0, a.days for _ in range(20): mid = (lo + hi) / 2 _, t, _, _ = price(net_of_on_hand(stock_items(r["flows"], mid), on_hand), a.cx) lo, hi = (mid, hi) if con_total + t <= budget else (lo, mid) print(f"SHORT by {total - budget:,.0f}: construction alone fits; stock affordable at ~{lo:.2f} days instead of {a.days:g}") else: print(f"SHORT by {total - budget:,.0f}: even construction alone ({con_total:,.0f}) exceeds budget") if __name__ == "__main__": main()