Files
dodoxandClaude Sonnet 5 bf15c709ea 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>
2026-10-17 19:03:51 +02:00

245 lines
12 KiB
Python

#!/usr/bin/env python3
"""Marginal ROI of adding to a base you already run: whole-plan-with-addition minus whole-plan-
without, per docs/library.md's diffing rule (NOT the plan-level ROI simulate.py/PRUNplanner print,
which divides whole-plan profit by only the new capex and badly overstates once a base exists).
Also flags any workforce tier the addition pushes over capacity (the recurring surprise this
session - e.g. PP2 overloading pioneer housing after POL+CHP already maxed it).
tools/whatif.py plans/examples/deimos_base.yaml --add CHP:1:MG,S,CU=>IND --infra HB1:+1
tools/whatif.py before.yaml --after after.yaml # two explicit full-base specs instead
tools/whatif.py before.yaml --add POL:1 # reuses POL's existing recipe in before.yaml
"""
import argparse, copy, sys
from collections import Counter
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import config, contracts, fio, market, prunplanner as pp, shipping
from puga.simulate import simulate
sys.path.insert(0, str(Path(__file__).resolve().parent))
import plan_push
def apply_add(spec: dict, add: str):
"""BUILDING:AMOUNT[:RECIPE[|RECIPE...]] - amount is a delta; recipe reuses an existing
entry of the same building ticker in spec if omitted."""
parts = add.split(":", 2)
if len(parts) < 2:
sys.exit(f"--add needs BUILDING:AMOUNT[:RECIPE], got {add!r}")
ticker, amount = parts[0], int(parts[1])
recipes = parts[2].split("|") if len(parts) == 3 else None
for b in spec.setdefault("buildings", []):
if b["building"] == ticker and (recipes is None or b["recipes"] == recipes):
b["amount"] += amount
return
if recipes is None:
sys.exit(f"no existing {ticker} in the plan to infer a recipe from - give one: {ticker}:{amount}:RECIPE")
spec["buildings"].append({"building": ticker, "amount": amount, "recipes": recipes})
def apply_remove(spec: dict, remove: str):
"""BUILDING[:AMOUNT] - a teardown scenario: removes AMOUNT (default: all) of that building
from the plan. For "should we demolish this" questions - pass the current full base as
--after and the --remove result as the plain positional arg (or vice versa; whatif always
reports AFTER minus BEFORE, so order which one is which by what you want the sign to mean)."""
parts = remove.split(":", 1)
ticker = parts[0]
amount = int(parts[1]) if len(parts) == 2 else None
for b in spec.get("buildings", []):
if b["building"] == ticker:
b["amount"] = max(0, b["amount"] - amount) if amount else 0
spec["buildings"] = [b for b in spec.get("buildings", []) if b["amount"] > 0]
def apply_infra(spec: dict, infra: str):
"""HB1:+1,HB2:+2 - deltas added to spec['infrastructure']."""
inf = spec.setdefault("infrastructure", {})
for part in infra.split(","):
ticker, amount = part.split(":")
inf[ticker] = inf.get(ticker, 0) + int(amount)
def reclaim_value(ticker: str, qty: int, built_count: int, cx: str):
"""Sell value of the materials you'd actually get back demolishing `qty` of a building you
already own `built_count` of - real per-site data from FIO's own reclaimables endpoint
(demolition is NOT a dead loss: observed ~50-90%% of original materials back, varies by
material, not a fixed fraction - don't approximate it, look it up). None if you don't
actually own one (can't reclaim a hypothetical building)."""
if built_count <= 0 or qty <= 0:
return None
u = fio.me()
rec = fio.own(f"/rain/userplanetbuildingreclaimables/{u}", ttl=3600)
totals = Counter()
for x in rec:
if x["BuildingId"].startswith(f"{ticker}-"):
totals[x["Material"]] += x["Amount"]
if not totals:
return None
value = 0.0
items = {}
for mat, amt in totals.items():
q = round(amt / built_count * min(qty, built_count)) # amt is already summed over all built_count units
if q <= 0:
continue
w = market.walk(mat, cx, q, "sell")
items[mat] = q
value += w["total"]
return {"value": value, "items": items}
def trip_tonnage(flows: dict, mats: dict):
"""Total t/m3 the base needs to haul IN (net importers) and ship OUT (net exporters) per day -
same accounting scan.py already uses for its own trip-cost estimate (max of the four, since
imports and exports share the same round trips)."""
w_in = w_out = v_in = v_out = 0.0
for t, f in flows.items():
net = f["inp"] - f["out"]
m = mats.get(t)
if not m or abs(net) < 1e-6:
continue
if net > 0:
w_in += net * m["Weight"]; v_in += net * m["Volume"]
else:
w_out += -net * m["Weight"]; v_out += -net * m["Volume"]
return w_in, w_out, v_in, v_out
def marginal_freight(r_b, r_a, planet_id, cx, sf_price, ff_price, mats):
"""AIC/day freight for the EXTRA trips this addition requires - not the base's whole freight
bill. Continuous amortized rate (extra tonnage/cargo-hold x round-trip cost), proportional to
the addition's own tonnage, not a step function - a small addition gets a small freight charge,
not exactly zero (that would need a real per-trip ceiling/rounding this doesn't model). Still
catches a recipe whose ROI looks great only because its freight cost was left out. Returns
(freight_aic_per_day, note)."""
origin = shipping.cx_system(cx)
if origin is None:
return 0.0, f"no station found for CX {cx!r} - freight not modelled"
rt = shipping.round_trip_cost(origin, planet_id, sf_price, ff_price)
if rt is None:
return 0.0, "no priceable route (gateway without a measured LOCK fee?) - freight not modelled"
trips_b = max(trip_tonnage(r_b["flows"], mats)) / contracts.SHIP_CARGO
trips_a = max(trip_tonnage(r_a["flows"], mats)) / contracts.SHIP_CARGO
extra_trips = trips_a - trips_b
return extra_trips * rt, f"{extra_trips:+.3f} extra round trips/day x {rt:,.0f} AIC/trip"
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, planet=planet)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("before", help="full-base plan spec as it stands (or as a baseline you want to compare from)")
ap.add_argument("--after", help="full-base plan spec with the addition already in it; skips --add/--infra")
ap.add_argument("--add", action="append", default=[], help="BUILDING:AMOUNT[:RECIPE[|RECIPE]], repeatable")
ap.add_argument("--remove", action="append", default=[], help="BUILDING[:AMOUNT] - teardown scenario, removes AMOUNT (default: all), repeatable")
ap.add_argument("--infra", action="append", default=[], help="HB1:+1,HB2:+2, repeatable")
ap.add_argument("--cx", default=config.DEFAULT_CX)
ap.add_argument("--basis", default="uni30", choices=["real", "uni30", "vwap30", "vwap7", "ask", "bid", "mid"])
ap.add_argument("--no-faction", action="store_true")
a = ap.parse_args()
before_spec = yaml.safe_load(Path(a.before).read_text())
if a.after:
after_spec = yaml.safe_load(Path(a.after).read_text())
else:
if not a.add and not a.remove and not a.infra:
sys.exit("give --after, or at least one --add/--remove/--infra")
after_spec = copy.deepcopy(before_spec)
for add in a.add:
apply_add(after_spec, add)
for remove in a.remove:
apply_remove(after_spec, remove)
for infra in a.infra:
apply_infra(after_spec, infra)
recipes, blds = pp.recipes(), pp.buildings()
snap = market.snapshot()
def price(t, side="buy"):
if a.basis == "uni30":
v = market.uni30(snap, t)
if v:
return v
q = snap.get((t, a.cx))
return q.ask if q else None
q = snap.get((t, a.cx))
if not q:
return None
return {"real": q.ask if side == "buy" else (q.vwap7 or q.vwap30 or q.bid),
"vwap30": q.vwap30 or q.vwap7 or q.ask, "vwap7": q.vwap7 or q.vwap30 or q.ask,
"ask": q.ask, "bid": q.bid, "mid": (q.ask + q.bid) / 2 if q.ask and q.bid else None}[a.basis]
st = yaml.safe_load(config.state_path().read_text())
faction = None if a.no_faction else (st.get("company") or {}).get("faction")
perm = (st.get("permits", {}).get("used", 1), st.get("permits", {}).get("total", 2))
planet_id = before_spec["planet"]
built = next((b.get("buildings", {}) for b in st.get("bases", []) if b.get("planet") == planet_id), {})
# hq comes from each spec file's own "hq:" field (plan_push.build_payload reads it directly) -
# keep both before/after specs' hq set explicitly rather than relying on state here.
plan_b, r_b = run(before_spec, recipes, blds, price, faction, perm, built)
plan_a, r_a = run(after_spec, recipes, blds, price, faction, perm, built)
d_profit = r_a["profit"] - r_b["profit"]
d_capex = r_a["new_capex"] - r_b["new_capex"]
mats = {m["Ticker"]: m for m in fio.materials()}
sf_px = (snap.get(("SF", a.cx)) or market.Quote("SF", a.cx)).ask or 0
ff_px = (snap.get(("FF", a.cx)) or market.Quote("FF", a.cx)).ask or 0
freight, freight_note = marginal_freight(r_b, r_a, planet_id, a.cx, sf_px, ff_px, mats)
d_profit -= freight
before_counts = Counter(b["building"] for b in before_spec.get("buildings", []) for _ in range(b["amount"]))
after_counts = Counter(b["building"] for b in after_spec.get("buildings", []) for _ in range(b["amount"]))
reclaim_total = 0.0
reclaim_lines = []
for ticker in before_counts:
removed_qty = before_counts[ticker] - after_counts.get(ticker, 0)
if removed_qty > 0:
r = reclaim_value(ticker, removed_qty, built.get(ticker, 0), a.cx)
if r:
reclaim_total += r["value"]
items = ", ".join(f"{q}{m}" for m, q in r["items"].items())
reclaim_lines.append(f" {ticker} x{removed_qty}: {r['value']:,.0f} AIC ({items})")
print(f"BEFORE profit/day {r_b['profit']:,.0f} new_capex {r_b['new_capex']:,.0f}")
print(f"AFTER profit/day {r_a['profit']:,.0f} new_capex {r_a['new_capex']:,.0f}")
print(f"FREIGHT {freight:+,.0f} AIC/day marginal ({freight_note})")
if reclaim_lines:
print("\nRECLAIMED MATERIALS (demolishing what's removed, sell value, real FIO data):")
for line in reclaim_lines:
print(line)
print(f" total: {reclaim_total:,.0f} AIC")
net_capex = d_capex - reclaim_total
net_label = f" net cash GAINED {-net_capex:,.0f}" if net_capex < 0 and reclaim_total else (f" net new capex {net_capex:,.0f}" if reclaim_total else "")
print(f"\nMARGINAL profit/day {d_profit:,.0f} new_capex {d_capex:,.0f}" + net_label, end="")
if net_capex > 0 and d_profit > 0:
print(f" payback {net_capex/d_profit:.1f} d (simple, non-compounding - a one-time capex paying itself off; not a reinvestable rate, see docs/decisions.md)")
elif net_capex <= 0 and d_profit > 0:
print(" (no net new capex after reclaim)" if reclaim_total else " (no new capex)")
else:
print(" (marginal profit <= 0 - not worth it, regardless of reclaim)")
print("\nWORKFORCE (after) need supply open eff%")
newly_overloaded = []
for t, w in r_a["workforce"].items():
if w["need"] or w["supply"]:
was_already_overloaded = r_b["workforce"].get(t, {}).get("open", 0) < 0
flag = ""
if w["open"] < 0 and not was_already_overloaded:
flag = " <- NEWLY OVERLOADED by this addition, drags every building's efficiency down"
newly_overloaded.append(t)
elif w["open"] < 0:
flag = " (already overloaded before this addition too - not new)"
print(f" {t:10} {w['need']:5.0f} {w['supply']:6.0f} {w['open']:5.0f} {w['eff']*100:7.2f}{flag}")
if newly_overloaded:
print(f"\nFix before building: more housing for {', '.join(newly_overloaded)} (see which HB covers each tier).")
if __name__ == "__main__":
main()