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>
150 lines
7.4 KiB
Python
Executable File
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()
|