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:
+103
-50
@@ -4,21 +4,21 @@ For each recipe: buildings the market can absorb (N*), and ROI/day at N=1 and N*
|
||||
(not top-of-book) and ask-walked inputs. Excludes thin markets by default.
|
||||
|
||||
tools/scan.py # AI1, all tiers, top 30 by ROI at N*
|
||||
tools/scan.py --cogc METALLURGY --tier S # Deimos-like: metallurgy COGC, up to settlers
|
||||
tools/scan.py --cogc METALLURGY --tier S # e.g. a Metallurgy-COGC base, up to settlers
|
||||
tools/scan.py --sort total --min-n 3 # rank by absorbable profit/day
|
||||
"""
|
||||
import argparse, sys
|
||||
import argparse, itertools, sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from puga import ROOT, config, econ, fio, market, saturation as sat
|
||||
from puga import ROOT, config, econ, fio, market, prunplanner as pp, saturation as sat
|
||||
|
||||
TIER_CODE = {"pioneer": "P", "settler": "S", "technician": "T", "engineer": "E", "scientist": "Sc"}
|
||||
TIER_RANK = {"P": 0, "S": 1, "T": 2, "E": 3, "Sc": 4}
|
||||
|
||||
|
||||
def load_state():
|
||||
p = ROOT / "state" / "company.yaml"
|
||||
p = config.state_path()
|
||||
return yaml.safe_load(p.read_text()) if p.exists() else {}
|
||||
|
||||
|
||||
@@ -27,10 +27,18 @@ def main():
|
||||
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
||||
ap.add_argument("--tier", help="max workforce tier: P S T E Sc")
|
||||
ap.add_argument("--cogc", help="COGC programme on the base, e.g. METALLURGY, or SETTLERS")
|
||||
ap.add_argument("--staffing", default="both", choices=["full", "under", "both"], help="evaluate fully staffed, understaffed (drop tiers), or both (default)")
|
||||
ap.add_argument("--trip-cost", type=float, default=9250.0, help="AIC per loaded round trip AI1<->base (handoff est. 8.5-10k); 0 disables freight")
|
||||
ap.add_argument("--cargo", type=float, default=500.0, help="t and m3 per trip (starter ship)")
|
||||
ap.add_argument("--no-hq", action="store_true", help="ignore HQ from state (new base without the HQ)")
|
||||
ap.add_argument("--permits-used", type=int, help="override permits used (affects faction bonus multiplier), e.g. 2 for a second base")
|
||||
ap.add_argument("--deprec", type=float, default=0, help="demolish-later mode: building value decays linearly to 0 over this many days (game: ~60); subtracts capex/deprec per day")
|
||||
ap.add_argument("--planet", help="planet natural id: adds extraction (EXT/COL/RIG) from its resources, uses its fertility and active COGC; new base (not in state) => no HQ, permits+1")
|
||||
ap.add_argument("--own", type=int, default=1, help="how many buildings WE would run; ROI is measured at this size (default 1). --min-n is only a market-size filter")
|
||||
ap.add_argument("--skip", default="", help="tiers left unstaffed, e.g. technician (no housing/wages; efficiency = staffed headcount share)")
|
||||
ap.add_argument("--experts", default="", help="expert counts, e.g. METALLURGY=2 (bonus x1.0306..1.284 for 1..5)")
|
||||
ap.add_argument("--hq", action="store_true", help="corp HQ bonus x1.1")
|
||||
ap.add_argument("--no-faction", action="store_true", help="ignore faction bonus from state/company.yaml")
|
||||
ap.add_argument("--no-faction", action="store_true", help="ignore faction bonus from empire/state/company.yaml")
|
||||
ap.add_argument("--min-n", type=float, default=1.0, help="min buildings the market absorbs (default 1)")
|
||||
ap.add_argument("--max-n", type=int, default=50, help="cap N* (capital/attention limit)")
|
||||
ap.add_argument("--budget", type=float, help="capital limit in AIC: caps N and drops recipes whose single-building capex exceeds it")
|
||||
@@ -39,6 +47,7 @@ def main():
|
||||
ap.add_argument("--top", type=int, default=30)
|
||||
ap.add_argument("--k", type=int, default=80, help="candidates refined with order books")
|
||||
ap.add_argument("--show-thin", action="store_true")
|
||||
ap.add_argument("--json", help="also write all rows to this JSON file (for tools/persistence.py)")
|
||||
a = ap.parse_args()
|
||||
|
||||
snap = market.snapshot()
|
||||
@@ -47,8 +56,22 @@ def main():
|
||||
st = load_state()
|
||||
faction = None if a.no_faction else (st.get("company") or {}).get("faction")
|
||||
pu, pt = (st.get("permits") or {}).get("used", 1), (st.get("permits") or {}).get("total", 2)
|
||||
if a.permits_used:
|
||||
pu = a.permits_used
|
||||
planet = None
|
||||
if a.planet:
|
||||
planet = pp._g(f"/data/planet/{a.planet}/", 3600)
|
||||
if not a.cogc:
|
||||
a.cogc = (planet.get("active_cogc_program_type") or "").replace("ADVERTISING_", "") or None
|
||||
if a.planet not in [b.get("planet") for b in st.get("bases", [])]: # new base: no HQ, one more permit used
|
||||
a.no_hq = True
|
||||
if not a.permits_used:
|
||||
pu += 1
|
||||
print(f"# planet {a.planet} {planet['planet_name']}: fertility {planet['fertility']:.2f}, COGC {a.cogc}, resources "
|
||||
+ ", ".join(f"{r['material_ticker']} {r['daily_extraction']:.0f}/d" for r in planet["resources"]))
|
||||
|
||||
blds = {b["Ticker"]: b for b in fio.buildings()}
|
||||
mat = {m["Ticker"]: m for m in fio.materials()}
|
||||
mcg = ask("MCG") or 0
|
||||
|
||||
def bcost(tk):
|
||||
@@ -76,53 +99,74 @@ def main():
|
||||
skip = {x.strip() for x in a.skip.split(",") if x.strip()}
|
||||
wages = {t: wage(t) for t in econ.TIERS}
|
||||
cands = []
|
||||
for rec in fio.recipes():
|
||||
all_recipes = list(fio.recipes())
|
||||
if planet: # extraction pseudo-recipes: one 24h cycle yielding the daily extraction
|
||||
bt = {"MINERAL": "EXT", "GASEOUS": "COL", "LIQUID": "RIG"}
|
||||
for r in planet["resources"]:
|
||||
all_recipes.append(dict(BuildingTicker=bt[r["resource_type"]], Inputs=[], TimeMs=econ.TOTAL_MS_DAY,
|
||||
Outputs=[dict(Ticker=r["material_ticker"], Amount=r["daily_extraction"])]))
|
||||
for rec in all_recipes:
|
||||
b = blds.get(rec["BuildingTicker"])
|
||||
if not b or not rec["Outputs"] or b["Ticker"].startswith("HB"):
|
||||
continue
|
||||
heads = {t: b[t.capitalize() + "s"] for t in econ.TIERS}
|
||||
used = [t for t in econ.TIERS if heads[t] and t not in skip] # staffed tiers
|
||||
if not used:
|
||||
continue
|
||||
top = TIER_CODE[used[-1]]
|
||||
if a.tier and TIER_RANK[top] > TIER_RANK[a.tier]:
|
||||
continue
|
||||
if any(hab_head[t] is None or wages[t] is None for t in used):
|
||||
need = [t for t in econ.TIERS if heads[t]]
|
||||
if not need:
|
||||
continue
|
||||
ins = {i["Ticker"]: i["Amount"] for i in rec["Inputs"]}
|
||||
outs = {o["Ticker"]: o["Amount"] for o in rec["Outputs"]}
|
||||
if any(not Q(t) or not Q(t).ask for t in ins) or any(not Q(t) or not Q(t).bid for t in outs):
|
||||
continue
|
||||
bd = {t + "s": heads[t] for t in econ.TIERS}
|
||||
eff, _ = econ.building_efficiency(bd, {t: (0.0 if t in skip else 1.0) for t in econ.TIERS}, expertise=b["Expertise"] or None, cogc=a.cogc,
|
||||
hq=a.hq, experts=experts, faction=faction, permits_used=pu, permits_total=pt)
|
||||
io = econ.production_io([dict(time_ms=rec["TimeMs"], inputs=ins, outputs=outs)], eff, 1)
|
||||
# saturation per output
|
||||
n_lim, lim, thin, mm = float("inf"), "", False, {}
|
||||
for t, qo in io["out"].items():
|
||||
x = Q(t)
|
||||
tr = sat.tref(x.traded7, x.traded30)
|
||||
if x.mm_buy and x.bid and x.mm_buy >= 0.9 * x.bid:
|
||||
mm[t] = x.mm_buy # market maker floor: unlimited depth at mm_buy
|
||||
capex0 = bcost(b["Ticker"])
|
||||
if capex0 is None:
|
||||
continue
|
||||
# staffing variants: fully staffed and every partial subset (efficiency = staffed headcount share; housing/wages only for staffed tiers)
|
||||
variants = []
|
||||
if a.staffing in ("full", "both"):
|
||||
variants.append(tuple(need))
|
||||
if a.staffing in ("under", "both") and len(need) > 1:
|
||||
for k in range(1, len(need)):
|
||||
variants += list(itertools.combinations(need, k))
|
||||
for keep in variants:
|
||||
keep = tuple(t for t in keep if t not in skip)
|
||||
if not keep:
|
||||
continue
|
||||
thin |= sat.is_thin(tr, x.demand, qo)
|
||||
n = sat.n_out(tr, 0, x.demand, qo) # stage 1: flow + demand only; queue penalty applied in stage 2 with the book
|
||||
if n < n_lim:
|
||||
n_lim, lim = n, t
|
||||
if n_lim == float("inf"):
|
||||
n_lim, lim = float(a.max_n), "MM"
|
||||
if (thin or n_lim < a.min_n) and not a.show_thin:
|
||||
continue
|
||||
capex = bcost(b["Ticker"])
|
||||
if capex is None:
|
||||
continue
|
||||
capex += sum(heads[t] * hab_head[t] for t in used)
|
||||
wcost = sum(heads[t] * wages[t] for t in used)
|
||||
price0 = {t: mm.get(t) or min(x for x in (Q(t).vwap7, Q(t).vwap30, Q(t).ask) if x) if (Q(t).vwap7 or Q(t).vwap30 or Q(t).ask) else Q(t).bid
|
||||
for t in io["out"]}
|
||||
rough = sum(io["out"][t] * price0[t] for t in io["out"]) - sum(io["in"][t] * ask(t) for t in io["in"]) - wcost
|
||||
cands.append(dict(rec=rec, b=b, io=io, eff=eff, capex=capex, wcost=wcost, n_lim=n_lim, lim=lim, thin=thin, mm=mm,
|
||||
rough_roi=100 * rough / capex, top=top, ins=ins, outs=outs))
|
||||
top = TIER_CODE[keep[-1]]
|
||||
if a.tier and TIER_RANK[top] > TIER_RANK[a.tier]:
|
||||
continue
|
||||
if any(hab_head[t] is None or wages[t] is None for t in keep):
|
||||
continue
|
||||
bd = {t + "s": heads[t] for t in econ.TIERS}
|
||||
eff, _ = econ.building_efficiency(bd, {t: (1.0 if t in keep else 0.0) for t in econ.TIERS}, expertise=b["Expertise"] or None,
|
||||
cogc=a.cogc, hq=a.hq or (bool(st.get("hq")) and not a.no_hq), experts=experts, faction=faction,
|
||||
permits_used=pu, permits_total=pt,
|
||||
fertility=planet["fertility"] if planet else None, is_farm=b["Ticker"] in ("FRM", "ORC"))
|
||||
if eff <= 0:
|
||||
continue # e.g. farms on fertility -1 planets
|
||||
io = econ.production_io([dict(time_ms=rec["TimeMs"], inputs=ins, outputs=outs)], eff, 1)
|
||||
n_lim, lim, thin, mm = float("inf"), "", False, {}
|
||||
for t, qo in io["out"].items():
|
||||
x = Q(t)
|
||||
tr = sat.tref(x.traded7, x.traded30)
|
||||
if x.mm_buy and x.bid and x.mm_buy >= 0.9 * x.bid:
|
||||
mm[t] = x.mm_buy # market maker floor: unlimited depth at mm_buy
|
||||
continue
|
||||
thin |= sat.is_thin(tr, x.demand, qo)
|
||||
n = sat.n_out(tr, 0, x.demand, qo) # stage 1: flow + demand only; queue penalty applied in stage 2 with the book
|
||||
if n < n_lim:
|
||||
n_lim, lim = n, t
|
||||
if n_lim == float("inf"):
|
||||
n_lim, lim = float(a.max_n), "MM"
|
||||
if (thin or n_lim < a.min_n) and not a.show_thin:
|
||||
continue
|
||||
capex = capex0 + sum(heads[t] * hab_head[t] for t in keep)
|
||||
wcost = sum(heads[t] * wages[t] for t in keep)
|
||||
price0 = {t: mm.get(t) or min(x for x in (Q(t).vwap7, Q(t).vwap30, Q(t).ask) if x) if (Q(t).vwap7 or Q(t).vwap30 or Q(t).ask) else Q(t).bid
|
||||
for t in io["out"]}
|
||||
rough = sum(io["out"][t] * price0[t] for t in io["out"]) - sum(io["in"][t] * ask(t) for t in io["in"]) - wcost
|
||||
staff = "".join(TIER_CODE[t] for t in keep) + ("" if len(keep) == len(need) else "-")
|
||||
cands.append(dict(rec=rec, b=b, io=io, eff=eff, capex=capex, wcost=wcost, n_lim=n_lim, lim=lim, thin=thin, mm=mm,
|
||||
rough_roi=100 * rough / capex, top=top, staff=staff, ins=ins, outs=outs))
|
||||
|
||||
cands.sort(key=lambda c: c["rough_roi"], reverse=True)
|
||||
rows = []
|
||||
@@ -162,13 +206,19 @@ def main():
|
||||
if w["short"]:
|
||||
return None
|
||||
cost += w["total"]
|
||||
return rev - cost - N * c["wcost"]
|
||||
frt = 0.0
|
||||
if a.trip_cost: # imports and exports share round trips: trips = worst of tonnes/m3 in either direction
|
||||
wi = sum(N * qi * mat[t]["Weight"] for t, qi in io["in"].items()); vi = sum(N * qi * mat[t]["Volume"] for t, qi in io["in"].items())
|
||||
wo = sum(N * qo * mat[t]["Weight"] for t, qo in io["out"].items()); vo = sum(N * qo * mat[t]["Volume"] for t, qo in io["out"].items())
|
||||
c["tons"] = max(wi, wo)
|
||||
frt = max(wi, wo, vi, vo) / a.cargo * a.trip_cost
|
||||
c["frt"] = frt
|
||||
dep = N * c["capex"] / a.deprec if a.deprec else 0.0 # value lost per day if demolished after holding
|
||||
return rev - cost - N * c["wcost"] - frt - dep
|
||||
|
||||
if a.budget and c["capex"] > a.budget:
|
||||
continue
|
||||
n_star = max(1, min(int(c["n_lim"]), a.max_n))
|
||||
if a.budget:
|
||||
n_star = max(1, min(n_star, int(a.budget // c["capex"])))
|
||||
n_star = max(1, a.own) # our size; the market-size filter (--min-n) already applied above
|
||||
net1 = evaluate(1)
|
||||
if net1 is None:
|
||||
continue
|
||||
@@ -180,15 +230,18 @@ def main():
|
||||
continue
|
||||
rows.append(dict(roi1=100 * net1 / c["capex"], roiN=100 * netN / n_star / c["capex"], total=netN, n=n_star,
|
||||
n_lim=c["n_lim"], lim=c["lim"], capex=c["capex"] * n_star, eff=c["eff"], bld=c["b"]["Ticker"],
|
||||
top=c["top"], h=c["rec"]["TimeMs"] / 3.6e6, thin=c["thin"],
|
||||
top=c["top"], staff=c["staff"], tons=c.get("tons", 0), frt=c.get("frt", 0), h=c["rec"]["TimeMs"] / 3.6e6, thin=c["thin"],
|
||||
rec=" ".join(f"{v:g}{k}" for k, v in c["ins"].items()) + " -> " + " ".join(f"{v:g}{k}" for k, v in c["outs"].items())))
|
||||
key = {"roi": "roiN", "roi1": "roi1", "total": "total"}[a.sort]
|
||||
rows.sort(key=lambda r: r[key], reverse=True)
|
||||
if a.json:
|
||||
import json
|
||||
Path(a.json).write_text(json.dumps(rows))
|
||||
print(f"# {a.cx} tier<={a.tier or 'any'} cogc={a.cogc or '-'} faction={faction or '-'} ({pu}/{pt} permits) min-n>={a.min_n} sorted by {a.sort}")
|
||||
print("# price: patient asks near VWAP (clamped), inputs ask-walked; N* = market-absorbable buildings (cap %d); all estimates" % a.max_n)
|
||||
print(f"{'ROI@1':>6} {'ROI@N*':>7} {'N*':>4} {'lim':4} {'profit/d@N*':>11} {'capex@N*':>9} {'eff':>4} {'bld':4} {'tier':4} {'h':>5} recipe")
|
||||
print("# ROI/day is for OUR size (--own buildings, default 1) incl. our own price impact; market = buildings the market could absorb (--min-n filters on it, nothing else). Net of freight. Patient asks near VWAP, inputs ask-walked; all estimates")
|
||||
print(f"{'ROI/d':>6} {'own':>3} {'market':>6} {'lim':4} {'profit/d':>11} {'capex':>9} {'eff':>4} {'bld':4} {'staff':5} {'t/d':>5} {'frt/d':>6} {'h':>5} recipe")
|
||||
for r in rows[:a.top]:
|
||||
print(f"{r['roi1']:6.1f} {r['roiN']:7.1f} {r['n']:4d} {r['lim']:4} {r['total']:11.0f} {r['capex']:9.0f} {r['eff']*100:4.0f} {r['bld']:4} {r['top']:4} "
|
||||
print(f"{r['roiN']:6.1f} {r['n']:3d} {r['n_lim']:6.1f} {r['lim']:4} {r['total']:11.0f} {r['capex']:9.0f} {r['eff']*100:4.0f} {r['bld']:4} {r['staff']:5} {r['tons']:5.0f} {r['frt']:6.0f} "
|
||||
f"{r['h']:5.1f} {r['rec']}{' [thin]' if r['thin'] else ''}")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user