- tools/runway.py: days of runway per material before a base stalls, from simulate()'s real net consumption, base storage, plus (by default) any ship currently flying to that planet - tools/state.py: tag each STORE entry with its planet (FIO AddressableId -> site SiteId) and each ship with its live flight origin/destination (keyed by ShipId, not StlFuelStoreId). Needed now that a 2nd base exists - storage/ship data was previously unscoped, which would have silently mixed bases' inventories together - tests, docs updated (docs/library.md notes the multi-base tagging as a reusable pattern) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
86 lines
4.0 KiB
Python
Executable File
86 lines
4.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Days of runway per material before the base stalls, from the plan's real NET consumption
|
|
(simulate()'s flows, so self-produced inputs like AL correctly don't count against you), the
|
|
base's own storage, plus (by default) any ship currently flying TO this planet, auto-detected
|
|
from empire state (tools/state.py tags each ship with its live flight destination).
|
|
|
|
puga runway plans/examples/base_plus_hwp.yaml # base storage + inbound ships (default)
|
|
puga runway plans/examples/base_plus_hwp.yaml --no-inbound # base storage only, ignore ships in flight
|
|
"""
|
|
import argparse, sys
|
|
from pathlib import Path
|
|
import yaml
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
from puga import config, prunplanner as pp, market
|
|
from puga.simulate import simulate
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import plan_push
|
|
from buy import stock_items # net-consumption-per-day logic, shared with `buy`
|
|
|
|
|
|
def inbound_ships(state: dict, planet: str) -> list[str]:
|
|
"""Registrations of ships whose live flight destination is this planet (natural id substring match
|
|
against the flight's destination string, e.g. '... - Deimos (ZV-759c)')."""
|
|
out = []
|
|
for s in state.get("ships", []):
|
|
f = s.get("flight")
|
|
if f and f.get("destination") and planet in f["destination"]:
|
|
out.append(s["reg"])
|
|
return out
|
|
|
|
|
|
def on_hand(state: dict, planet: str, ships: list[str]) -> dict:
|
|
"""This planet's own STORE items plus the named ships' SHIP_STORE cargo, summed."""
|
|
totals: dict[str, float] = {}
|
|
for s in state.get("storage", []):
|
|
if (s.get("type") == "STORE" and s.get("planet") == planet) or (s.get("type") == "SHIP_STORE" and s.get("name") in ships):
|
|
for tk, qty in s.get("items", {}).items():
|
|
totals[tk] = totals.get(tk, 0) + qty
|
|
return totals
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("spec", nargs="?", help="plans/*.yaml describing the WHOLE base (see docs/library.md)")
|
|
ap.add_argument("--uuid", help="read the plan from the PRUNplanner account instead")
|
|
ap.add_argument("--no-inbound", action="store_true", help="ignore ships currently flying to this base; base storage only")
|
|
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
|
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())
|
|
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)
|
|
snap = market.snapshot()
|
|
price = lambda t, side="buy": (lambda q: q.ask if q else None)(snap.get((t, a.cx)))
|
|
r = simulate(plan, recipes, blds, planet["resources"], planet["fertility"], price, faction, perm)
|
|
|
|
ships = [] if a.no_inbound else inbound_ships(st, plan["planet_natural_id"])
|
|
have = on_hand(st, plan["planet_natural_id"], ships)
|
|
daily = stock_items(r["flows"], days=1.0) # net units consumed per day, per material (self-production already netted)
|
|
if not daily:
|
|
print("nothing net-consumed by this plan; no runway to compute")
|
|
return
|
|
|
|
rows = sorted(((tk, have.get(tk, 0), rate, have.get(tk, 0) / rate) for tk, rate in daily.items()), key=lambda x: x[3])
|
|
ships_note = f" + inbound {', '.join(ships)}" if ships else ""
|
|
print(f"RUNWAY (base storage{ships_note})\n{'material':8} {'have':>8} {'burn/day':>9} {'days':>7}")
|
|
for tk, have, rate, days in rows:
|
|
flag = " <- CRITICAL" if days < 1 else (" <- low" if days < 2 else "")
|
|
print(f"{tk:8} {have:8.1f} {rate:9.2f} {days:7.2f}{flag}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|