"""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]}