- tools/buy.py: cash-aware shopping list (construction gap vs empire state's built buildings, plus N days of NET operating stock via simulate() so self-produced inputs net against consumption), priced at real order-book fill cost, checked against cash/reserve, flags multi-trip, binary-searches an affordable size when short - tools/sell.py: where to post an ask vs hitting the bids now, with expected clear time from 30-day traded-volume percentiles - docs/library.md: module map and patterns for using puga.* directly instead of the CLI, plus the "simulate() only knows what the plan lists" gotcha that a first version of buy.py hit - tools/simulate.py: fix --no-hq being a no-op; tools/state.py: permits_total_override for when the in-game HQ screen disagrees with FIO's MaximumPermits - docs/mechanics.md: HQ display vs PRUNplanner's HQ flag, workforce arrival sources - CLAUDE.md, docs/roadmap.md updated Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
145 lines
7.0 KiB
Python
Executable File
145 lines
7.0 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.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()
|