Add buy/sell tools, library docs, and permit/HQ fixes

- 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>
This commit is contained in:
2026-09-22 17:40:17 +02:00
co-authored by Claude Sonnet 5
parent 6e0554e10a
commit e2e592cae3
9 changed files with 368 additions and 1 deletions
Executable
+144
View File
@@ -0,0 +1,144 @@
#!/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()