Add planet environment-extra materials to capex calcs; buildcost.py tool

Every building needs a planet-hazard material bundle on top of its normal
BuildingCosts (SEA for low pressure, HSE for high pressure, MGC for low
gravity, BL for high gravity, INS for cold, TSH for heat - area-scaled
where applicable), confirmed against live in-game build panels on Nike.
FIO's own BuildRequirements field is an unreliable superset across hazard
types, not a direct per-building list - don't use it directly.

- puga/planet.py::environment_extras(): the real threshold table.
- tools/buildcost.py: full construction material list for one building,
  optionally on a given planet.
- Wired into whatif.py/buy.py (via simulate(planet=...)) and scan.py's
  own bcost(), so their capex numbers stop silently omitting this.
- docs/mechanics.md: the rule, plus a separate forum-sourced note on the
  CX order-cancellation penalty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 19:03:51 +02:00
co-authored by Claude Sonnet 5
parent 6bd5d6cc69
commit bf15c709ea
9 changed files with 153 additions and 12 deletions
+17
View File
@@ -57,7 +57,24 @@ Run as `puga <tool> ...` (e.g. `puga scan --min-n 3`, `puga plan pull <uuid>`);
- `tools/shiptrip.py SHIP FROM TO [--v0 X --v1 Y]` the simple ship-trip tool: give a ship registration + start + stop, it works out jumps/charges/landing fuel itself via `tools/route.py`'s jump graph and reports fuel cost (defaults to max sliders). Direct-jump routes only - an unconnected pair means gateway-only, and it tells you exactly what to read off the live APEX Ship Flight Control screen (the LOCK fee - genuinely not available via FIO/PRUNplanner, confirmed by inspecting both) and what to run instead (`tools/shipcost.py`). Warns if the given ship's live specs (FIO `/ship/ships/{user}`) don't match the calibrated hull/reactor.
- `tools/shipcost.py [--jumps N --charges N] [--v0 X --v1 Y] [--gateways N --lock-fee AMOUNT:CURRENCY] [--to PLANET]` the explicit/manual version: fuel/AIC cost of a ship trip from physics parameters you already know (jump count, slider values, gateway LOCK fees read off the live game) - use `tools/shiptrip.py` instead if you just want "ship X from A to B". Landing fuel is a measured-planet lookup via `--to`, independent of whether the route used jumps or gateways. Both tools scoped to this account's one hull/reactor (blueprint BP-STRT-0000, shared by both ships AVI-07JFB/AVI-07JFC); open caveats (v0 relative to current fuel load, landing fuel confounded with gravity/distance, no second hull to cross-check) are in `puga/shipping.py`'s module docstring - don't trust magnitudes beyond what's there. Raw calibration data (APEX DOM reads, 2026-10-09): `empire/data/shipping_calibration/`. Also wired into `tools/network.py`'s freight cost (direct-jump transfers use the real model; gateway transfers need an explicit `trip_cost` in the chain YAML, else a 9250 AIC fallback). Library: `puga/shipping.py` (`trip_fuel()`, `round_trip_cost()`).
- `tools/parkcash.py [--min-spread X] [--min-trade-days N] [--rate R] [--top N]` where to park idle cash as a buy-low/sell-high position (not a build) while ships are busy or cash has no better use right now - scans every ticker for a big bid/ask spread AND real trading liquidity (trade-days/gap over a calendar window - a huge spread with few trading days is a stale-quote trap, confirmed by hand: AST showed a 130% spread but its resting ask had gone stale after a 60% real price crash). Round-trip wait/value is EMPIRICAL (`puga.history.empirical_touch`, same model as `tools/sell.py --tiers`), not an assumed random walk - a driftless-walk version was tried and backtested wrong (overstated reachability 2-4x vs 3 years of real data, see `docs/decisions.md`). This dropped the whole universe's top pick from ~94%/day to ~1%/day with real waits of 19-194+ days - treat any number from the old model as stale. `--rate` defaults to 0.0 (matches the idle-cash premise; pass your realized growth rate if this cash IS earmarked) and shows up as a separate `discounted_pct` column (margin% x real odds both legs ever happen) alongside the simple non-compounding `daily_pct` used for ranking. Library: `puga/parkcash.py`.
- `tools/contd.py [--cx AI1]` evaluates this account's OPEN CONTD contract offers (the NPC logistics-officer
pool in APEX's CONTD panel, usually 3 at a time, only one acceptable) - payment, each required
material priced at real buy-fresh cost, and net. Deliberately leaves conditions it can't price
(EXPLORATION, REPAIR_SHIP, HEADQUARTERS_UPGRADE, BASE_CONSTRUCTION, CONTRIBUTION, WAIT,
INCREASE_SATISFACTION - real cost depends on context the API can't see, e.g. a ship already flying
that route) as flagged, unpriced conditions, and doesn't account for materials you may already hold
in storage - it's a breakdown for the caller to judge, not a verdict. Library: `puga/contd.py`.
- `tools/lmscan.py PLANET [--direction source|destination] [--min-margin X]` live Local Market hauling-ad scan (FIO, public/no-auth - real player-posted freight jobs, not the CONTD player-contract system), ranked by margin after real fuel cost (`puga.shipping.leg_cost`). One-way cost only - margin assumes the next job is picked up near the destination, not an empty flight home. A small measured gateway-fee table covers ANT<->HRT/BEN; other gateway routes can't be priced (LOCK fee isn't available via any API). Library: `puga/contracts.py`.
- `tools/buildcost.py BUILDING [--planet ID] [--amount N] [--cx AI1]` full construction material
list for one building, priced at real fill cost; `--planet` adds that planet's environment-extra
materials (SEA/HSE/MGC/BL/INS/TSH - threshold table in `docs/mechanics.md`). Also wired into
`whatif.py`/`buy.py`/`scan.py`'s own capex numbers via `simulate(planet=...)` / `scan.py`'s
`bcost()`. Library: `puga/planet.py::environment_extras()`.
- `tools/actpkg.py NAME TICKER:QTY [TICKER:QTY ...] [--cx AI1] [-o file.json]` emits a Refined PrUn
(browser extension, github.com/refined-prun/refined-prun) `XIT ACT` action-package JSON for a buy
list - paste into the extension's Import Action Package to pre-fill the in-game CXPO buy tile per
material (player still clicks the real buy button - semi-auto, not full auto). NAME must be
letters/digits/spaces only; punctuation is stripped automatically. Library: `puga/actpkg.py`.
- `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.
- `puga/econ.py` pure formulas ported from PRUNplanner (efficiency stack, workforce, extraction, production I/O, hab optimizer); `tests/test_econ.py` holds the reference values.
+30
View File
@@ -75,6 +75,36 @@ storage fee - this only confirms placing the order doesn't require a prerequisit
that storage itself is free. Also still unconfirmed: FX conversion fee size (a forum thread titled
"remove FX transaction fee entirely" implies one exists, but no amount confirmed).
## Planet environment-extra materials (per community handbook, confirmed live against Nike)
FIO's `/planet/full/{id}` -> `BuildRequirements` lists a superset across hazard types and is NOT
a direct per-building material list - compute it instead from the planet's own Pressure/Gravity/
Temperature/Surface fields against these threshold rules (source: Prosperous Universe Handbook,
building-costs page):
| condition | extra material | quantity |
|---|---|---|
| rocky | MCG | area x 4 |
| gaseous | AEF | area / 3 |
| pressure < 0.25 atm | SEA | area x 1 |
| pressure > 2.0 atm | HSE | 1/building |
| gravity < 0.25g | MGC | 1/building |
| gravity > 2.5g | BL | 1/building |
| temp < -25C | INS | area x 10 |
| temp > 75C | TSH | 1/building |
Applies per building (construction AND repair), area-scaled rows use the building's own `AreaCost`.
Implemented in `puga/planet.py::environment_extras()`, used by `tools/buildcost.py` and wired into
`whatif.py`/`buy.py`/`scan.py`'s own capex numbers.
## CX order cancellation penalty (UNCONFIRMED numbers, forum-sourced)
Deleting too many unfilled/partially-filled CX orders on the same ticker within a day triggers a
fee (added per dev patch notes to deter bot-style requoting); a warning appears around the 4th
attempt. No confirmed threshold or fee formula - forum reports range from 3 to 5-6 cancels/ticker/
day, fee said to scale with order size. Effect: rate-limits requoting, so effective spreads widen
and top-of-book gets sticky/stale - chasing the top spot is still possible within the free
cancel budget (~3-5/ticker/day per forum reports), just be aware each reprice spends one of those
before the fee kicks in.
## HQ in game vs PRUNplanner's "HQ" flag (observed 2026-09-21)
- The HQ screen (level 1, base permits 1 / 2) shows "Efficiency gains: Electronics 20.0%", and 23.3% for the next level (permits 1 / 3). That is exactly the faction bonus formula (Antares electronics 5% x 2 x (3 - 2 x used/total)), so the HQ display is the faction bonus.
- PRUNplanner's plan flag "HQ" (x1.1 on every building) is NOT visible in live efficiencies (smelters 1.3361, HWP 1.0696 both match the model without it). Keep it off. The HQ upgrade adds a base permit and raises the faction bonus.
+3
View File
@@ -4,6 +4,9 @@ Open work only - what's already built/fixed is discoverable from `CLAUDE.md`'s T
`docs/library.md`, `docs/decisions.md`, and module docstrings, not repeated here.
## Todo
- [ ] `tools/network.py`/`puga/network.py`'s `combine()` doesn't pass `planet` into `simulate()`
yet (environment-extra construction materials, `puga/planet.py::environment_extras()`) -
`whatif.py`/`buy.py`/`scan.py` already do.
- [ ] `tools/arb.py`: inter-CX arbitrage at real fill size (replaces `prun_cxarb.py`, which
overstated profit 7-11x on 2/4 top hits).
- [ ] `tools/haul.py`: fuel cost / profit per trip (route.py has the jump graph).
+28 -3
View File
@@ -1,8 +1,33 @@
"""Rank planets by extraction factor for a resource, with founding cost (environment extras
from FIO's own BuildRequirements - authoritative, not guessed from temperature/pressure
thresholds, which PRUNplanner's own calculations don't model either)."""
"""Rank planets by extraction factor for a resource. Also holds environment_extras(): FIO's own
BuildRequirements field is an unreliable superset across hazard types (confirmed wrong in-game,
see docs/mechanics.md) - the real per-building material is computed from the planet's own
pressure/gravity/temperature/surface against the Prosperous Universe Handbook's threshold table,
which PRUNplanner's own calculations don't model either."""
from . import fio
# (condition check, ticker, qty per unit area, flat qty) - handbook building-costs table
ENV_RULES = [
(lambda p: (p.get("pressure") or 0) < 0.25, "SEA", 1.0, 0.0),
(lambda p: (p.get("pressure") or 0) > 2.0, "HSE", 0.0, 1.0),
(lambda p: 0 < (p.get("gravity") or 1) < 0.25, "MGC", 0.0, 1.0),
(lambda p: (p.get("gravity") or 0) > 2.5, "BL", 0.0, 1.0),
(lambda p: (p.get("temperature") if p.get("temperature") is not None else 20) < -25, "INS", 10.0, 0.0),
(lambda p: (p.get("temperature") or 0) > 75, "TSH", 0.0, 1.0),
]
def environment_extras(planet: dict, area: float) -> dict[str, float]:
"""Extra construction/repair materials a building needs beyond its normal BuildingCosts and
the universal MCG area cost (already handled elsewhere, e.g. simulate()'s mcg_per_area) - one
row per hazard the planet actually has (pressure/gravity/temperature; rocky-vs-gaseous MCG/AEF
swap not covered here). planet needs lowercase pressure/gravity/temperature keys, e.g. from
prunplanner._g('/data/planet/{id}/') or fio.planets_full() remapped to lowercase."""
out: dict[str, float] = {}
for cond, tk, per_area, flat in ENV_RULES:
if cond(planet):
out[tk] = out.get(tk, 0.0) + per_area * area + flat
return out
def candidates(resource_ticker: str, min_factor: float = 0.0):
"""All planets with this resource, sorted by factor descending. Each: PlanetNaturalId,
+7 -3
View File
@@ -2,6 +2,7 @@
Input is a PRUNplanner plan JSON (as stored by the API, or built by tools/plan_push.build_payload). Pure given the data passed in.
Validated against a screenshot of the real simulator (tests/test_simulate.py)."""
from . import econ
from . import planet as planet_mod
TIERS = econ.TIERS
FARMS = {"FRM", "ORC"}
@@ -10,8 +11,8 @@ DEGRADATION_DAYS = 180 # usePlanCalculation.ts: degradation/day = const
def simulate(plan: dict, recipes: list[dict], buildings: list[dict], resources: list[dict], fertility: float,
price, faction: str | None = None, permits: tuple[float, float] = (1, 2), mcg_per_area: float = 4.0,
built: dict[str, int] | None = None) -> dict:
"""price(ticker, side) -> AIC per unit or None; side is "buy" (net consumed) or "sell" (net produced), like PRUNplanner BUY/SELL exchange preferences; construction uses "buy". resources: planet resources [{material_ticker, resource_type, daily_extraction}]."""
built: dict[str, int] | None = None, planet: dict | None = None) -> dict:
"""price(ticker, side) -> AIC per unit or None; side is "buy" (net consumed) or "sell" (net produced), like PRUNplanner BUY/SELL exchange preferences; construction uses "buy". resources: planet resources [{material_ticker, resource_type, daily_extraction}]. planet: optional, adds environment-extra construction materials via planet.environment_extras() (SEA/HSE/MGC/BL/INS/TSH - needs lowercase pressure/gravity/temperature keys)."""
d = plan["plan_data"]
bl = {b["building_ticker"]: b for b in buildings}
rec = {r["recipe_id"]: r for r in recipes}
@@ -69,7 +70,10 @@ def simulate(plan: dict, recipes: list[dict], buildings: list[dict], resources:
def unit_cost(tk):
info = bl[tk]
return sum(c["material_amount"] * (price(c["material_ticker"], "buy") or 0) for c in info["costs"]) + mcg_per_area * info["area_cost"] * (price("MCG", "buy") or 0)
cost = sum(c["material_amount"] * (price(c["material_ticker"], "buy") or 0) for c in info["costs"]) + mcg_per_area * info["area_cost"] * (price("MCG", "buy") or 0)
if planet is not None:
cost += sum(qty * (price(etk, "buy") or 0) for etk, qty in planet_mod.environment_extras(planet, info["area_cost"]).items())
return cost
# Verified vs simulator screenshots: degradation = production buildings' construction cost / 180 (infrastructure excluded);
# plan cost and area also include one core module (CM), added automatically.
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Full construction material list for one building, optionally on a specific planet (adds its
environment-extra materials via puga.planet.environment_extras() - SEA/HSE/MGC/BL/INS/TSH).
puga buildcost HB1
puga buildcost EXT --planet ZV-194a
puga buildcost PP1 --planet ZV-194a --amount 1
"""
import argparse, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import config, fio, market, prunplanner as pp, planet as planet_mod
def main():
ap = argparse.ArgumentParser()
ap.add_argument("building", help="building ticker, e.g. HB1, EXT, PP1")
ap.add_argument("--planet", help="planet natural id - adds its environment-extra materials")
ap.add_argument("--amount", type=int, default=1)
ap.add_argument("--cx", default=config.DEFAULT_CX)
a = ap.parse_args()
blds = {b["Ticker"]: b for b in fio.buildings()}
b = blds.get(a.building.upper())
if not b:
sys.exit(f"unknown building {a.building!r}")
need: dict[str, float] = {c["CommodityTicker"]: c["Amount"] for c in b["BuildingCosts"]}
need["MCG"] = need.get("MCG", 0) + 4 * b["AreaCost"]
if a.planet:
planet = pp._g(f"/data/planet/{a.planet}/", 3600)
for tk, qty in planet_mod.environment_extras(planet, b["AreaCost"]).items():
need[tk] = need.get(tk, 0) + qty
print(f"# {b['Ticker']} on {planet['planet_name']} ({a.planet}): pressure {planet['pressure']:.3f}, "
f"gravity {planet['gravity']:.2f}, temp {planet['temperature']:.1f}C")
total = 0.0
for tk, qty in sorted(need.items()):
qty *= a.amount
cost = market.walk(tk, a.cx, qty, "buy")
px = cost["total"] if not cost["short"] else None
total += px or 0
print(f"{tk:4} {qty:8.1f} {px:>10,.0f} AIC" if px is not None else f"{tk:4} {qty:8.1f} (short on {a.cx})")
print(f"total: {total:,.0f} AIC" if total else "")
if __name__ == "__main__":
main()
+9 -4
View File
@@ -20,14 +20,16 @@ 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 import planet as planet_mod
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."""
def construction_items(plan: dict, buildings: list, built: dict, planet: dict | None = None) -> dict:
"""Materials needed for the plan's buildings/infra beyond what `built` (ticker -> count) already has.
planet: optional, adds environment-extra materials (SEA/HSE/MGC/BL/INS/TSH) via planet.environment_extras()."""
bl = {b["building_ticker"]: b for b in buildings}
d = plan["plan_data"]
need: dict[str, float] = {}
@@ -39,6 +41,9 @@ def construction_items(plan: dict, buildings: list, built: dict) -> dict:
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
if planet is not None:
for etk, qty in planet_mod.environment_extras(planet, info["area_cost"]).items():
need[etk] = need.get(etk, 0) + qty * gap
return need
@@ -101,9 +106,9 @@ def main():
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)
r = simulate(plan, recipes, blds, planet["resources"], planet["fertility"], price_ask, faction, perm, planet=planet)
con = net_of_on_hand(construction_items(plan, blds, built), on_hand)
con = net_of_on_hand(construction_items(plan, blds, built, planet), 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)
+9 -1
View File
@@ -13,6 +13,7 @@ from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import ROOT, config, econ, fio, market, prunplanner as pp, saturation as sat
from puga import planet as pl
TIER_CODE = {"pioneer": "P", "settler": "S", "technician": "T", "engineer": "E", "scientist": "Sc"}
TIER_RANK = {"P": 0, "S": 1, "T": 2, "E": 3, "Sc": 4}
@@ -83,7 +84,14 @@ def main():
parts = [(x["Amount"], ask(x["CommodityTicker"])) for x in b["BuildingCosts"]]
if any(p is None for _, p in parts):
return None
return sum(n * p for n, p in parts) + a.mcg * b["AreaCost"] * mcg
cost = sum(n * p for n, p in parts) + a.mcg * b["AreaCost"] * mcg
if planet is not None:
extras = pl.environment_extras(planet, b["AreaCost"])
prices = {etk: ask(etk) for etk in extras}
if any(p is None for p in prices.values()):
return None
cost += sum(qty * prices[etk] for etk, qty in extras.items())
return cost
hab_head, hab_area = {}, {}
for tier, hab in (("pioneer", "HB1"), ("settler", "HB2"), ("technician", "HB3"), ("engineer", "HB4"), ("scientist", "HB5")):
+1 -1
View File
@@ -127,7 +127,7 @@ def marginal_freight(r_b, r_a, planet_id, cx, sf_price, ff_price, mats):
def run(spec, recipes, blds, price, faction, perm, built):
plan = plan_push.build_payload(spec, recipes, {b["building_ticker"] for b in blds})
planet = pp._g(f"/data/planet/{plan['planet_natural_id']}/", 3600)
return plan, simulate(plan, recipes, blds, planet["resources"], planet["fertility"], price, faction, perm, built=built)
return plan, simulate(plan, recipes, blds, planet["resources"], planet["fertility"], price, faction, perm, built=built, planet=planet)
def main():