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>
106 lines
6.4 KiB
Python
106 lines
6.4 KiB
Python
"""Replica of PRUNplanner's plan simulator (usePlanCalculation.ts): workforce, efficiency, material I/O, cost, profit.
|
|
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"}
|
|
DEGRADATION_DAYS = 180 # usePlanCalculation.ts: degradation/day = construction cost / 180
|
|
|
|
|
|
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, 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}
|
|
cogc = plan.get("plan_cogc")
|
|
cogc = None if cogc in (None, "---") else cogc
|
|
experts = {e["type"].upper(): e["amount"] for e in d["experts"]}
|
|
lux = {w["type"]: (w["lux1"], w["lux2"]) for w in d["workforce"]}
|
|
|
|
req = {t: sum(bl[b["name"]][t + "s"] * b["amount"] for b in d["buildings"]) for t in TIERS}
|
|
cap = dict.fromkeys(TIERS, 0)
|
|
for i in d["infrastructure"]:
|
|
hab = bl[i["building"]].get("habitations")
|
|
if hab:
|
|
for t in TIERS:
|
|
cap[t] += hab[t + "s"] * i["amount"]
|
|
tier_eff = {t: econ.tier_efficiency(cap[t], req[t], *lux[t]) for t in TIERS}
|
|
workforce = {t: dict(need=req[t], supply=cap[t], open=cap[t] - req[t], eff=tier_eff[t]) for t in TIERS}
|
|
|
|
flows: dict[str, dict[str, float]] = {}
|
|
|
|
def add(side, tk, amt):
|
|
flows.setdefault(tk, {"in": 0.0, "out": 0.0})[side] += amt
|
|
|
|
lines = []
|
|
for b in d["buildings"]:
|
|
info = bl[b["name"]]
|
|
heads = {t + "s": info[t + "s"] for t in TIERS}
|
|
eff, elements = econ.building_efficiency(heads, tier_eff, expertise=info["expertise"], cogc=cogc, hq=plan.get("plan_corphq", False),
|
|
experts=experts, faction=faction, permits_used=permits[0], permits_total=permits[1],
|
|
fertility=fertility, is_farm=b["name"] in FARMS)
|
|
rs = []
|
|
for ar in b["active_recipes"]:
|
|
if ar["amount"] == 0: # switched off in the UI (quantity 0): no production, workforce still required
|
|
continue
|
|
rid = ar["recipeid"]
|
|
if "#" in rid and "=>" not in rid: # extraction, e.g. EXT#ALO: daily rate = factor*70 (60 gas)
|
|
tk = rid.split("#")[1]
|
|
res = next(r for r in resources if r["material_ticker"] == tk)
|
|
rs.append(dict(time_ms=econ.TOTAL_MS_DAY, inputs={}, outputs={tk: res["daily_extraction"]}, amount=ar["amount"]))
|
|
else:
|
|
r = rec[rid]
|
|
rs.append(dict(time_ms=r["time_ms"], inputs={i["material_ticker"]: i["material_amount"] for i in r["inputs"]},
|
|
outputs={o["material_ticker"]: o["material_amount"] for o in r["outputs"]}, amount=ar["amount"]))
|
|
if rs and eff > 0:
|
|
io = econ.production_io(rs, eff, b["amount"])
|
|
for tk, v in io["in"].items():
|
|
add("in", tk, v)
|
|
for tk, v in io["out"].items():
|
|
add("out", tk, v)
|
|
lines.append(dict(building=b["name"], amount=b["amount"], efficiency=eff, elements=elements, recipes=[a["recipeid"] for a in b["active_recipes"]]))
|
|
|
|
for t in TIERS:
|
|
for tk, v in econ.workforce_consumption(t, req[t], cap[t], *lux[t]).items():
|
|
add("in", tk, v)
|
|
|
|
def unit_cost(tk):
|
|
info = bl[tk]
|
|
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.
|
|
prod_cost = sum(unit_cost(b["name"]) * b["amount"] for b in d["buildings"])
|
|
infra_cost = sum(unit_cost(i["building"]) * i["amount"] for i in d["infrastructure"])
|
|
cm_cost = unit_cost("CM")
|
|
plan_cost = prod_cost + infra_cost + cm_cost
|
|
area = (sum(bl[b["name"]]["area_cost"] * b["amount"] for b in d["buildings"])
|
|
+ sum(bl[i["building"]]["area_cost"] * i["amount"] for i in d["infrastructure"]) + bl["CM"]["area_cost"])
|
|
degradation = prod_cost / DEGRADATION_DAYS
|
|
rows = {}
|
|
missing = []
|
|
for tk, f in flows.items():
|
|
delta = f["out"] - f["in"]
|
|
p = price(tk, "sell" if delta > 0 else "buy")
|
|
if p is None:
|
|
missing.append(tk)
|
|
p = 0.0
|
|
rows[tk] = dict(inp=f["in"], out=f["out"], delta=delta, value=delta * p, price=p)
|
|
gross = sum(r["value"] for r in rows.values())
|
|
profit = gross - degradation
|
|
# construction still to do = planned minus already built (incl. CM), like PRUNplanner's Construction Cart
|
|
built = built or {}
|
|
planned = {}
|
|
for tk, n in [(b["name"], b["amount"]) for b in d["buildings"]] + [(i["building"], i["amount"]) for i in d["infrastructure"]]:
|
|
planned[tk] = planned.get(tk, 0) + n
|
|
new_capex = sum(unit_cost(tk) * max(n - built.get(tk, 0), 0) for tk, n in planned.items()) + (0 if built.get("CM") else cm_cost)
|
|
return dict(cm_cost=cm_cost, new_capex=new_capex, planned=planned, area=area, prod_cost=prod_cost, infra_cost=infra_cost, workforce=workforce, buildings=lines, flows=rows, plan_cost=plan_cost, degradation=degradation, gross=gross,
|
|
profit=profit, roi_days=(plan_cost / profit if profit > 0 else None), missing_prices=missing)
|