diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index 5765eb4..4fb2906 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -4,6 +4,7 @@ aurovite callanite caterium + heatsink larrussite quickwire siterite diff --git a/main.py b/main.py index 4062c3f..c78d1cb 100644 --- a/main.py +++ b/main.py @@ -34,7 +34,7 @@ class Production(BaseModel): quantity: float class ProductionChain: - def __init__(self): + def __init__(self, preferred_recipes: Optional[Dict[Items, Recipe]] = None): self.item_to_recipies: Dict[Items, list[Recipes]] = defaultdict(list) for r in Recipes: for o in r.value.outputs: @@ -49,9 +49,11 @@ class ProductionChain: self.production_chain: Dict[str, float] = defaultdict(float) self.excess: Dict[Items, float] = defaultdict(float) self.byproduct: Dict[Items, float] = defaultdict(float) + self.preferred_recipes = preferred_recipes or {} def get_recipe(self, item: Items) -> Optional[Recipe]: - # TODO: make logic for priority selection of recipes + if item in self.preferred_recipes: + return self.preferred_recipes[item] return self.item_to_recipies.get(item, (None,))[0] def calculate_excess(self, production: Dict[Items, float], demand: Dict[Items, float], raw_resources: Dict[Items, float]): @@ -102,7 +104,12 @@ class ProductionChain: levels.append(target_quantity / quantity) production_level = max(levels) + if production_level == 0: + continue production_chain[recipe.name] += production_level + if production_chain[recipe.name] < 0: + del(production_chain[recipe.name]) + production_level = 0 for out, quantity in recipe.outputs.items(): production[out] += production_level * quantity for byproduct, quantity in recipe.byproducts.items(): @@ -153,13 +160,77 @@ def index(): 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 + error_msgs: List[str] = [] - # Read from query parameters for bookmarkable URLs - item_name = request.args.get("item") or selected_item - rate_str = request.args.get("rate") + # -------------------- + # Parse targets (support multiple products) + # -------------------- + # Accept both legacy single `item`/`rate` and indexed `item_1`/`rate_1`, ... + # Determine how many indexed rows we have + indexed_pairs: List[Tuple[str, str]] = [] + max_idx = 0 + for key in request.args.keys(): + if key.startswith("item_"): + try: + idx = int(key.split("_", 1)[1]) + max_idx = max(max_idx, idx) + except ValueError: + pass + for i in range(1, max_idx + 1): + indexed_pairs.append((request.args.get(f"item_{i}"), request.args.get(f"rate_{i}"))) + + # Build targets_ui (for rendering the form) and targets dict (for compute) + targets_ui: List[dict] = [] + targets: Dict[Items, float] = defaultdict(float) + + def _add_target_row(item_name: Optional[str], rate_str: Optional[str]): + nonlocal targets, targets_ui + if not item_name and not rate_str: + return + item_name = (item_name or "").strip() + rate_val: Optional[float] = None + if rate_str is not None and rate_str != "": + try: + rate_val = float(rate_str) + if rate_val < 0: + raise ValueError + except (TypeError, ValueError): + error_msgs.append(f"Invalid rate for product '{item_name or '(missing)'}'. Enter a non-negative number.") + rate_val = None + if item_name: + targets_ui.append({"item": item_name, "rate": rate_val if rate_val is not None else 0.0}) + if rate_val is not None: + item_obj = name_to_item.get(item_name) + if item_obj is None: + error_msgs.append(f"Unknown item '{item_name}'.") + else: + targets[item_obj] += rate_val + elif rate_val is not None: + # Rate without item name – just keep it in UI + targets_ui.append({"item": "", "rate": rate_val}) + + if indexed_pairs: + for item_nm, rate_s in indexed_pairs: + _add_target_row(item_nm, rate_s) + else: + # Legacy single controls + default_item = item_names[0] if item_names else "" + single_item = request.args.get("item") or default_item + single_rate = request.args.get("rate") + _add_target_row(single_item, single_rate) + if not request.args: + # Initial load: ensure at least one default row + targets_ui = [{"item": default_item, "rate": 60.0}] + if default_item: + item_obj0 = name_to_item.get(default_item) + if item_obj0: + targets[item_obj0] += 60.0 + + # Selected defaults are taken from the first row (for compatibility with existing template pieces) + selected_item = targets_ui[0]["item"] if targets_ui else (item_names[0] if item_names else "") + selected_rate = targets_ui[0]["rate"] if targets_ui else 60.0 + + # Optional top-level recipe (applies only when exactly one target) selected_recipe = request.args.get("recipe") or "" # Parse per-item recipe overrides from query params recipe_for_ @@ -184,23 +255,9 @@ def index(): if value in candidates: overrides[item_enum] = value - rate = None - if rate_str is not None and rate_str != "": - 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 - - # Determine candidate recipes for the selected output item + # Candidate recipes for the (first) selected item (single-target convenience) recipe_options: List[str] = [] - item_obj_for_options = name_to_item.get(selected_item) + item_obj_for_options = name_to_item.get(selected_item) if len(targets) <= 1 else None if item_obj_for_options is not None: for r in Recipes: recipe = r.value @@ -224,61 +281,129 @@ def index(): # Compute and also prepare per-item override options based on resulting chain overrides_ui: List[dict] = [] - 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, preferred_by_output=preferred) - result = { - "targets": {item_name: rate}, - "raw": raw, - "steps": steps, - "outputs": outputs, - "unused": unused, - } + if targets: + # Translate preferred mapping (Items -> recipe name) into objects and pass to ProductionChain + preferred_recipes_obj: Optional[Dict[Items, Recipe]] = None + if preferred: + name_to_recipe = {r.value.name: r.value for r in Recipes} + preferred_recipes_obj = {} + for itm, rec_name in preferred.items(): + rec_obj = name_to_recipe.get(rec_name) + if rec_obj: + preferred_recipes_obj[itm] = rec_obj + prod_chain = ProductionChain(preferred_recipes=preferred_recipes_obj) + prod_chain.compute_chain(targets) - # Collect unique output items from steps - unique_items = [] - seen = set() - for s in steps: - item_nm = s.get("item") - if item_nm and item_nm not in seen: - seen.add(item_nm) - unique_items.append(item_nm) - # For each item, compute candidate recipes and current selection - for item_nm in unique_items: - item_enum2 = name_to_item.get(item_nm) - if not item_enum2: - continue - candidates = [] - for r in Recipes: - rec = r.value - if item_enum2 in rec.outputs: - candidates.append({ - "name": rec.name, - "building": rec.building.value.name, - }) - if len(candidates) <= 1: - continue # only show when alternates exist - candidates.sort(key=lambda x: (x["name"])) - sel = None - if preferred and item_enum2 in preferred: - sel = preferred[item_enum2] - slug = _slugify(item_nm) - overrides_ui.append({ - "item_name": item_nm, - "slug": slug, - "options": candidates, - "selected": sel or "", + # Build UI-facing structures from ProductionChain state + raw = {itm.value.name: qty for itm, qty in prod_chain.raw_resources.items() if qty > 0} + unused = {itm.value.name: qty for itm, qty in prod_chain.byproduct.items() if qty > 0} + excess = {itm.value.name: qty for itm, qty in prod_chain.excess.items() if qty > 0} + outputs = {itm.value.name: qty for itm, qty in prod_chain.production.items() if qty > 0} + + # Steps per recipe + steps = [] + for recipe_name, level in prod_chain.production_chain.items(): + rec = prod_chain.recipe_name_to_obj.get(recipe_name) + if not rec: + continue + chosen_item = None + if rec.outputs: + demanded = sorted(rec.outputs.keys(), key=lambda it: prod_chain.demand.get(it, 0.0), reverse=True) + chosen_item = demanded[0] + if not chosen_item: + continue + per_building_output = rec.outputs[chosen_item] + buildings_float = float(level) + buildings = ceil(buildings_float) if buildings_float > 0 else 0 + utilization = (buildings_float / buildings) if buildings > 0 else 0.0 + target_rate_item = buildings_float * per_building_output + # Compute per-step input item rates for this recipe at the computed level + step_inputs = [] + for inp_item, qty_per_building in rec.inputs.items(): + rate = buildings_float * qty_per_building + step_inputs.append({ + "item": inp_item.value.name, + "rate": rate, }) + # Sort inputs by descending rate for a stable display + step_inputs.sort(key=lambda x: x["rate"], reverse=True) + steps.append({ + "item": chosen_item.value.name, + "recipe": rec.name, + "building": rec.building.value.name, + "target_rate": target_rate_item, + "per_building_output": per_building_output, + "buildings_float": buildings_float, + "buildings": buildings, + "utilization": utilization, + "inputs": step_inputs, + }) - # Build reset query (clear overrides) - reset_query = f"?item={selected_item}&rate={selected_rate}" + # Aggregate targets for display + result_targets = {} + for itm, qty in targets.items(): + if qty > 0: + result_targets[itm.value.name] = qty + + result = { + "targets": result_targets, + "raw": raw, + "steps": steps, + "outputs": outputs, + "unused": unused, + "excess": excess, + } + + # Collect unique output items from steps for override options + unique_items = [] + seen = set() + for s in steps: + item_nm = s.get("item") + if item_nm and item_nm not in seen: + seen.add(item_nm) + unique_items.append(item_nm) + for item_nm in unique_items: + item_enum2 = name_to_item.get(item_nm) + if not item_enum2: + continue + candidates = [] + for r in Recipes: + rec = r.value + if item_enum2 in rec.outputs: + candidates.append({ + "name": rec.name, + "building": rec.building.value.name, + }) + if len(candidates) <= 1: + continue + candidates.sort(key=lambda x: (x["name"])) + sel = None + if preferred and item_enum2 in preferred: + sel = preferred[item_enum2] + slug = _slugify(item_nm) + overrides_ui.append({ + "item_name": item_nm, + "slug": slug, + "options": candidates, + "selected": sel or "", + }) + + # Build reset query (clear overrides) while preserving current targets + if indexed_pairs or len(targets_ui) > 1: + # Multi-target + parts = [] + for idx, row in enumerate(targets_ui, start=1): + parts.append(f"item_{idx}={row['item']}") + parts.append(f"rate_{idx}={row['rate']}") + reset_query = "?" + "&".join(parts) + else: + reset_query = f"?item={selected_item}&rate={selected_rate}" if selected_recipe: reset_query += f"&recipe={selected_recipe}" + # Combine error messages + error = " ".join(error_msgs) if error_msgs else None + return render_template( "index.html", items=item_names, @@ -290,6 +415,7 @@ def index(): selected_recipe=selected_recipe, overrides_ui=overrides_ui, reset_query=reset_query, + targets_ui=targets_ui, ) @@ -299,6 +425,6 @@ def create_app(): if __name__ == "__main__": # For local dev: python main.py - # app.run(host="0.0.0.0", port=5000, debug=True) - prod_chain = ProductionChain() - prod_chain.compute_chain({Items.ModularFrame: 1.5}) \ No newline at end of file + app.run(host="0.0.0.0", port=5000, debug=True) + # prod_chain = ProductionChain() + # prod_chain.compute_chain({Items.CateriumHeatsink: 10.0}) \ No newline at end of file diff --git a/plus.py b/plus.py index 69221a0..fc3faa9 100644 --- a/plus.py +++ b/plus.py @@ -12,6 +12,8 @@ class Machines(Enum): Sorter = Machine(name="Sorter") Crusher = Machine(name="Crusher") Foundry = Machine(name="Foundry") + FlexibleBlastFurnace = Machine(name="Flexible Blast Furnace") + Reformer = Machine(name="Reformer") class Items(Enum): IronIngot = Item(name="Iron Ingot") @@ -71,6 +73,18 @@ class Items(Enum): BronzeFrame = Item(name="Bronze Frame") AILimiter = Item(name="AI Limiter") Water = Item(name="Water") + CrushedCaterium = Item(name="Crushed Caterium") + CateriumHeatsink = Item(name="Caterium Heatsink") + MoltenIron = Item(name="Molten Iron") + MoltenCopper = Item(name="Molten Copper") + MoltenTin = Item(name="Molten Tin") + Steam = Item(name="Steam") + MoltenBrass = Item(name="Molten Brass") + BrassIngot = Item(name="Brass Ingot") + BrassPlates = Item(name="Brass Plates") + BrassPipes = Item(name="Brass Pipes") + ColdSlag = Item(name="Cold Slag") + FanBlades = Item(name="Fan Blades") class RawResources(Enum): Water = Items.Water @@ -177,6 +191,16 @@ class Recipes(Enum): }, inputs={Items.CrushedLarrussite: 120.0}, ) + CrushedCaterium = Recipe( + name="Crushed Caterium", + building=Machines.Sorter, + outputs={ + Items.CrushedCaterium: 80.0, + Items.CrushedCopper: 40.0, + Items.CrushedGangue: 30.0, + }, + inputs={Items.CrushedAurovite: 120.0}, + ) # Smelter # - smelting IronIngot = Recipe( @@ -197,6 +221,12 @@ class Recipes(Enum): outputs={Items.TinIngot: 15.0}, inputs={Items.CrushedTin: 15.0}, ) + CateriumIngot = Recipe( + name="Caterium Ingot", + building=Machines.Smelter, + outputs={Items.CateriumIngot: 40.0}, + inputs={Items.CrushedCaterium: 40.0}, + ) # - Ingots ImpureIronIngot = Recipe( name="Impure Iron Ingot", @@ -219,8 +249,8 @@ class Recipes(Enum): ImpureCateriumIngot = Recipe( name="Impure Caterium Ingot", building=Machines.Smelter, - inputs={Items.CrushedAurovite: 40.0}, outputs={Items.CateriumIngot: 24.0}, + inputs={Items.CrushedAurovite: 40.0}, ) ZincIngot = Recipe( name="Zinc Ingot", @@ -235,6 +265,12 @@ class Recipes(Enum): outputs={Items.Glass: 20.0}, inputs={Items.Sand: 30.0}, ) + CastIronScrew = Recipe( + name="Cast Iron Screw", + building=Machines.Smelter, + outputs={Items.Screws: 40.0}, + inputs={Items.IronIngot: 20.0}, + ) # Constructor # - Standard Parts IronPlate = Recipe( @@ -309,6 +345,18 @@ class Recipes(Enum): outputs={Items.ZincPlates: 15.0}, inputs={Items.ZincIngot: 12.5}, ) + RolledBronzePlate = Recipe( + name="Rolled Bronze Plate", + building=Machines.Constructor, + outputs={Items.BronzePipes: 16.0}, + inputs={Items.BronzePlates: 8.0}, + ) + CompactedIronPlate = Recipe( + name="Compacted Iron Plate", + building=Machines.Constructor, + outputs={Items.ReinforcedIronPlate: 3.0}, + inputs={Items.IronPlate: 20.0}, + ) # - Electronics IronWire = Recipe( name="Iron Wire", @@ -353,7 +401,13 @@ class Recipes(Enum): outputs={Items.Concrete: 15.0}, inputs={Items.CrushedGangue: 45.0}, ) - + # - Industrial Parts + BrassPlates = Recipe( + name="Brass Plates", + building=Machines.Constructor, + outputs={Items.BrassPlates: 18.0}, + inputs={Items.BrassIngot: 25.0}, + ) # Foundry # - Building Parts SolarCell = Recipe( @@ -406,6 +460,24 @@ class Recipes(Enum): Items.Rotor: 3.75, }, ) + SlagConcrete = Recipe( + name="Slag Concrete", + building=Machines.Assembler, + outputs={Items.Concrete: 22.5}, + inputs={ + Items.ColdSlag: 33.75, + Items.CrushedGangue: 11.25, + }, + ) + FanBlades = Recipe( + name="Fan Blades", + building=Machines.Assembler, + outputs={Items.FanBlades: 7.5}, + inputs={ + Items.BrassPlates: 8.0, + Items.IronRod: 22.5, + }, + ) # - Standard Parts TinPlate = Recipe( name="Tin Plate", @@ -470,6 +542,25 @@ class Recipes(Enum): Items.BronzeBeam: 7.5, }, ) + FastenedFrame = Recipe( + name="Fastened Frame", + building=Machines.Assembler, + outputs={Items.ModularFrame: 20.0}, + inputs={ + Items.ReinforcedIronPlate: 15, + Items.Screws: 150, + }, + ) + BrassPipes = Recipe( + name="Brass Pipes", + building=Machines.Assembler, + outputs={Items.BrassPipes: 12.5}, + inputs={ + Items.BrassPlates: 15.0, + Items.CopperRod: 7.5 + }, + ) + # - Electronics AILimiter = Recipe( name="AI Limiter", building=Machines.Assembler, @@ -479,5 +570,101 @@ class Recipes(Enum): Items.TinnedWire: 18.0, }, ) + # - Industrial + CateriumHeatsink = Recipe( + name="Caterium Heatsink", + building=Machines.Assembler, + outputs={Items.CateriumHeatsink: 10.0}, + inputs={ + Items.CateriumPlate: 28.0, + Items.CopperBusbars: 10.0, + }, + ) + # Flexible Blast Furnace + # - Molten Metals + MoltenIron = Recipe( + name="Molten Iron", + building=Machines.FlexibleBlastFurnace, + outputs={Items.MoltenIron: 60.0}, + inputs={ + Items.CrushedIron: 120.0, + }, + ) + MoltenCopper = Recipe( + name="Molten Copper", + building=Machines.FlexibleBlastFurnace, + outputs={Items.MoltenCopper: 60.0}, + inputs={ + Items.CrushedCopper: 120.0, + }, + ) + MoltenTin = Recipe( + name="Molten Tin", + building=Machines.FlexibleBlastFurnace, + outputs={Items.MoltenTin: 60.0}, + inputs={ + Items.CrushedTin: 120.0, + }, + ) + # - Alloys + MoltenBrass = Recipe( + name="Molten Brass", + building=Machines.FlexibleBlastFurnace, + outputs={Items.MoltenBrass: 60.0}, + inputs={ + Items.CrushedZinc: 32.0, + Items.MoltenCopper: 20.0, + }, + ) + # Reformer + # - Ingots + CastIronIngot = Recipe( + name="Cast Iron Ingot", + building=Machines.Reformer, + outputs={ + Items.IronIngot: 180.0, + Items.Steam: 60.0 + }, + inputs={ + Items.MoltenIron: 60.0, + Items.Water: 60.0, + }, + ) + CastCopperIngot = Recipe( + name="Cast Copper Ingot", + building=Machines.Reformer, + outputs={ + Items.CopperIngot: 144.0, + Items.Steam: 24.0 + }, + inputs={ + Items.MoltenCopper: 48.0, + Items.Water: 24.0, + } + ) + CastTinIngot = Recipe( + name="Cast Tin Ingot", + building=Machines.Reformer, + outputs={ + Items.TinIngot: 180.0, + Items.Steam: 20.0 + }, + inputs={ + Items.MoltenTin: 60.0, + Items.Water: 20.0, + } + ) + CastBrassIngot = Recipe( + name="Cast Brass Ingot", + building=Machines.Reformer, + outputs={ + Items.BrassIngot: 50.0, + Items.Steam: 15.0 + }, + inputs={ + Items.MoltenBrass: 40.0, + Items.Water: 15.0, + } + ) Recipe.model_rebuild() \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 23b03dc..faeaac5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -26,6 +26,8 @@ code { color: var(--accent); } .mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .pill { display:inline-block; padding:2px 8px; border-radius: 999px; background:#0b1220; border:1px solid #334155; color: var(--muted); font-size: 12px; } + .completed td:not(.done-col) { text-decoration: line-through; color: var(--muted); } + .done-col { width: 1%; white-space: nowrap; } @@ -33,19 +35,39 @@

Satisfactory Production Calculator

Compute buildings and raw inputs for a target output rate (items per minute).

-
-
-
- - + {% for it in items %} + + {% endfor %} + + + + + + + + + {% endfor %} - -
-
- - + + +
+
@@ -64,11 +86,12 @@ {% if result.raw %} - + {% for item,rate in result.raw.items() %} + @@ -82,8 +105,11 @@ {% if overrides_ui and overrides_ui|length > 0 %}

Recipe overrides

- - + {# Preserve all target rows in the recalculation #} + {% for row in targets_ui %} + + + {% endfor %} {% if selected_recipe %}{% endif %}
ItemRate (items/min)
DoneItemRate (items/min)
{{ item }} {{ '%.2f'|format(rate) }}
@@ -120,9 +146,11 @@
+ + @@ -132,9 +160,21 @@ {% for s in result.steps %} + + @@ -147,15 +187,36 @@

No production steps (target is a raw resource).

{% endif %} +

Excess products

+ {% if result.excess and result.excess|length > 0 %} +
Done Output Item Recipe BuildingInputs Target rate Per-building output Buildings
{{ s.item }} {{ s.recipe }} {{ s.building }} + {% if s.inputs and s.inputs|length > 0 %} +
+ {% for inp in s.inputs %} +
{{ inp.item }} — {{ '%.2f'|format(inp.rate) }}
+ {% endfor %} +
+ {% else %} + None + {% endif %} +
{{ '%.2f'|format(s.target_rate) }} {{ '%.2f'|format(s.per_building_output) }} {{ '%.2f'|format(s.buildings_float) }} (~ {{ s.buildings }})
+ + + + + {% for item,rate in result.excess.items() %} + + + + + + {% endfor %} + +
DoneItemExcess rate (items/min)
{{ item }}{{ '%.2f'|format(rate) }}
+ {% else %} +

No excess products for this target.

+ {% endif %} +

Unused byproducts

{% if result.unused and result.unused|length > 0 %} - + {% for item,rate in result.unused.items() %} + @@ -170,8 +231,45 @@
ItemUnused rate (items/min)
DoneItemUnused rate (items/min)
{{ item }} {{ '%.2f'|format(rate) }}