Files
PuGa/puga/simulate.py
T
dodoxandClaude Sonnet 5 3bbf524ebb Add simulator replica, persistence, planet scan, README; split empire state out of the repo
- tools/simulate.py + puga/simulate.py: replica of PRUNplanner's simulator (flows and
  efficiency verified against screenshots), reports real new capex (planned minus built)
- tools/scan.py: staffing variants, freight, HQ/experts, --planet mode, demolish-later,
  --min-n as a pure market-size filter, --json output
- tools/history.py, tools/persistence.py: margin history and short-horizon payback checks
- tools/plan_push.py: guarded delete; tools/state.py: syncs to empire/
- README with features and setup; CLAUDE.md made generic
- Own-empire material (profile, state, plans, notes) moved to gitignored empire/;
  generic examples in plans/examples and state/company.example.yaml

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 00:11:02 +02:00

102 lines
6.0 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
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) -> 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}]."""
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]
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)
# 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)