checkpoint

This commit is contained in:
2025-11-08 21:51:39 +00:00
parent df2bf7058c
commit 11fe895274
4 changed files with 508 additions and 96 deletions

View File

@@ -4,6 +4,7 @@
<w>aurovite</w> <w>aurovite</w>
<w>callanite</w> <w>callanite</w>
<w>caterium</w> <w>caterium</w>
<w>heatsink</w>
<w>larrussite</w> <w>larrussite</w>
<w>quickwire</w> <w>quickwire</w>
<w>siterite</w> <w>siterite</w>

204
main.py
View File

@@ -34,7 +34,7 @@ class Production(BaseModel):
quantity: float quantity: float
class ProductionChain: 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) self.item_to_recipies: Dict[Items, list[Recipes]] = defaultdict(list)
for r in Recipes: for r in Recipes:
for o in r.value.outputs: for o in r.value.outputs:
@@ -49,9 +49,11 @@ class ProductionChain:
self.production_chain: Dict[str, float] = defaultdict(float) self.production_chain: Dict[str, float] = defaultdict(float)
self.excess: Dict[Items, float] = defaultdict(float) self.excess: Dict[Items, float] = defaultdict(float)
self.byproduct: 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]: 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] 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]): 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) levels.append(target_quantity / quantity)
production_level = max(levels) production_level = max(levels)
if production_level == 0:
continue
production_chain[recipe.name] += production_level 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(): for out, quantity in recipe.outputs.items():
production[out] += production_level * quantity production[out] += production_level * quantity
for byproduct, quantity in recipe.byproducts.items(): for byproduct, quantity in recipe.byproducts.items():
@@ -153,13 +160,77 @@ def index():
name_to_item = {i.value.name: i for i in Items} name_to_item = {i.value.name: i for i in Items}
result = None result = None
error = None error_msgs: List[str] = []
selected_item = item_names[0] if item_names else ""
selected_rate = 60.0
# Read from query parameters for bookmarkable URLs # --------------------
item_name = request.args.get("item") or selected_item # Parse targets (support multiple products)
rate_str = request.args.get("rate") # --------------------
# 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 "" selected_recipe = request.args.get("recipe") or ""
# Parse per-item recipe overrides from query params recipe_for_<slug(item)> # Parse per-item recipe overrides from query params recipe_for_<slug(item)>
@@ -184,23 +255,9 @@ def index():
if value in candidates: if value in candidates:
overrides[item_enum] = value overrides[item_enum] = value
rate = None # Candidate recipes for the (first) selected item (single-target convenience)
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
recipe_options: List[str] = [] 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: if item_obj_for_options is not None:
for r in Recipes: for r in Recipes:
recipe = r.value recipe = r.value
@@ -224,22 +281,80 @@ def index():
# Compute and also prepare per-item override options based on resulting chain # Compute and also prepare per-item override options based on resulting chain
overrides_ui: List[dict] = [] overrides_ui: List[dict] = []
if not error and item_name and rate is not None: if targets:
item_obj = name_to_item.get(item_name) # Translate preferred mapping (Items -> recipe name) into objects and pass to ProductionChain
if item_obj is None: preferred_recipes_obj: Optional[Dict[Items, Recipe]] = None
error = "Unknown item selected." if preferred:
else: name_to_recipe = {r.value.name: r.value for r in Recipes}
targets = {item_obj: rate} preferred_recipes_obj = {}
raw, steps, outputs, unused = compute_chain(targets, preferred_by_output=preferred) 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)
# 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,
})
# Aggregate targets for display
result_targets = {}
for itm, qty in targets.items():
if qty > 0:
result_targets[itm.value.name] = qty
result = { result = {
"targets": {item_name: rate}, "targets": result_targets,
"raw": raw, "raw": raw,
"steps": steps, "steps": steps,
"outputs": outputs, "outputs": outputs,
"unused": unused, "unused": unused,
"excess": excess,
} }
# Collect unique output items from steps # Collect unique output items from steps for override options
unique_items = [] unique_items = []
seen = set() seen = set()
for s in steps: for s in steps:
@@ -247,7 +362,6 @@ def index():
if item_nm and item_nm not in seen: if item_nm and item_nm not in seen:
seen.add(item_nm) seen.add(item_nm)
unique_items.append(item_nm) unique_items.append(item_nm)
# For each item, compute candidate recipes and current selection
for item_nm in unique_items: for item_nm in unique_items:
item_enum2 = name_to_item.get(item_nm) item_enum2 = name_to_item.get(item_nm)
if not item_enum2: if not item_enum2:
@@ -261,7 +375,7 @@ def index():
"building": rec.building.value.name, "building": rec.building.value.name,
}) })
if len(candidates) <= 1: if len(candidates) <= 1:
continue # only show when alternates exist continue
candidates.sort(key=lambda x: (x["name"])) candidates.sort(key=lambda x: (x["name"]))
sel = None sel = None
if preferred and item_enum2 in preferred: if preferred and item_enum2 in preferred:
@@ -274,11 +388,22 @@ def index():
"selected": sel or "", "selected": sel or "",
}) })
# Build reset query (clear overrides) # 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}" reset_query = f"?item={selected_item}&rate={selected_rate}"
if selected_recipe: if selected_recipe:
reset_query += f"&recipe={selected_recipe}" reset_query += f"&recipe={selected_recipe}"
# Combine error messages
error = " ".join(error_msgs) if error_msgs else None
return render_template( return render_template(
"index.html", "index.html",
items=item_names, items=item_names,
@@ -290,6 +415,7 @@ def index():
selected_recipe=selected_recipe, selected_recipe=selected_recipe,
overrides_ui=overrides_ui, overrides_ui=overrides_ui,
reset_query=reset_query, reset_query=reset_query,
targets_ui=targets_ui,
) )
@@ -299,6 +425,6 @@ def create_app():
if __name__ == "__main__": if __name__ == "__main__":
# For local dev: python main.py # For local dev: python main.py
# app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=True)
prod_chain = ProductionChain() # prod_chain = ProductionChain()
prod_chain.compute_chain({Items.ModularFrame: 1.5}) # prod_chain.compute_chain({Items.CateriumHeatsink: 10.0})

191
plus.py
View File

@@ -12,6 +12,8 @@ class Machines(Enum):
Sorter = Machine(name="Sorter") Sorter = Machine(name="Sorter")
Crusher = Machine(name="Crusher") Crusher = Machine(name="Crusher")
Foundry = Machine(name="Foundry") Foundry = Machine(name="Foundry")
FlexibleBlastFurnace = Machine(name="Flexible Blast Furnace")
Reformer = Machine(name="Reformer")
class Items(Enum): class Items(Enum):
IronIngot = Item(name="Iron Ingot") IronIngot = Item(name="Iron Ingot")
@@ -71,6 +73,18 @@ class Items(Enum):
BronzeFrame = Item(name="Bronze Frame") BronzeFrame = Item(name="Bronze Frame")
AILimiter = Item(name="AI Limiter") AILimiter = Item(name="AI Limiter")
Water = Item(name="Water") 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): class RawResources(Enum):
Water = Items.Water Water = Items.Water
@@ -177,6 +191,16 @@ class Recipes(Enum):
}, },
inputs={Items.CrushedLarrussite: 120.0}, 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 # Smelter
# - smelting # - smelting
IronIngot = Recipe( IronIngot = Recipe(
@@ -197,6 +221,12 @@ class Recipes(Enum):
outputs={Items.TinIngot: 15.0}, outputs={Items.TinIngot: 15.0},
inputs={Items.CrushedTin: 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 # - Ingots
ImpureIronIngot = Recipe( ImpureIronIngot = Recipe(
name="Impure Iron Ingot", name="Impure Iron Ingot",
@@ -219,8 +249,8 @@ class Recipes(Enum):
ImpureCateriumIngot = Recipe( ImpureCateriumIngot = Recipe(
name="Impure Caterium Ingot", name="Impure Caterium Ingot",
building=Machines.Smelter, building=Machines.Smelter,
inputs={Items.CrushedAurovite: 40.0},
outputs={Items.CateriumIngot: 24.0}, outputs={Items.CateriumIngot: 24.0},
inputs={Items.CrushedAurovite: 40.0},
) )
ZincIngot = Recipe( ZincIngot = Recipe(
name="Zinc Ingot", name="Zinc Ingot",
@@ -235,6 +265,12 @@ class Recipes(Enum):
outputs={Items.Glass: 20.0}, outputs={Items.Glass: 20.0},
inputs={Items.Sand: 30.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 # Constructor
# - Standard Parts # - Standard Parts
IronPlate = Recipe( IronPlate = Recipe(
@@ -309,6 +345,18 @@ class Recipes(Enum):
outputs={Items.ZincPlates: 15.0}, outputs={Items.ZincPlates: 15.0},
inputs={Items.ZincIngot: 12.5}, 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 # - Electronics
IronWire = Recipe( IronWire = Recipe(
name="Iron Wire", name="Iron Wire",
@@ -353,7 +401,13 @@ class Recipes(Enum):
outputs={Items.Concrete: 15.0}, outputs={Items.Concrete: 15.0},
inputs={Items.CrushedGangue: 45.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 # Foundry
# - Building Parts # - Building Parts
SolarCell = Recipe( SolarCell = Recipe(
@@ -406,6 +460,24 @@ class Recipes(Enum):
Items.Rotor: 3.75, 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 # - Standard Parts
TinPlate = Recipe( TinPlate = Recipe(
name="Tin Plate", name="Tin Plate",
@@ -470,6 +542,25 @@ class Recipes(Enum):
Items.BronzeBeam: 7.5, 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( AILimiter = Recipe(
name="AI Limiter", name="AI Limiter",
building=Machines.Assembler, building=Machines.Assembler,
@@ -479,5 +570,101 @@ class Recipes(Enum):
Items.TinnedWire: 18.0, 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() Recipe.model_rebuild()

View File

@@ -26,6 +26,8 @@
code { color: var(--accent); } code { color: var(--accent); }
.mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .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; } .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; }
</style> </style>
</head> </head>
<body> <body>
@@ -33,19 +35,39 @@
<h1>Satisfactory Production Calculator</h1> <h1>Satisfactory Production Calculator</h1>
<p>Compute buildings and raw inputs for a target output rate (items per minute).</p> <p>Compute buildings and raw inputs for a target output rate (items per minute).</p>
<form class="card" method="get"> <form class="card" method="get" id="targets-form">
<div class="row">
<div> <div>
<label for="item">Product</label> <label>Products</label>
<select id="item" name="item" required> <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 %} {% for it in items %}
<option value="{{ it }}" {% if selected_item and it==selected_item %}selected{% endif %}>{{ it }}</option> <option value="{{ it }}" {% if row.item == it %}selected{% endif %}>{{ it }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </td>
<div> <td>
<label for="rate">Target rate (items/min)</label> <input name="rate_{{ loop.index }}" type="number" step="0.01" min="0" value="{{ row.rate }}" required>
<input id="rate" name="rate" type="number" step="0.01" min="0" value="{{ selected_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> </div>
@@ -64,11 +86,12 @@
{% if result.raw %} {% if result.raw %}
<table> <table>
<thead> <thead>
<tr><th>Item</th><th class="mono">Rate (items/min)</th></tr> <tr><th class="done-col">Done</th><th>Item</th><th class="mono">Rate (items/min)</th></tr>
</thead> </thead>
<tbody> <tbody>
{% for item,rate in result.raw.items() %} {% for item,rate in result.raw.items() %}
<tr> <tr>
<td class="done-col"><input type="checkbox" onchange="toggleDone(this)"></td>
<td>{{ item }}</td> <td>{{ item }}</td>
<td class="mono">{{ '%.2f'|format(rate) }}</td> <td class="mono">{{ '%.2f'|format(rate) }}</td>
</tr> </tr>
@@ -82,8 +105,11 @@
{% if overrides_ui and overrides_ui|length > 0 %} {% if overrides_ui and overrides_ui|length > 0 %}
<h3 class="mt">Recipe overrides</h3> <h3 class="mt">Recipe overrides</h3>
<form method="get" class="card" style="background: transparent; border:0; padding:0;"> <form method="get" class="card" style="background: transparent; border:0; padding:0;">
<input type="hidden" name="item" value="{{ selected_item }}"> {# Preserve all target rows in the recalculation #}
<input type="hidden" name="rate" value="{{ selected_rate }}"> {% 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 %}
{% if selected_recipe %}<input type="hidden" name="recipe" value="{{ selected_recipe }}">{% endif %} {% if selected_recipe %}<input type="hidden" name="recipe" value="{{ selected_recipe }}">{% endif %}
<table> <table>
<thead> <thead>
@@ -120,9 +146,11 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th class="done-col">Done</th>
<th>Output Item</th> <th>Output Item</th>
<th>Recipe</th> <th>Recipe</th>
<th>Building</th> <th>Building</th>
<th>Inputs</th>
<th class="mono">Target rate</th> <th class="mono">Target rate</th>
<th class="mono">Per-building output</th> <th class="mono">Per-building output</th>
<th class="mono">Buildings</th> <th class="mono">Buildings</th>
@@ -132,9 +160,21 @@
<tbody> <tbody>
{% for s in result.steps %} {% for s in result.steps %}
<tr> <tr>
<td class="done-col"><input type="checkbox" onchange="toggleDone(this)"></td>
<td>{{ s.item }}</td> <td>{{ s.item }}</td>
<td>{{ s.recipe }}</td> <td>{{ s.recipe }}</td>
<td>{{ s.building }}</td> <td>{{ s.building }}</td>
<td>
{% if s.inputs and s.inputs|length > 0 %}
<div>
{% for inp in s.inputs %}
<div>{{ inp.item }} — <span class="mono">{{ '%.2f'|format(inp.rate) }}</span></div>
{% endfor %}
</div>
{% else %}
<span class="pill">None</span>
{% endif %}
</td>
<td class="mono">{{ '%.2f'|format(s.target_rate) }}</td> <td class="mono">{{ '%.2f'|format(s.target_rate) }}</td>
<td class="mono">{{ '%.2f'|format(s.per_building_output) }}</td> <td class="mono">{{ '%.2f'|format(s.per_building_output) }}</td>
<td class="mono">{{ '%.2f'|format(s.buildings_float) }} (~ {{ s.buildings }})</td> <td class="mono">{{ '%.2f'|format(s.buildings_float) }} (~ {{ s.buildings }})</td>
@@ -147,15 +187,36 @@
<p class="pill">No production steps (target is a raw resource).</p> <p class="pill">No production steps (target is a raw resource).</p>
{% endif %} {% endif %}
<h3 class="mt">Excess products</h3>
{% if result.excess and result.excess|length > 0 %}
<table>
<thead>
<tr><th class="done-col">Done</th><th>Item</th><th class="mono">Excess rate (items/min)</th></tr>
</thead>
<tbody>
{% for item,rate in result.excess.items() %}
<tr>
<td class="done-col"><input type="checkbox" onchange="toggleDone(this)"></td>
<td>{{ item }}</td>
<td class="mono">{{ '%.2f'|format(rate) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="pill">No excess products for this target.</p>
{% endif %}
<h3 class="mt">Unused byproducts</h3> <h3 class="mt">Unused byproducts</h3>
{% if result.unused and result.unused|length > 0 %} {% if result.unused and result.unused|length > 0 %}
<table> <table>
<thead> <thead>
<tr><th>Item</th><th class="mono">Unused rate (items/min)</th></tr> <tr><th class="done-col">Done</th><th>Item</th><th class="mono">Unused rate (items/min)</th></tr>
</thead> </thead>
<tbody> <tbody>
{% for item,rate in result.unused.items() %} {% for item,rate in result.unused.items() %}
<tr> <tr>
<td class="done-col"><input type="checkbox" onchange="toggleDone(this)"></td>
<td>{{ item }}</td> <td>{{ item }}</td>
<td class="mono">{{ '%.2f'|format(rate) }}</td> <td class="mono">{{ '%.2f'|format(rate) }}</td>
</tr> </tr>
@@ -170,8 +231,45 @@
</div> </div>
<script> <script>
// Tiny helper to maintain selected item after submit if not handled by server // Dynamic add/remove for multiple product targets
// (we already set it via Jinja, this is just progressive enhancement) 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);
// Optionally 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}`;
});
}
function toggleDone(cb) {
const tr = cb.closest('tr');
if (!tr) return;
tr.classList.toggle('completed', cb.checked);
}
</script> </script>
</body> </body>
</html> </html>