Initial PuGa toolkit: data layer, econ, depth-aware scan, state sync, plan push

- puga/: cached FIO + PRUNplanner clients, market view with order-book walk,
  econ formulas ported from PRUNplanner (tested against its suite and live FIO),
  saturation model v1 (reviewed by Opus)
- tools/: scan (depth-aware), price, book, chain, state sync, plan_push
  (dry run default, [PuGa]-prefixed plans only), legacy prun_scan/prun_cxarb
- docs/: mechanics (PRUNplanner is source of truth), roadmap, decisions,
  saturation design, archived handoff
- secrets stay in .env (gitignored); ref/ holds PRUNplanner source (ignored)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:00:21 +02:00
co-authored by Claude Sonnet 5
commit 7a538cb300
35 changed files with 1692 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
"""Game economics ported from PRUNplanner (ref/frontend/src/features/planning/calculations/*).
PRUNplanner is the source of truth; each function names its source file. Pure functions, no I/O."""
import itertools, math
TOTAL_MS_DAY = 24 * 3600 * 1000
TIERS = ["pioneer", "settler", "technician", "engineer", "scientist"]
# --- bonusCalculations.ts -------------------------------------------------------------
EXPERT_BONUS = {0: 0.0, 1: 0.0306, 2: 0.0696, 3: 0.1248, 4: 0.1974, 5: 0.284}
FACTION_BONUS = {
"ANTARES": {"ELECTRONICS": 0.05},
"BENTEN": {"MANUFACTURING": 0.05},
"HORTUS": {"AGRICULTURE": 0.03, "FOOD_INDUSTRIES": 0.02},
"MORIA": {"METALLURGY": 0.02, "CONSTRUCTION": 0.03},
"OUTSIDEREGION": {"CHEMISTRY": 0.02, "FUEL_REFINING": 0.02, "RESOURCE_EXTRACTION": 0.02},
}
WORKFORCE_COGC = {"PIONEERS": "pioneer", "SETTLERS": "settler", "TECHNICIANS": "technician",
"ENGINEERS": "engineer", "SCIENTISTS": "scientist"}
def expert_bonus(n: int) -> float:
return EXPERT_BONUS.get(n, 0.0) # out of range (<0 or >5) gives 0, as in the source
def faction_multiplier(faction: str | None, expertise: str | None, permits_used: float, permits_total: float):
"""Returns the factor (1 + bonus*m) or None if no bonus applies."""
if not expertise or not faction:
return None
b = FACTION_BONUS.get(faction, {}).get(expertise)
if not b:
return None
m = 2 * (-2 * (permits_used / permits_total) + 3)
return 1 + b * m
# --- workforceCalculations.ts ---------------------------------------------------------
BASE_SAT = 0.02 * (1 + 10 / 3) * (1 + 4) * (1 + 5 / 6) # 0.7944; both luxuries met gives exactly 1.0
LUX1, LUX2 = 1 + 1 / 11, 1 + 2 / 13
def tier_efficiency(capacity: float, required: float, lux1: bool, lux2: bool) -> float:
"""calculateSatisfaction: min(1, capacity/required) * base * luxury multipliers; 0 if nothing required."""
if required <= 0:
return 0.0
sat = 1.0 if required < capacity else capacity / required
eff = BASE_SAT * (LUX1 if lux1 else 1) * (LUX2 if lux2 else 1)
return sat * eff
# (ticker, need per worker per day, lux1?, lux2?)
CONSUMPTION = {
"pioneer": [("DW", 4, 0, 0), ("RAT", 4, 0, 0), ("OVE", .5, 0, 0), ("PWO", .2, 1, 0), ("COF", .5, 0, 1)],
"settler": [("DW", 5, 0, 0), ("RAT", 6, 0, 0), ("EXO", .5, 0, 0), ("PT", .5, 0, 0), ("REP", .2, 1, 0), ("KOM", 1, 0, 1)],
"technician": [("DW", 7.5, 0, 0), ("RAT", 7, 0, 0), ("MED", .5, 0, 0), ("HMS", .5, 0, 0), ("SCN", .1, 0, 0), ("SC", .1, 1, 0), ("ALE", 1, 0, 1)],
"engineer": [("DW", 10, 0, 0), ("MED", .5, 0, 0), ("FIM", 7, 0, 0), ("HSS", .2, 0, 0), ("PDA", .1, 0, 0), ("VG", .2, 1, 0), ("GIN", 1, 0, 1)],
"scientist": [("DW", 10, 0, 0), ("MED", .5, 0, 0), ("MEA", 7, 0, 0), ("LC", .2, 0, 0), ("WS", .05, 0, 0), ("NST", .1, 1, 0), ("WIN", 1, 0, 1)],
} # needs are per 100 workers per day in the table above; divided below
def workforce_consumption(tier: str, required: float, capacity: float, lux1: bool, lux2: bool) -> dict[str, float]:
"""calculateSingleWorkforceConsumption: units/day by ticker. Consumers = min(required, capacity)."""
n = min(required, capacity)
if n <= 0:
return {}
out = {}
for tk, need, l1, l2 in CONSUMPTION[tier]:
if (not l1 and not l2) or (l1 and lux1) or (l2 and lux2):
out[tk] = need / 100 * n
return out
# --- bonusCalculations.ts: building efficiency ---------------------------------------
def workforce_factor(building: dict, tier_eff: dict[str, float]) -> float:
"""building: {'pioneers': n, 'settlers': n, ...} required heads; tier_eff: {'pioneer': eff, ...}."""
heads = {t: building.get(t + "s", 0) for t in TIERS}
total = sum(heads.values())
return sum(heads[t] / total * tier_eff.get(t, 0.0) for t in TIERS) if total else 0.0
def building_efficiency(building: dict, tier_eff: dict[str, float], *, expertise: str | None = None,
cogc: str | None = None, hq: bool = False, experts: dict[str, int] | None = None,
faction: str | None = None, permits_used: float = 0, permits_total: float = 1,
fertility: float | None = None, is_farm: bool = False,
condition: float | None = None) -> tuple[float, dict[str, float]]:
"""Product of factors. Returns (total, elements). `expertise` uses upper snake (METALLURGY);
`experts` keys are the same names ('METALLURGY': 3). fertility only applies when is_farm (FRM, ORC)."""
el: dict[str, float] = {}
if is_farm and fertility is not None:
el["FERTILITY"] = 1 + fertility * (10 / 33) if fertility != -1.0 else 0.0
if hq:
el["HQ"] = 1.1
if expertise:
if cogc == expertise:
el["COGC"] = 1.25
n = (experts or {}).get(expertise, 0)
if n > 0:
el["EXPERT"] = 1 + expert_bonus(n)
if cogc in WORKFORCE_COGC and building.get(WORKFORCE_COGC[cogc] + "s", 0) > 0:
el["COGC_WORKFORCE"] = 1.1
el["WORKFORCE"] = workforce_factor(building, tier_eff)
if condition is not None: # building wear; not in PRUNplanner but present in FIO's live Efficiency (verified 2026-09-18)
el["CONDITION"] = condition
fb = faction_multiplier(faction, expertise, permits_used, permits_total)
if fb is not None:
el["FACTION"] = fb
total = 1.0
for v in el.values():
total *= v
return total, el
# --- extraction: extractionCalculations.ts + backend gamedata/fio/importers.py --------
CYCLE_MS = {"MINERAL": 12 * 3600e3, "GASEOUS": 6 * 3600e3, "LIQUID": 4.8 * 3600e3}
def daily_extraction(factor: float, resource_type: str) -> float:
"""Backend: factor*60 for GASEOUS, else factor*70 (factor = concentration as fraction). Before efficiency."""
return factor * (60.0 if resource_type == "GASEOUS" else 70.0)
def extraction_cycle(resource_type: str, daily: float) -> tuple[float, int]:
"""(time_ms, amount) per cycle; amount is rounded up, time scaled to keep the daily rate."""
amt = math.ceil(daily * CYCLE_MS[resource_type] / TOTAL_MS_DAY)
return amt * (TOTAL_MS_DAY / daily), amt
# --- production: usePlanCalculation.ts / buildingCalculations.ts ----------------------
def production_io(recipes: list[dict], efficiency: float, n_buildings: float = 1) -> dict[str, dict[str, float]]:
"""recipes: [{'time_ms', 'inputs': {tk: amt}, 'outputs': {tk: amt}, 'amount': repeats (default 1)}].
Each recipe's time = time_ms * amount / efficiency; batches/day = day*n / sum(times).
Returns {'in': {tk: per day}, 'out': {tk: per day}} for all buildings."""
times = [r["time_ms"] * r.get("amount", 1) / efficiency for r in recipes]
runs = TOTAL_MS_DAY * n_buildings / sum(times)
io = {"in": {}, "out": {}}
for r in recipes:
a = r.get("amount", 1)
for side, key in (("in", "inputs"), ("out", "outputs")):
for tk, amt in r[key].items():
io[side][tk] = io[side].get(tk, 0) + amt * a * runs
return io
# --- habOptimization.ts ---------------------------------------------------------------
HAB_AREA = {"HB1": 10, "HB2": 12, "HB3": 14, "HB4": 16, "HB5": 18, "HBB": 14, "HBC": 17, "HBM": 20, "HBL": 22}
HAB_CAP = { # tier -> capacity per hab
"HB1": {"pioneer": 100}, "HB2": {"settler": 100}, "HB3": {"technician": 100},
"HB4": {"engineer": 100}, "HB5": {"scientist": 100},
"HBB": {"pioneer": 75, "settler": 75}, "HBC": {"settler": 75, "technician": 75},
"HBM": {"technician": 75, "engineer": 75}, "HBL": {"engineer": 75, "scientist": 75},
}
def optimize_habs(required: dict[str, float], costs: dict[str, float], goal: str = "cost",
max_area: float | None = None) -> dict | None:
"""Integer hab mix covering `required` heads per tier, minimising 'cost' or 'area' (optionally area-capped).
Exhaustive over combined habs (<=4 vars), single habs fill the rest. Returns {'habs', 'cost', 'area'} or None."""
req = {t: max(0.0, required.get(t, 0)) for t in TIERS}
combos = [h for h in HAB_CAP if len(HAB_CAP[h]) == 2]
single = {next(iter(HAB_CAP[h])): h for h in HAB_CAP if len(HAB_CAP[h]) == 1}
limits = [math.ceil(min(req[t] for t in HAB_CAP[h]) / 75) for h in combos]
best = None
for counts in itertools.product(*[range(l + 1) for l in limits]):
cov = dict.fromkeys(TIERS, 0.0)
habs = dict(zip(combos, counts))
for h, n in habs.items():
for t, c in HAB_CAP[h].items():
cov[t] += c * n
for t in TIERS:
rest = req[t] - cov[t]
if rest > 0:
habs[single[t]] = habs.get(single[t], 0) + math.ceil(rest / 100)
cost = sum(costs[h] * n for h, n in habs.items())
area = sum(HAB_AREA[h] * n for h, n in habs.items())
if goal == "cost" and max_area is not None and area > max_area:
continue
key = cost if goal == "cost" else area
if best is None or key < best[0]:
best = (key, {h: n for h, n in habs.items() if n}, cost, area)
return None if best is None else {"habs": best[1], "cost": best[2], "area": best[3]}