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:
@@ -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.
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
Executable
+85
@@ -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
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user