Add runway tool with auto-detected inbound ships; fix multi-base state tagging

- 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>
This commit is contained in:
2026-09-28 21:56:18 +02:00
co-authored by Claude Sonnet 5
parent 58db08b921
commit b24f132210
6 changed files with 155 additions and 2 deletions
+1
View File
@@ -45,6 +45,7 @@ Run as `puga <tool> ...` (e.g. `puga scan --min-n 3`, `puga plan pull <uuid>`);
- `tools/buy.py <spec.yaml|--uuid U> --days N` cash-aware shopping list: construction gap (plan vs empire state's built buildings) plus N days of the plan's NET operating stock (via `simulate()`, so self-produced inputs like AL net against consumption), priced at real fill cost, checked against cash/reserve. Pass a plan covering the WHOLE base for restocking, not just a new addition (see `docs/library.md`). - `tools/buy.py <spec.yaml|--uuid U> --days N` cash-aware shopping list: construction gap (plan vs empire state's built buildings) plus N days of the plan's NET operating stock (via `simulate()`, so self-produced inputs like AL net against consumption), priced at real fill cost, checked against cash/reserve. Pass a plan covering the WHOLE base for restocking, not just a new addition (see `docs/library.md`).
- `tools/sell.py TICKER QTY [--tight]` where to post an ask, expected hours to clear, and a self-contained tranche split: aggressive (undercut) tranche capped at the median 30d-volume quantile (or 80th pct with `--tight`, for when a payment is imminent and stockout risk outweighs a few points of margin), patient tranche priced just under the next competitor tier. `--show-bid` for the instant-bid comparison (off by default; usually worse). - `tools/sell.py TICKER QTY [--tight]` where to post an ask, expected hours to clear, and a self-contained tranche split: aggressive (undercut) tranche capped at the median 30d-volume quantile (or 80th pct with `--tight`, for when a payment is imminent and stockout risk outweighs a few points of margin), patient tranche priced just under the next competitor tier. `--show-bid` for the instant-bid comparison (off by default; usually worse).
- `tools/network.py <chain.yaml>` models a MULTI-BASE chain (e.g. mine LST at Nike, ship it, make BSE at Deimos): simulates each base, nets a transferred material's producer-surplus against consumer-need, charges real freight only on what's moved (`puga/network.py`). Spec and worked example in `plans/chains/`. `cm_free: true` per base for a new founding covered by a Core Module Kit. profit/day per base is the WHOLE plan, not the marginal addition - diff against the base's plan without the addition for a true marginal ROI. - `tools/network.py <chain.yaml>` models a MULTI-BASE chain (e.g. mine LST at Nike, ship it, make BSE at Deimos): simulates each base, nets a transferred material's producer-surplus against consumer-need, charges real freight only on what's moved (`puga/network.py`). Spec and worked example in `plans/chains/`. `cm_free: true` per base for a new founding covered by a Core Module Kit. profit/day per base is the WHOLE plan, not the marginal addition - diff against the base's plan without the addition for a true marginal ROI.
- `tools/runway.py <spec.yaml|--uuid U> [--no-inbound]` days of runway per material before a base stalls: real NET consumption via `simulate()` (self-produced inputs excluded), base storage, PLUS (by default) any ship currently flying to that planet, auto-detected from live flight data (`tools/state.py` tags each ship with its flight origin/destination). Pass the WHOLE base plan (see `docs/library.md`).
- `tools/chain.py TICKER` make-vs-buy cost tree plus sourcing depth of inputs. - `tools/chain.py TICKER` make-vs-buy cost tree plus sourcing depth of inputs.
- `tools/price.py` prices across exchanges with VWAP, volume, fill price for a quantity; `tools/book.py` order-book ladder. - `tools/price.py` prices across exchanges with VWAP, volume, fill price for a quantity; `tools/book.py` order-book ladder.
- `tools/prun_scan.py`, `tools/prun_cxarb.py` legacy top-of-book scans; overstate thin markets. Baseline only. `tools/arb.py` replacement is on the roadmap. - `tools/prun_scan.py`, `tools/prun_cxarb.py` legacy top-of-book scans; overstate thin markets. Baseline only. `tools/arb.py` replacement is on the roadmap.
+11
View File
@@ -118,6 +118,17 @@ profit/day is for its WHOLE plan, not the marginal addition - for "is this new b
also simulate the base without the addition and diff, same as `tools/simulate.py`'s built-vs-planned also simulate the base without the addition and diff, same as `tools/simulate.py`'s built-vs-planned
new_capex logic but applied to profit instead of capex. new_capex logic but applied to profit instead of capex.
## Multi-base state: storage and ships are now tagged by location
Once a 2nd base exists, `empire/state/company.yaml`'s `storage` list has multiple `STORE` entries
(one per base) and must be filtered by `planet` (a field `tools/state.py` now adds, from FIO's
`AddressableId` matching a site's `SiteId`) - summing all `STORE` entries blindly (as `tools/buy.py`
did briefly) mixes bases' inventories together. Similarly each entry in `ships` now carries a
`flight` dict (`origin`, `destination`, `eta_ms`) from live `/ship/flights`, keyed to the ship via
`ShipId` (not `StlFuelStoreId`, despite that looking like a plausible match - verified 2026-09-28).
`tools/runway.py`'s `inbound_ships()`/`on_hand()` are the reference implementation for scoping to
one base correctly; reuse them rather than re-deriving this.
## Conventions to keep ## Conventions to keep
- Cache TTLs matter: market data is cached ~15 min, static game data ~24h (`puga/cache.py`). - Cache TTLs matter: market data is cached ~15 min, static game data ~24h (`puga/cache.py`).
+2
View File
@@ -20,6 +20,8 @@ Legend: [ ] todo, [x] done. Build order matters; each step is usable on its own.
13. [x] `tools/buy.py`: cash-aware shopping list (construction gap vs empire state's built count, plus N days of net operating stock via simulate(), priced at real fill cost, checked against cash/reserve). 13. [x] `tools/buy.py`: cash-aware shopping list (construction gap vs empire state's built count, plus N days of net operating stock via simulate(), priced at real fill cost, checked against cash/reserve).
14. [x] `tools/sell.py`: ask-post-price + expected-clear-time advisor from 30d traded-volume percentiles, vs hitting the bids now. 14. [x] `tools/sell.py`: ask-post-price + expected-clear-time advisor from 30d traded-volume percentiles, vs hitting the bids now.
15. [x] `tools/runway.py`: days-of-runway per material from simulate()'s net consumption, base storage plus auto-detected inbound ship cargo (tools/state.py now tags each ship with its live flight origin/destination, and each STORE with its planet, needed once a 2nd base existed).
12. [ ] `tools/portfolio.py`: choose the set of 1-building opportunities for one base that maximises total profit given shared whole-building housing (HB1/HB2/...), capex, permits and (for demolish-later) 60-day value decay. Persistence check exists (`tools/persistence.py`); wire it into the ranking. 12. [ ] `tools/portfolio.py`: choose the set of 1-building opportunities for one base that maximises total profit given shared whole-building housing (HB1/HB2/...), capex, permits and (for demolish-later) 60-day value decay. Persistence check exists (`tools/persistence.py`); wire it into the ranking.
## Open questions ## Open questions
+45
View File
@@ -0,0 +1,45 @@
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location("runway", Path(__file__).resolve().parent.parent / "tools" / "runway.py")
runway = importlib.util.module_from_spec(spec)
spec.loader.exec_module(runway)
STATE = {
"storage": [
{"type": "STORE", "planet": "ZV-759c", "items": {"C": 6, "STL": 0}},
{"type": "STORE", "planet": "ZV-194a", "items": {"RAT": 30}}, # Nike's, must not leak into Deimos
{"type": "SHIP_STORE", "name": "AVI-07JFB", "items": {"C": 39, "STL": 15}},
{"type": "SHIP_STORE", "name": "AVI-07JFC", "items": {"MCG": 205}}, # not inbound to Deimos in this fixture
],
"ships": [
{"reg": "AVI-07JFB", "flight": {"destination": "Antares II (ZV-759) - Deimos (ZV-759c)"}},
{"reg": "AVI-07JFC", "flight": {"destination": "Antares III (ZV-194) - Nike (ZV-194a)"}},
{"reg": "AVI-IDLE", "flight": None},
],
}
def test_inbound_ships_matches_destination_planet():
assert runway.inbound_ships(STATE, "ZV-759c") == ["AVI-07JFB"]
assert runway.inbound_ships(STATE, "ZV-194a") == ["AVI-07JFC"]
def test_inbound_ships_ignores_ships_with_no_flight():
assert "AVI-IDLE" not in runway.inbound_ships(STATE, "ZV-759c")
def test_on_hand_scopes_store_to_the_right_planet():
have = runway.on_hand(STATE, "ZV-759c", ships=[])
assert have == {"C": 6, "STL": 0} # Nike's RAT must not appear here
def test_on_hand_adds_named_ship_cargo():
have = runway.on_hand(STATE, "ZV-759c", ships=["AVI-07JFB"])
assert have == {"C": 45, "STL": 15} # 6+39, 0+15
def test_on_hand_with_inbound_ships_end_to_end():
ships = runway.inbound_ships(STATE, "ZV-759c")
have = runway.on_hand(STATE, "ZV-759c", ships)
assert have == {"C": 45, "STL": 15} # only JFB counted, not JFC (heading to Nike)
+85
View File
@@ -0,0 +1,85 @@
#!/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()
+11 -2
View File
@@ -29,6 +29,8 @@ def sync():
prod = fio.own(f"/production/{u}", ttl=0) prod = fio.own(f"/production/{u}", ttl=0)
stores = fio.own(f"/storage/{u}", ttl=0) stores = fio.own(f"/storage/{u}", ttl=0)
ships = fio.own(f"/ship/ships/{u}", ttl=0) ships = fio.own(f"/ship/ships/{u}", ttl=0)
flights = fio.own(f"/ship/flights/{u}", ttl=0)
by_ship = {f["ShipId"]: f for f in flights}
st["permits"] = {"used": sites[0]["InvestedPermits"] if sites else 0, "total": sites[0]["MaximumPermits"] if sites else 0} st["permits"] = {"used": sites[0]["InvestedPermits"] if sites else 0, "total": sites[0]["MaximumPermits"] if sites else 0}
if st.get("permits_total_override"): # e.g. the HQ screen (base permits x / y) disagrees with FIO's MaximumPermits if st.get("permits_total_override"): # e.g. the HQ screen (base permits x / y) disagrees with FIO's MaximumPermits
st["permits"]["total"] = st["permits_total_override"] st["permits"]["total"] = st["permits_total_override"]
@@ -42,10 +44,17 @@ def sync():
for l in prod if l["SiteId"] == s["SiteId"]] for l in prod if l["SiteId"] == s["SiteId"]]
bases.append(b) bases.append(b)
st["bases"] = bases st["bases"] = bases
st["storage"] = [dict(type=x["Type"], name=x["Name"], weight=f"{x['WeightLoad']:.0f}/{x['WeightCapacity']:.0f}t", site_planet = {s["SiteId"]: s["PlanetIdentifier"] for s in sites} # STORE.AddressableId == a site's SiteId
st["storage"] = [dict(type=x["Type"], name=x["Name"], planet=site_planet.get(x.get("AddressableId")),
weight=f"{x['WeightLoad']:.0f}/{x['WeightCapacity']:.0f}t",
items={i["MaterialTicker"]: i["MaterialAmount"] for i in x["StorageItems"] if i.get("MaterialTicker")}) items={i["MaterialTicker"]: i["MaterialAmount"] for i in x["StorageItems"] if i.get("MaterialTicker")})
for x in stores if x["StorageItems"] or x["Type"] == "SHIP_STORE"] for x in stores if x["StorageItems"] or x["Type"] == "SHIP_STORE"]
st["ships"] = [dict(name=x["Name"], reg=x["Registration"], mass=x["Mass"], in_flight=bool(x.get("FlightId"))) for x in ships] def ship_dest(x):
f = by_ship.get(x.get("ShipId"))
return None if not f else {"origin": f.get("Origin"), "destination": f.get("Destination"), "eta_ms": f.get("ArrivalTimeEpochMs")}
st["ships"] = [dict(name=x["Name"], reg=x["Registration"], mass=x["Mass"], in_flight=bool(x.get("FlightId")), flight=ship_dest(x)) for x in ships]
P.write_text(yaml.safe_dump(st, sort_keys=False, default_flow_style=None, width=140)) P.write_text(yaml.safe_dump(st, sort_keys=False, default_flow_style=None, width=140))
print("synced", st["as_of"]) print("synced", st["as_of"])