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
+10 -3
View File
@@ -1,5 +1,5 @@
"""Tiny disk cache for JSON over HTTP, keyed by URL, expiry by TTL seconds."""
import hashlib, json, time, urllib.request
import hashlib, json, time, urllib.error, urllib.request
from .config import CACHE_DIR
UA = "PuGa/0.1 (personal advisory toolkit)"
@@ -11,7 +11,14 @@ def get_json(url: str, ttl: int, headers: dict[str, str] | None = None, refresh:
if not refresh and f.exists() and time.time() - f.stat().st_mtime < ttl:
return json.loads(f.read_text())
req = urllib.request.Request(url, headers={"User-Agent": UA, **(headers or {})})
with urllib.request.urlopen(req, timeout=60) as r:
data = json.load(r)
for attempt in range(4): # transient SSL/connection errors happen on long scans
try:
with urllib.request.urlopen(req, timeout=60) as r:
data = json.load(r)
break
except (urllib.error.URLError, ConnectionError, TimeoutError):
if attempt == 3:
raise
time.sleep(1.5 * (attempt + 1))
f.write_text(json.dumps(data))
return data
+9
View File
@@ -24,3 +24,12 @@ def get(key: str, default: str | None = None) -> str | None:
DEFAULT_CX = get("DEFAULT_CX", "AI1")
CACHE_DIR = ROOT / "data" / "cache"
EMPIRE_DIR = ROOT / "empire" # gitignored: our empire: player profile, company state, own plans and notes
def state_path():
"""Real company state if synced (empire/), else the tracked example so the tools still run."""
real = EMPIRE_DIR / "state" / "company.yaml"
return real if real.exists() else ROOT / "state" / "company.example.yaml"
+1 -1
View File
@@ -24,7 +24,7 @@ def systems(refresh=False): return _g("/systemstars", STATIC, refresh)
def stations(refresh=False): return _g("/exchange/station", STATIC, refresh)
def private(path: str, ttl: int = 300, refresh: bool = False):
def own(path: str, ttl: int = 300, refresh: bool = False):
"""Authenticated FIO REST (own data: /sites/{user}, /storage/{user}, /production/{user}, /ship/ships/{user}...).
Auth: `Authorization: <FIO_REST_KEY>` (verified 2026-09-18). FIO_API_KEY does NOT work on rest.fnar.net."""
from . import config
+11
View File
@@ -51,3 +51,14 @@ def walk(mat: str, cx: str, qty: float, side: str, refresh=False) -> dict:
break
filled = qty - left
return dict(avg=total / filled if filled else None, filled=filled, worst=worst, total=total, short=left > 0)
def uni30(snap: dict, tk: str) -> float | None:
"""PRUNplanner's 'Universe 30D' price basis: volume-weighted 30d VWAP across all exchanges (verified against its BHP price)."""
num = den = 0.0
for cx in CXS:
q = snap.get((tk, cx))
if q and q.vwap30 and q.traded30:
num += q.vwap30 * q.traded30
den += q.traded30
return num / den if den else None
+101
View File
@@ -0,0 +1,101 @@
"""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)