from __future__ import annotations from dataclasses import dataclass from enum import Enum from math import ceil from typing import Dict, List, Tuple, Optional from flask import Flask, render_template, request from pydantic import BaseModel, Field app = Flask(__name__) class Item(BaseModel): name: str class Machine(BaseModel): name: str class Machines(Enum): Miner = Machine(name="Miner") Smelter = Machine(name="Smelter") Constructor = Machine(name="Constructor") Assembler = Machine(name="Assembler") Sorter = Machine(name="Sorter") Crusher = Machine(name="Crusher") Foundry = Machine(name="Foundry") class Items(Enum): IronIngot = Item(name="Iron Ingot") CopperIngot = Item(name="Copper Ingot") Limestone = Item(name="Limestone") IronOre = Item(name="Iron Ore") CopperOre = Item(name="Copper Ore") IronPlate = Item(name="Iron Plate") IronRod = Item(name="Iron Rod") Wire = Item(name="Wire") Cable = Item(name="Cable") Concrete = Item(name="Concrete") ReinforcedIronPlate = Item(name="Reinforced Iron Plate") ModularFrame = Item(name="Modular Frame") BronzeBeam = Item(name="Bronze Beam") TinPlate = Item(name="Tin Plate") TinIngot = Item(name="Tin Ingot") CrushedTin = Item(name="Crushed Tin") CrushedIron = Item(name="Crushed Iron") CrushedGangue = Item(name="Crushed Gangue") CrushedSiterite = Item(name="Crushed Siterite") SiteriteOre = Item(name="Siterite Ore") Screws = Item(name="Screws") BronzeIngot = Item(name="Bronze Ingot") CrushedCopper = Item(name="Crushed Copper") CrushedMagnesium = Item(name="Crushed Magnesium") CrushedCallanite = Item(name="Crushed Callanite") CallaniteOre = Item(name="Callanite Ore") class RawResources(Enum): IronOre = Items.IronOre CopperOre = Items.CopperOre Limestone = Items.Limestone # --- Domain model (minimal, extensible) --- class Recipe(BaseModel): name: str # Human-friendly name building: Machines # e.g., "Smelter", "Constructor" outputs: Dict[Items, float] # Produced item name inputs: Dict[Items, float] = Field(default_factory=dict) # A very small starter dataset (default, non-alternate recipes) # Rates are per building per minute, matching Satisfactory default recipes. class Recipes(Enum): ModularFrame = Recipe( name="Modular Frame", building=Machines.Assembler, outputs={Items.ModularFrame: 6.0}, inputs={ Items.ReinforcedIronPlate: 7.5, Items.BronzeBeam: 7.5, }, ) ReinforcedIronPlate = Recipe( name="Reinforced Iron Plate", building=Machines.Assembler, outputs={Items.ReinforcedIronPlate: 5.0}, inputs={ Items.TinPlate: 20.0, Items.Screws: 37.5, }, ) TinPlate = Recipe( name="Tin Plate", building=Machines.Assembler, outputs={Items.TinPlate: 40.0}, inputs={ Items.IronPlate: 20.0, Items.TinIngot: 30.0, }, ) TinIngot = Recipe( name="Tin Ingot", building=Machines.Smelter, outputs={Items.TinIngot: 15.0}, inputs={Items.CrushedTin: 15.0}, ) CrushedIron = Recipe( name="Crushed Iron", building=Machines.Sorter, outputs={ Items.CrushedIron: 90.0, Items.CrushedTin: 60.0, Items.CrushedGangue: 40.0, }, inputs={Items.CrushedSiterite: 120.0}, ) CrushedSiterite = Recipe( name="Crushed Siterite", building=Machines.Crusher, outputs={ Items.CrushedSiterite: 60.0, Items.CrushedGangue: 25.0, }, inputs={Items.SiteriteOre: 60.0}, ) IronPlate = Recipe( name="Iron Plate", building=Machines.Constructor, outputs={Items.IronPlate: 20.0}, inputs={Items.IronIngot: 30.0}, ) IronIngot = Recipe( name="Iron Ingot", building=Machines.Smelter, outputs={Items.IronIngot: 30.0}, inputs={Items.CrushedIron: 30.0}, ) Screws = Recipe( name="Screws", building=Machines.Constructor, outputs={Items.Screws: 50.0}, inputs={Items.IronRod: 20.0}, ) IronRod = Recipe( name="Iron Rod", building=Machines.Constructor, outputs={Items.IronRod: 15.0}, inputs={Items.IronIngot: 15.0}, ) BronzeBeam = Recipe( name="Bronze Beam", building=Machines.Constructor, outputs={Items.BronzeBeam: 7.5}, inputs={Items.BronzeIngot: 22.5}, ) BronzeIngot = Recipe( name="Bronze Ingot", building=Machines.Foundry, outputs={Items.BronzeIngot: 45.0}, inputs={ Items.CopperIngot: 36.0, Items.TinIngot: 15.0, }, ) CopperIngot = Recipe( name="Copper Ingot", building=Machines.Smelter, outputs={Items.CopperIngot: 48.0}, inputs={Items.CrushedCopper: 48.0}, ) CrushedCopper = Recipe( name="Crushed Copper", building=Machines.Sorter, outputs={ Items.CrushedCopper: 96.0, Items.CrushedMagnesium: 80.0, Items.CrushedGangue: 40.0, }, inputs={Items.CrushedCallanite: 120.0}, ) CrushedCallanite = Recipe( name="Crushed Callanite", building=Machines.Sorter, outputs={ Items.CrushedCallanite: 60.0, Items.CrushedGangue: 25.0, }, inputs={Items.CallaniteOre: 60.0}, ) # Items which are considered raw resources (mined); they have no crafting recipes here RAW_RESOURCES = { Items.IronOre, Items.CopperOre, Items.Limestone, Items.SiteriteOre, } def compute_chain(targets: Dict[Items, float]) -> Tuple[Dict[str, float], List[dict], Dict[str, float], Dict[str, float]]: """ Given desired output rates (item -> units/min), compute: - required raw input rates (raw item -> units/min) - a flat list of steps with building counts and per-building utilization - total production rates (echo of targets summed if multiple entries per item) - unused byproducts (items/min) produced by multi-output recipes but not consumed or targeted Uses the Recipes enum and Items objects. """ # Build a mapping from output item -> recipe that produces it (default recipe set) output_to_recipe: Dict[Items, Recipe] = {} for r in Recipes: recipe = r.value for out_item in recipe.outputs.keys(): # prefer the first seen; can be extended to handle alternates later output_to_recipe.setdefault(out_item, recipe) # Aggregate demands for each item demand: Dict[Items, float] = {} def add_demand(item: Items, rate: float) -> None: if rate == 0: return demand[item] = demand.get(item, 0.0) + rate for item, rate in targets.items(): add_demand(item, rate) # Work lists steps: List[dict] = [] raw_requirements: Dict[str, float] = {} # Track produced and consumed rates to calculate unused byproducts produced: Dict[Items, float] = {} consumed: Dict[Items, float] = {} # Expand demanded craftable items into their inputs until only raw remain while True: craftable_item = next( (i for i, r in demand.items() if r > 1e-9 and i in output_to_recipe), None, ) if craftable_item is None: break needed_rate = demand[craftable_item] recipe = output_to_recipe[craftable_item] per_building_output = recipe.outputs[craftable_item] # Buildings needed buildings = needed_rate / per_building_output buildings_ceiled = ceil(buildings - 1e-9) utilization = 0.0 if buildings_ceiled == 0 else buildings / buildings_ceiled # Record the step (as display-friendly strings) steps.append({ "item": craftable_item.value.name, "recipe": recipe.name, "building": recipe.building.value.name, "target_rate": needed_rate, "per_building_output": per_building_output, "buildings_float": buildings, "buildings": buildings_ceiled, "utilization": utilization, }) # Consume this demand and add input demands demand[craftable_item] -= needed_rate scale = buildings # exact fractional buildings to match demand exactly # Account for all outputs produced by this recipe at the chosen scale for out_item, out_rate_per_build in (recipe.outputs or {}).items(): produced[out_item] = produced.get(out_item, 0.0) + out_rate_per_build * scale # Add input demands and track consumption for in_item, in_rate_per_build in (recipe.inputs or {}).items(): rate_needed = in_rate_per_build * scale add_demand(in_item, rate_needed) consumed[in_item] = consumed.get(in_item, 0.0) + rate_needed # What's left in demand are raw items for item, rate in demand.items(): if rate <= 1e-9: continue if item in RAW_RESOURCES or item not in output_to_recipe: raw_requirements[item.value.name] = raw_requirements.get(item.value.name, 0.0) + rate else: # Shouldn't happen, but guard raw_requirements[item.value.name] = raw_requirements.get(item.value.name, 0.0) + rate # Merge steps for same item/building merged: Dict[Tuple[str, str], dict] = {} for s in steps: key = (s["item"], s["building"]) if key not in merged: merged[key] = {**s} else: m = merged[key] m["target_rate"] += s["target_rate"] m["buildings_float"] += s["buildings_float"] m["buildings"] += s["buildings"] total_buildings = m["buildings"] m["utilization"] = 0.0 if total_buildings == 0 else m["buildings_float"] / total_buildings merged_steps = sorted(merged.values(), key=lambda x: (x["building"], x["item"])) # Echo total outputs (same as targets possibly aggregated), as item-name -> rate total_outputs: Dict[str, float] = {} for item, rate in targets.items(): total_outputs[item.value.name] = total_outputs.get(item.value.name, 0.0) + rate # Compute unused byproducts: produced but not consumed and not part of explicit targets unused_byproducts: Dict[str, float] = {} for item, qty_produced in produced.items(): qty_consumed = consumed.get(item, 0.0) qty_targeted = targets.get(item, 0.0) unused = qty_produced - qty_consumed - qty_targeted if unused > 1e-9: unused_byproducts[item.value.name] = unused return raw_requirements, merged_steps, total_outputs, unused_byproducts @app.route("/", methods=["GET", "POST"]) def index(): # Build selectable items list from Items enum (display names) item_names = sorted([i.value.name for i in Items]) name_to_item = {i.value.name: i for i in Items} result = None error = None selected_item = item_names[0] if item_names else "" selected_rate = 60.0 if request.method == "POST": item_name = request.form.get("item") or selected_item rate_str = request.form.get("rate") try: rate = float(rate_str) if rate < 0: raise ValueError except (TypeError, ValueError): error = "Please enter a valid non-negative number for rate (items per minute)." rate = None selected_item = item_name if rate is not None: selected_rate = rate if not error and item_name and rate is not None: item_obj = name_to_item.get(item_name) if item_obj is None: error = "Unknown item selected." else: targets = {item_obj: rate} raw, steps, outputs, unused = compute_chain(targets) result = { "targets": {item_name: rate}, "raw": raw, "steps": steps, "outputs": outputs, "unused": unused, } return render_template( "index.html", items=item_names, result=result, error=error, selected_item=selected_item, selected_rate=selected_rate, ) def create_app(): return app if __name__ == "__main__": # For local dev: python main.py app.run(host="0.0.0.0", port=5000, debug=True)