Compare commits

...

3 Commits

Author SHA1 Message Date
67ade78747 testing 2025-11-12 17:18:16 +00:00
9f4ff856e6 checkpoint 2025-11-11 21:24:54 +00:00
47bef8cbe4 testing 2025-11-11 19:27:55 +00:00
4 changed files with 850 additions and 15 deletions

6
.idea/ruff.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RuffConfigService">
<option name="globalRuffExecutablePath" value="c:\users\peterdwyer\.local\bin\ruff.exe" />
</component>
</project>

590
main.py
View File

@@ -1,11 +1,11 @@
from collections import defaultdict, deque
from enum import Enum
from math import ceil
from typing import Dict, List, Tuple, Optional, Any
from typing import Dict, List, Tuple, Optional, Any, Deque, Annotated
import networkx as nx
from flask import Flask, render_template, request
from pydantic import BaseModel, Field
from plus import Items, Machines, Recipes, Recipe, RawResources
from flask import Flask, render_template, request, jsonify
from pydantic import BaseModel, Field, computed_field, BeforeValidator
from plus import Items, Machines, Recipes, Recipe, RawResources, MachineOrder
# from vanilla import Items, Machines, Recipes, Recipe, RawResources
from rich import print
import matplotlib.pyplot as plt
@@ -29,9 +29,70 @@ def _slugify(name: str) -> str:
prev_us = False
return ''.join(out).strip('_')
class ProductionLink(BaseModel):
item: Items
quantity: float
production: Optional[Production]
raw: bool = False
def positive_float(number: float) -> float:
return max(0.0, number)
class Production(BaseModel):
recipe: Recipe
quantity: float
ingress: Dict[Items, List[ProductionLink]] = Field(default_factory=lambda : defaultdict(list))
egress: Dict[Items, List[ProductionLink]] = Field(
default_factory=lambda: defaultdict(list)
)
demand: Dict[Items, Annotated[float, BeforeValidator(positive_float)]] = Field(default_factory=lambda: defaultdict(float))
@computed_field
@property
def production_level(self) -> float:
levels = []
for item, quantity in self.demand.items():
levels.append(quantity/(self.recipe.outputs|self.recipe.byproducts)[item])
return max(levels) if levels else 0.0
@computed_field
@property
def inputs(self) -> Dict[Items, float]:
return {
item: quantity*self.production_level for item, quantity in self.recipe.inputs.items()
}
@computed_field
@property
def outputs(self) -> Dict[Items, float]:
return {
item: quantity*self.production_level for item, quantity in self.recipe.outputs.items() | self.recipe.byproducts.items()
}
def demand_satisfied(self):
if self.production_level <= 0:
return True
current_demand = self.demand.copy()
for item in self.ingress:
for link in self.ingress[item]:
current_demand[item] -= link.quantity
return all(current_demand[item] <= 0 for item in current_demand)
def remaining_output(self):
outputs = self.outputs
for item in self.egress:
for link in self.egress[item]:
outputs[item] -= link.quantity
return outputs
def remaining_input(self):
inputs = self.inputs
for item in self.ingress:
for link in self.ingress[item]:
inputs[item] -= link.quantity
return inputs
class ProductionChain:
def __init__(self, preferred_recipes: Optional[Dict[Items, Recipe]] = None):
@@ -142,6 +203,245 @@ class ProductionChain:
# self.generate_graph(production_chain, production, raw_resources, excess, byproduct)
return production_chain
def build_chain(self, targets: Dict[Items, float]) -> Any:
if not targets:
return {}
demand = defaultdict(float)
item_to_production: Dict[Items, List[Production]] = defaultdict(list)
queue: Deque[Items] = deque()
for target in targets:
recipe = self.get_recipe(target)
p = Production(recipe=recipe)
p.demand[target] = targets[target]
p.egress[target].append(ProductionLink(item=target, quantity=targets[target], raw=True, production=None))
queue.extend(p.inputs)
for output in p.outputs:
item_to_production[output].append(p)
while queue:
item = queue.popleft()
if item in item_to_production or item in RawResources:
continue
recipe = self.get_recipe(item)
p = Production(recipe=recipe)
for output in p.outputs:
item_to_production[output].append(p)
queue.extend(p.inputs)
calc_queue: Deque[Production] = deque()
for item in item_to_production:
for production in item_to_production[item]:
if not production.demand_satisfied():
calc_queue.append(production)
print(calc_queue)
while calc_queue:
prod = calc_queue.popleft()
if prod.demand_satisfied():
continue
for inp in prod.inputs:
if inp in RawResources:
if prod.remaining_input()[inp] <= 0:
continue
prod.ingress[inp].append(ProductionLink(item=inp, quantity=prod.remaining_input()[inp], production=None, raw=True))
else:
prod_list = item_to_production[inp]
prod_source = None
if len(prod_list) == 1:
prod_source = prod_list[0]
else:
for prod_list_item in prod_list:
if self.get_recipe(inp) == prod_list_item.recipe:
prod_source = prod_list_item
break
if not prod_source:
continue
current_quantity = prod_source.remaining_output()[inp]
target_quantity = prod.remaining_input()[inp]
if current_quantity >= target_quantity:
prod.ingress[inp].append(
ProductionLink(
item=inp,
quantity=target_quantity,
production=prod_source,
raw=False,
)
)
prod_source.egress[inp].append(
ProductionLink(
item=inp,
quantity=target_quantity,
production=prod,
raw=False,
)
)
else:
pass
if target_quantity > 0:
prod_source.demand[inp] += target_quantity
prod.ingress[inp].append(ProductionLink(item=inp, quantity=target_quantity, production=prod_source, raw=False))
prod_source.egress[inp].append(ProductionLink(item=inp, quantity=target_quantity, production=prod, raw=False))
calc_queue.append(prod_source)
return item_to_production
# print("item_to_production:", item_to_production)
def compute_chain(targets: Dict[Items, float], preferred_by_output: Optional[Dict[Items, str]] = None) -> 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.
Now supports alternate recipes: when multiple recipes produce the same output item,
a selection heuristic is used unless an explicit preference is configured.
preferred_by_output: optional mapping from output Item -> recipe name to force selection for that item.
"""
# Build a mapping from output item -> list of recipes that produce it
output_to_recipes: Dict[Items, List[Recipe]] = {}
for r in Recipes:
recipe = r.value
for out_item in recipe.outputs.keys():
output_to_recipes.setdefault(out_item, []).append(recipe)
# Optional explicit preferences: map output Item -> recipe name to prefer
PREFERRED_RECIPE_BY_OUTPUT: Dict[Items, str] = preferred_by_output or {}
# Heuristic to select a recipe when multiple alternatives exist
def select_recipe_for(item: Items) -> Optional[Recipe]:
candidates = output_to_recipes.get(item, [])
if not candidates:
return None
# If explicit preference exists and matches a candidate, use it
pref_name = PREFERRED_RECIPE_BY_OUTPUT.get(item)
if pref_name:
for c in candidates:
if c.name == pref_name:
return c
# Otherwise pick the candidate with the highest per-building output for this item
# Tie-breaker 1: smallest total input per unit of this output
# Tie-breaker 2: deterministic by name
def score(c: Recipe) -> Tuple[float, float, str]:
per_build_out = c.outputs.get(item, 0.0)
total_input = sum(c.inputs.values())
# Lower input per unit is better; we express as (total_input/per_build_out)
# Protect against division by zero
eff = float('inf') if per_build_out <= 0 else (total_input / per_build_out)
return (per_build_out, -eff, c.name)
return sorted(candidates, key=score, reverse=True)[0]
# 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_recipes),
None,
)
if craftable_item is None:
break
needed_rate = demand[craftable_item]
recipe = select_recipe_for(craftable_item)
if recipe is None:
# Should not happen because craftable_item is in output_to_recipes,
# but guard anyway: treat as raw if selection failed.
demand[craftable_item] = 0.0
raw_requirements[craftable_item.value.name] = raw_requirements.get(craftable_item.value.name, 0.0) + needed_rate
continue
per_building_output = recipe.outputs[craftable_item]
# Buildings needed
buildings = needed_rate / per_building_output if per_building_output > 0 else 0.0
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
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
def generate_graph(self, production_chain, production, raw_resources, excess, byproduct):
nx_graph = nx.DiGraph()
for recipe in production_chain:
@@ -473,6 +773,280 @@ def index():
)
@app.route("/new", methods=["GET"])
def new():
# Render a page similar to index but powered by ProductionChain.build_chain
item_names = sorted([i.value.name for i in Items])
name_to_item = {i.value.name: i for i in Items}
# Parse multiple targets (item_1/rate_1, ...). Fallback to legacy item/rate
indexed_pairs: List[Tuple[Optional[str], Optional[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}")))
targets: Dict[Items, float] = defaultdict(float)
targets_ui: List[dict] = []
errors: List[str] = []
def _add_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):
errors.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:
errors.append(f"Unknown item '{item_name}'.")
else:
targets[item_obj] += rate_val
elif rate_val is not None:
# Rate without item name keep in UI only
targets_ui.append({"item": "", "rate": rate_val})
if indexed_pairs:
for item_nm, rate_s in indexed_pairs:
_add_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_row(single_item, single_rate)
if not request.args:
# Initial load: ensure 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
# Build reset query preserving current targets
if indexed_pairs or len(targets_ui) > 1:
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:
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
reset_query = f"?item={selected_item}&rate={selected_rate}"
error = " ".join(errors) if errors else None
# Parse per-item recipe overrides from query params: recipe_for_<slug(item name)>
# Build slug -> Items map and recipe name -> Recipe map
slug_to_item: Dict[str, Items] = { _slugify(i.value.name): i for i in Items }
recipe_name_to_obj: Dict[str, Recipe] = {}
for r in Recipes:
recipe_name_to_obj[r.value.name] = r.value
preferred: Dict[Items, Recipe] = {}
for key, value in request.args.items():
if not key.startswith("recipe_for_"):
continue
if not value:
# Empty value means no override for this item
continue
slug = key[len("recipe_for_") :]
item_enum = slug_to_item.get(slug)
if not item_enum:
continue
# Validate that the provided recipe name is a candidate for this item
candidates = []
for r in Recipes:
rec = r.value
if item_enum in rec.outputs:
candidates.append(rec.name)
if value in candidates:
preferred[item_enum] = recipe_name_to_obj[value]
# Build chain using the object-oriented builder
# Now organize the UI data by production (recipe/building instance) rather than by item
chain_ui: List[dict] = []
# Additionally, build a separate table for alternate recipes organized by item
alternates_by_item_ui: List[dict] = []
if not errors and targets:
# Pass preferred recipes so alternates can be selected
prod_chain = ProductionChain(preferred_recipes=preferred)
item_to_production = prod_chain.build_chain(targets)
def _it_name(i: Items) -> str:
return i.value.name
# Collect unique Production objects (they may appear under multiple output items)
seen_ids: set[int] = set()
productions: List[Production] = []
for plist in item_to_production.values():
for p in plist:
pid = id(p)
if pid in seen_ids:
continue
seen_ids.add(pid)
productions.append(p)
# Sort productions by MachineOrder (then by recipe name for stability)
order_index_map = {m: i for i, m in enumerate(MachineOrder)}
def _prod_sort_key(pr: Production):
b = pr.recipe.building
# Fallback to the end if a building isn't listed in MachineOrder
order_idx = order_index_map.get(b, len(MachineOrder))
return (order_idx, pr.recipe.name)
productions.sort(key=_prod_sort_key)
chain_ui = []
used_slugs: set[str] = set()
for p in productions:
# Choose an anchor output item to bind alternate recipe selection.
# Prefer items that actually egress (flow to another production or final output),
# falling back to any output if there is no egress.
egress_items = list(p.egress.keys())
output_items = list(p.outputs.keys())
anchor_item: Optional[Items] = None
pick_from: List[Items] = egress_items if egress_items else output_items
if pick_from:
anchor_item = sorted(pick_from, key=lambda i: i.value.name)[0]
# Build candidate recipes for the anchor item
candidates: List[dict] = []
selected_recipe_name = p.recipe.name
recipe_slug = _slugify(anchor_item.value.name) if anchor_item else ""
if anchor_item is not None:
for r in Recipes:
rec = r.value
if anchor_item in rec.outputs:
candidates.append({
"name": rec.name,
"building": rec.building.value.name,
})
candidates.sort(key=lambda x: (x["name"]))
# Only show the selector once per anchor item to avoid duplicate keys in the form
if recipe_slug in used_slugs:
# clear candidates and slug so template renders plain text
candidates = []
recipe_slug = ""
else:
used_slugs.add(recipe_slug)
# Build ingress/egress presentation lists
ingress_ui: List[dict] = []
for it, links in p.ingress.items():
for lk in links:
if lk.raw or lk.production is None:
via = "Raw"
else:
via = f"{lk.production.recipe.name} ({lk.production.recipe.building.value.name})"
ingress_ui.append({
"item": _it_name(it),
"rate": lk.quantity,
"via": via,
})
egress_ui: List[dict] = []
for it, links in p.egress.items():
for lk in links:
if lk.production is None:
to = "Final Output"
else:
to = f"{lk.production.recipe.name} ({lk.production.recipe.building.value.name})"
egress_ui.append({
"item": _it_name(it),
"rate": lk.quantity,
"to": to,
})
chain_ui.append(
{
"recipe": p.recipe.name,
"building": p.recipe.building.value.name,
"production_level": p.production_level,
"inputs": [
{"item": _it_name(i), "rate": q} for i, q in p.inputs.items()
],
"outputs": [
{"item": _it_name(i), "rate": q} for i, q in p.outputs.items()
],
# Link visualization
"ingress": ingress_ui,
"egress": egress_ui,
# Deprecated in UI: per-production alternate selector (kept for reference)
"recipe_options": candidates,
"recipe_selected": selected_recipe_name,
"recipe_slug": recipe_slug,
}
)
# Build the alternate-recipe table data organized by item.
# Consider items that appear as outputs in the current chain.
items_in_chain: set[Items] = set()
for p in productions:
for it in p.outputs.keys():
items_in_chain.add(it)
# Helper: map current chain's selected recipe for an item (if any)
current_selected_by_item: Dict[Items, str] = {}
for p in productions:
for it in p.outputs.keys():
# if multiple productions output same item, first one wins (they should be consistent)
current_selected_by_item.setdefault(it, p.recipe.name)
# Build candidate lists and choose selected value
for item in sorted(items_in_chain, key=lambda i: i.value.name):
candidates: List[dict] = []
for r in Recipes:
rec = r.value
if item in rec.outputs:
candidates.append({
"name": rec.name,
"building": rec.building.value.name,
})
# Only include items that actually have alternates (2 or more options)
if len(candidates) <= 1:
continue
candidates.sort(key=lambda x: x["name"])
# Selected recipe prioritizes explicit override if provided; otherwise use what chain used
selected_name = None
if item in preferred:
selected_name = preferred[item].name
else:
selected_name = current_selected_by_item.get(item)
alternates_by_item_ui.append({
"item": item.value.name,
"slug": _slugify(item.value.name),
"options": candidates,
"selected": selected_name,
})
return render_template(
"new.html",
items=item_names,
targets_ui=targets_ui,
error=error,
chain=chain_ui,
reset_query=reset_query,
alternates_by_item=alternates_by_item_ui,
)
def create_app():
return app
@@ -481,4 +1055,8 @@ 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.SteelBeam: 3.0})
# chain = prod_chain.build_chain({Items.BrassBeltDrive: 10.0})
# print(chain)
# prod_chain.compute_chain({Items.FusedModularFrame: 1.5})

41
plus.py
View File

@@ -1,5 +1,5 @@
from enum import Enum
from typing import Dict
from typing import Dict, Tuple
from shared import Machine, Item
from pydantic import BaseModel, Field, ConfigDict
@@ -17,6 +17,19 @@ class Machines(Enum):
WetWasher = Machine(name="Wet Washer")
BoilerMk1 = Machine(name="Boiler Mk1")
MachineOrder: Tuple[Machines, ...] = (
Machines.Miner,
Machines.BoilerMk1,
Machines.Crusher,
Machines.WetWasher,
Machines.Sorter,
Machines.Smelter,
Machines.Foundry,
Machines.FlexibleBlastFurnace,
Machines.Reformer,
Machines.Constructor,
Machines.Assembler,
)
class Items(Enum):
IronIngot = Item(name="Iron Ingot")
@@ -169,9 +182,11 @@ class Recipes(Enum):
building=Machines.Crusher,
outputs={
Items.Silica: 60.0,
Items.Sand: 40.0,
},
inputs={Items.Kyalite: 60.0},
byproducts={
Items.Sand: 40.0,
}
)
# - Crushing Powders
CoarseSand = Recipe(
@@ -206,9 +221,11 @@ class Recipes(Enum):
outputs={
Items.CrushedIron: 90.0,
Items.CrushedTin: 60.0,
Items.CrushedGangue: 40.0,
},
inputs={Items.CrushedSiterite: 120.0},
byproducts={
Items.CrushedGangue: 40.0,
},
)
CrushedCopper = Recipe(
name="Crushed Copper",
@@ -216,21 +233,23 @@ class Recipes(Enum):
outputs={
Items.CrushedCopper: 96.0,
Items.CrushedMagnesium: 80.0,
Items.CrushedGangue: 40.0,
},
inputs={Items.CrushedCallanite: 120.0},
byproducts={
Items.CrushedGangue: 40.0,
},
)
CrushedZinc = Recipe(
name="Crushed Zinc",
building=Machines.Sorter,
outputs={
Items.CrushedZinc: 96.0,
Items.Sand: 48.0,
},
inputs={Items.CrushedLarrussite: 120.0},
byproducts={
Items.CrushedGangue: 60.0,
}
Items.Sand: 48.0,
},
)
CrushedCaterium = Recipe(
name="Crushed Caterium",
@@ -744,14 +763,16 @@ class Recipes(Enum):
name="Molten Steel",
building=Machines.FlexibleBlastFurnace,
outputs={
Items.MoltenSteel: 40.0,
Items.FlueGas: 30.0
Items.MoltenSteel: 40.0
},
inputs={
Items.MoltenIron: 40.0,
Items.CarbonPowder: 40.0,
Items.Steam: 60.0
},
byproducts={
Items.FlueGas: 30.0
}
)
DirectSteel = Recipe(
name="Direct Steel",
@@ -890,11 +911,13 @@ class Recipes(Enum):
building=Machines.WetWasher,
outputs={
Items.CrushedTin: 45.0,
Items.TailingsSlurry: 60.0
},
inputs={
Items.SiteriteOre: 60.0,
Items.Water: 30.0,
},
byproducts={
Items.TailingsSlurry: 60.0
}
)
SteamMk1 = Recipe(

228
templates/new.html Normal file
View File

@@ -0,0 +1,228 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Satisfactory Production Calculator — New</title>
<style>
:root { --bg: #0f172a; --card: #111827; --text: #e5e7eb; --muted:#94a3b8; --accent:#22d3ee; --ok:#4ade80; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
.container { margin: 0 auto; padding: 24px; }
h1 { font-size: 1.75rem; margin: 0 0 8px; }
p { color: var(--muted); margin-top: 0; }
.card { background: var(--card); border: 1px solid #1f2937; border-radius: 10px; padding: 16px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 800px) { .row { grid-template-columns: 1fr; } }
label { display:block; margin-bottom:8px; font-weight:600; }
select, input[type=number] { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid #334155; background: #0b1220; color: var(--text); }
button { padding: 10px 14px; border: 0; border-radius: 8px; background: var(--accent); color: #012b30; font-weight: 700; cursor: pointer; }
button:hover { filter: brightness(1.08); }
.mt { margin-top: 16px; }
.error { color: #fda4af; background: #451a1a; border: 1px solid #7f1d1d; padding: 10px; border-radius: 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #1f2937; vertical-align: top; }
th { color: var(--muted); font-weight: 600; }
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; }
</style>
<link rel="preconnect" href="/">
<meta name="robots" content="noindex">
<meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Satisfactory Production Calculator (new build-chain view)">
<link rel="icon" href="data:,">
<base href="/new">
<style>
details summary { cursor: pointer; }
</style>
</head>
<body>
<div class="container">
<h1>Satisfactory Production Calculator — New</h1>
<p>Compute production using the new build-chain logic. This page mirrors the index UI.</p>
<form class="card" method="get" id="targets-form">
<div>
<label>Products</label>
<table style="width:100%; border-collapse: collapse;">
<thead>
<tr>
<th style="width:60%">Item</th>
<th style="width:30%" class="mono">Rate (items/min)</th>
<th style="width:10%"></th>
</tr>
</thead>
<tbody id="targets-rows">
{% for row in targets_ui %}
<tr class="target-row">
<td>
<select name="item_{{ loop.index }}" required>
{% for it in items %}
<option value="{{ it }}" {% if row.item == it %}selected{% endif %}>{{ it }}</option>
{% endfor %}
</select>
</td>
<td>
<input name="rate_{{ loop.index }}" type="number" step="0.01" min="0" value="{{ row.rate }}" required>
</td>
<td>
<button type="button" class="pill" onclick="removeRow(this)" title="Remove"></button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt">
<button type="button" onclick="addRow()">Add product</button>
</div>
</div>
<div class="mt">
<button type="submit">Calculate</button>
<a href="{{ reset_query }}" class="pill" style="margin-left:8px; display:inline-block; text-decoration:none;">Reset</a>
</div>
{% if error %}
<div class="mt error">{{ error }}</div>
{% endif %}
</form>
{% if chain and chain|length > 0 %}
<div class="mt card">
<h2 style="margin-top:0">Results</h2>
<form method="get" style="background: transparent; border:0; padding:0;">
{# Preserve all target rows in the recalculation #}
{% for row in targets_ui %}
<input type="hidden" name="item_{{ loop.index }}" value="{{ row.item }}">
<input type="hidden" name="rate_{{ loop.index }}" value="{{ row.rate }}">
{% endfor %}
{# Separate table: Alternate recipe choices organized by item #}
{% if alternates_by_item and alternates_by_item|length > 0 %}
<h3>Alternate recipes by item</h3>
<table class="mt">
<thead>
<tr>
<th style="width:40%">Item</th>
<th style="width:60%">Preferred recipe</th>
</tr>
</thead>
<tbody>
{% for row in alternates_by_item %}
<tr>
<td>{{ row.item }}</td>
<td>
<select name="recipe_for_{{ row.slug }}">
{% for opt in row.options %}
<option value="{{ opt.name }}" {% if opt.name == row.selected %}selected{% endif %}>{{ opt.name }} ({{ opt.building }})</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<table>
<thead>
<tr>
<th>Recipe</th>
<th>Building</th>
<th class="mono">Production level</th>
<th>Ingress</th>
<th>Outputs</th>
<th>Egress</th>
</tr>
</thead>
<tbody>
{% for p in chain %}
<tr>
<td>{{ p.recipe }}</td>
<td>{{ p.building }}</td>
<td class="mono">{{ '%.2f'|format(p.production_level) }}</td>
<td>
{% if p.ingress and p.ingress|length > 0 %}
<div>
{% for ing in p.ingress %}
<div>{{ ing.item }} ← {{ ing.via }} — <span class="mono">{{ '%.2f'|format(ing.rate) }}</span></div>
{% endfor %}
</div>
{% else %}
<span class="pill">None</span>
{% endif %}
</td>
<td>
{% if p.outputs and p.outputs|length > 0 %}
<div>
{% for out in p.outputs %}
<div>{{ out.item }} — <span class="mono">{{ '%.2f'|format(out.rate) }}</span></div>
{% endfor %}
</div>
{% else %}
<span class="pill">None</span>
{% endif %}
</td>
<td>
{% if p.egress and p.egress|length > 0 %}
<div>
{% for eg in p.egress %}
<div>{{ eg.item }} → {{ eg.to }} — <span class="mono">{{ '%.2f'|format(eg.rate) }}</span></div>
{% endfor %}
</div>
{% else %}
<span class="pill">None</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt">
<button type="submit">Recalculate</button>
<a href="{{ reset_query }}" class="pill" style="margin-left:8px; display:inline-block; text-decoration:none;">Reset overrides</a>
</div>
</form>
</div>
{% endif %}
</div>
<script>
// Dynamic add/remove for multiple product targets
const itemsList = {{ items | tojson }};
function addRow() {
const tbody = document.getElementById('targets-rows');
const idx = tbody.querySelectorAll('tr').length + 1;
const tr = document.createElement('tr');
tr.className = 'target-row';
const options = itemsList.map(it => `<option value="${it}">${it}</option>`).join('');
tr.innerHTML = `
<td>
<select name="item_${idx}" required>${options}</select>
</td>
<td>
<input name="rate_${idx}" type="number" step="0.01" min="0" value="60.0" required>
</td>
<td>
<button type="button" class="pill" onclick="removeRow(this)" title="Remove">✕</button>
</td>`;
tbody.appendChild(tr);
}
function removeRow(btn) {
const tr = btn.closest('tr');
const tbody = tr && tr.parentElement;
if (!tbody) return;
tbody.removeChild(tr);
// Renumber names to keep indices compact
Array.from(tbody.querySelectorAll('tr')).forEach((row, i) => {
const idx = i + 1;
const sel = row.querySelector('select[name^="item_"]');
const rate = row.querySelector('input[name^="rate_"]');
if (sel) sel.name = `item_${idx}`;
if (rate) rate.name = `rate_${idx}`;
});
}
</script>
</body>
</html>