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>
This commit is contained in:
2026-09-19 00:11:02 +02:00
co-authored by Claude Sonnet 5
parent 7a538cb300
commit 3bbf524ebb
26 changed files with 778 additions and 259 deletions
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Read a plan the way PRUNplanner's simulator does: efficiencies, workforce, material I/O, profit.
Source: a local spec (plans/*.yaml) or the plan stored in his PRUNplanner account (--uuid; picks up his UI edits).
tools/simulate.py plans/examples/base_plus_hwp.yaml
tools/simulate.py --uuid <plan-uuid> --basis ask --cx AI1
Prices: --basis real (buy at ask, sell at 7d VWAP at --cx: what a patient trader gets) | uni30 (default: volume-weighted 30d VWAP across all exchanges, = PRUNplanner 'Universe 30D') | vwap30 | vwap7 | ask | bid | mid at --cx."""
import argparse, sys
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import ROOT, config, market, prunplanner as pp
from puga.simulate import simulate
sys.path.insert(0, str(Path(__file__).resolve().parent))
import plan_push
def main():
ap = argparse.ArgumentParser()
ap.add_argument("spec", nargs="?")
ap.add_argument("--uuid")
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("--off", default="", help="building tickers whose recipes are switched off (quantity 0), e.g. EXT,SME")
ap.add_argument("--no-faction", action="store_true")
ap.add_argument("--cm-free", action="store_true", help="treat the core module as free/already there (e.g. new base where CM cost is ignored)")
ap.add_argument("--no-hq", action="store_true", help="ignore hq: true from empire/state/company.yaml")
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")
off = {x.strip() for x in a.off.split(",") if x.strip()}
for b in plan["plan_data"]["buildings"]:
if b["name"] in off:
for ar in b["active_recipes"]:
ar["amount"] = 0
planet = pp._g(f"/data/planet/{plan['planet_natural_id']}/", 3600)
snap = market.snapshot()
def price(t, side="both"):
if a.basis == "real":
q = snap.get((t, a.cx))
return None if not q else (q.ask if side == "buy" else (q.vwap7 or q.vwap30 or q.bid))
if a.basis == "uni30":
return market.uni30(snap, t) or ((snap.get((t, a.cx)) or market.Quote(t, a.cx)).ask)
q = snap.get((t, a.cx))
if not q:
return None
v = {"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]
return v
import yaml as _y
st = _y.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))
if not a.no_hq and st.get("hq"):
plan["plan_corphq"] = True
built = next((b.get("buildings", {}) for b in st.get("bases", []) if b.get("planet") == plan["planet_natural_id"]), {})
if a.cm_free:
built = {**built, "CM": 1}
r = simulate(plan, recipes, blds, planet["resources"], planet["fertility"], price, faction, perm, built=built)
print(f"{plan['plan_name']} {plan['planet_natural_id']} COGC {plan.get('plan_cogc')} prices: {a.cx} {a.basis}")
print(f"Area {r['area']:.0f}/500 Profit/day {r['profit']:,.0f} gross {r['gross']:,.0f} degradation {r['degradation']:,.0f} plan cost {r['plan_cost']:,.0f} ROI {r['roi_days'] and round(r['roi_days'], 2)} d")
nc = r["new_capex"]
print(f"NEW CAPEX = planned minus already built ({built or 'nothing built'}): {nc:,.0f} -> payback {nc / r['profit']:.2f} d = {100 * r['profit'] / nc:.1f}%/day" if r["profit"] > 0 and nc > 0 else ("NEW CAPEX: nothing to build" if nc <= 0 else "NEW CAPEX: profit <= 0"))
print("\nWORKFORCE need supply open eff%")
for t, w in r["workforce"].items():
if w["need"] or w["supply"]:
print(f" {t:10} {w['need']:5.0f} {w['supply']:6.0f} {w['open']:5.0f} {w['eff']*100:7.2f}")
print("\nBUILDINGS")
for b in r["buildings"]:
print(f" {b['amount']:3} x {b['building']:4} eff {b['efficiency']*100:7.2f}% {b['recipes']}")
print(f"\n{'MATERIAL':8} {'in/d':>9} {'out/d':>9} {'delta':>9} {'price':>7} {'value/d':>10}")
for tk, f in sorted(r["flows"].items(), key=lambda kv: -abs(kv[1]["value"])):
print(f"{tk:8} {f['inp']:9.2f} {f['out']:9.2f} {f['delta']:9.2f} {f['price']:7.0f} {f['value']:10.0f}")
if r["missing_prices"]:
print("no price for:", r["missing_prices"])
if __name__ == "__main__":
main()