From b24f132210ec84170155b6aa1e563f43778cd809 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Mon, 28 Sep 2026 21:56:18 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 1 + docs/library.md | 11 ++++++ docs/roadmap.md | 2 ++ tests/test_runway.py | 45 +++++++++++++++++++++++ tools/runway.py | 85 ++++++++++++++++++++++++++++++++++++++++++++ tools/state.py | 13 +++++-- 6 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 tests/test_runway.py create mode 100755 tools/runway.py diff --git a/CLAUDE.md b/CLAUDE.md index 5e7fedf..be71e4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ Run as `puga ...` (e.g. `puga scan --min-n 3`, `puga plan pull `); - `tools/buy.py --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/network.py ` 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 [--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/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. diff --git a/docs/library.md b/docs/library.md index 103065b..3a8f8c4 100644 --- a/docs/library.md +++ b/docs/library.md @@ -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 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 - Cache TTLs matter: market data is cached ~15 min, static game data ~24h (`puga/cache.py`). diff --git a/docs/roadmap.md b/docs/roadmap.md index d076333..9a2d055 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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). 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. ## Open questions diff --git a/tests/test_runway.py b/tests/test_runway.py new file mode 100644 index 0000000..668f873 --- /dev/null +++ b/tests/test_runway.py @@ -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) diff --git a/tools/runway.py b/tools/runway.py new file mode 100755 index 0000000..d90b1e9 --- /dev/null +++ b/tools/runway.py @@ -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() diff --git a/tools/state.py b/tools/state.py index 8831750..3cccc7e 100755 --- a/tools/state.py +++ b/tools/state.py @@ -29,6 +29,8 @@ def sync(): prod = fio.own(f"/production/{u}", ttl=0) stores = fio.own(f"/storage/{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} 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"] @@ -42,10 +44,17 @@ def sync(): for l in prod if l["SiteId"] == s["SiteId"]] bases.append(b) 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")}) 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)) print("synced", st["as_of"])