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:
+179
@@ -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]}
|
||||
Reference in New Issue
Block a user