Files
PuGa/tools/buy.py
T
dodoxandClaude Sonnet 5 bf15c709ea Add planet environment-extra materials to capex calcs; buildcost.py tool
Every building needs a planet-hazard material bundle on top of its normal
BuildingCosts (SEA for low pressure, HSE for high pressure, MGC for low
gravity, BL for high gravity, INS for cold, TSH for heat - area-scaled
where applicable), confirmed against live in-game build panels on Nike.
FIO's own BuildRequirements field is an unreliable superset across hazard
types, not a direct per-building list - don't use it directly.

- puga/planet.py::environment_extras(): the real threshold table.
- tools/buildcost.py: full construction material list for one building,
  optionally on a given planet.
- Wired into whatif.py/buy.py (via simulate(planet=...)) and scan.py's
  own bcost(), so their capex numbers stop silently omitting this.
- docs/mechanics.md: the rule, plus a separate forum-sourced note on the
  CX order-cancellation penalty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-17 19:03:51 +02:00

150 lines
7.4 KiB
Python
Executable File

#!/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 <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 import planet as planet_mod
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, planet: dict | None = None) -> dict:
"""Materials needed for the plan's buildings/infra beyond what `built` (ticker -> count) already has.
planet: optional, adds environment-extra materials (SEA/HSE/MGC/BL/INS/TSH) via planet.environment_extras()."""
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
if planet is not None:
for etk, qty in planet_mod.environment_extras(planet, info["area_cost"]).items():
need[etk] = need.get(etk, 0) + qty * 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, planet=planet)
con = net_of_on_hand(construction_items(plan, blds, built, planet), 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()