checkpoint

This commit is contained in:
2025-11-07 09:03:25 +00:00
parent 562b33dff9
commit 58c83f6140
5 changed files with 274 additions and 11 deletions

180
main.py
View File

@@ -1,14 +1,84 @@
from collections import defaultdict, deque
from enum import Enum from enum import Enum
from math import ceil from math import ceil
from typing import Dict, List, Tuple, Optional from typing import Dict, List, Tuple, Optional, Any
import networkx as nx
from flask import Flask, render_template, request from flask import Flask, render_template, request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from plus import Items, Machines, Recipes, Recipe from plus import Items, Machines, Recipes, Recipe
from rich import print
app = Flask(__name__) app = Flask(__name__)
# Helpers to map item names to safe query parameter keys
def _slugify(name: str) -> str:
s = ''.join(ch.lower() if ch.isalnum() else '_' for ch in name)
# collapse consecutive underscores and trim
out = []
prev_us = False
for ch in s:
if ch == '_':
if not prev_us:
out.append('_')
prev_us = True
else:
out.append(ch)
prev_us = False
return ''.join(out).strip('_')
def compute_chain(targets: Dict[Items, float]) -> Tuple[Dict[str, float], List[dict], Dict[str, float], Dict[str, float]]: class Production(BaseModel):
recipe: Recipe
quantity: float
def get_recipe(item: Items, recipe_map: Dict[Items, list[Recipes]]) -> Optional[Recipe]:
return recipe_map.get(item, (None,))[0]
def compute_chain2(targets: Dict[Items, float]) -> Any:
if not targets:
return {}
recipe_map: Dict[Items, list[Recipes]] = defaultdict(list)
for r in Recipes:
for o in r.value.outputs:
recipe_map[o].append(r.value)
# print(recipe_map)
demand = defaultdict(float)
for target in targets:
demand[target] = targets[target]
production = defaultdict(float)
# add demands to production
# find a recipe for that demand
# add inputs to demand
queue = deque(targets)
g = nx.DiGraph()
production_queue = []
raw_resources = defaultdict(float)
production_chain = defaultdict(float)
while queue:
item = queue.popleft()
recipe = get_recipe(item, recipe_map)
if recipe is None:
raw_resources[item] += demand[item] - raw_resources[item]
continue
levels = []
for out, quantity in recipe.outputs.items():
if out in demand:
target_quantity = demand[out] - production[out]
if target_quantity > 0:
levels.append(target_quantity / quantity)
else:
levels.append(0)
production_level = max(levels) if max(levels) > 0 else 0
production_chain[recipe.name] = production_level
for out, quantity in recipe.outputs.items():
production[out] += production_level * quantity
for inp, quantity in recipe.inputs.items():
queue.append(inp)
demand[inp] += production_level * quantity
print(demand, production, raw_resources, production_chain)
return 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: Given desired output rates (item -> units/min), compute:
- required raw input rates (raw item -> units/min) - required raw input rates (raw item -> units/min)
@@ -19,6 +89,8 @@ def compute_chain(targets: Dict[Items, float]) -> Tuple[Dict[str, float], List[d
Now supports alternate recipes: when multiple recipes produce the same output item, Now supports alternate recipes: when multiple recipes produce the same output item,
a selection heuristic is used unless an explicit preference is configured. 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 # Build a mapping from output item -> list of recipes that produce it
output_to_recipes: Dict[Items, List[Recipe]] = {} output_to_recipes: Dict[Items, List[Recipe]] = {}
@@ -28,8 +100,7 @@ def compute_chain(targets: Dict[Items, float]) -> Tuple[Dict[str, float], List[d
output_to_recipes.setdefault(out_item, []).append(recipe) output_to_recipes.setdefault(out_item, []).append(recipe)
# Optional explicit preferences: map output Item -> recipe name to prefer # Optional explicit preferences: map output Item -> recipe name to prefer
# Users can populate/modify this mapping elsewhere if desired. PREFERRED_RECIPE_BY_OUTPUT: Dict[Items, str] = preferred_by_output or {}
PREFERRED_RECIPE_BY_OUTPUT: Dict[Items, str] = {}
# Heuristic to select a recipe when multiple alternatives exist # Heuristic to select a recipe when multiple alternatives exist
def select_recipe_for(item: Items) -> Optional[Recipe]: def select_recipe_for(item: Items) -> Optional[Recipe]:
@@ -177,6 +248,30 @@ def index():
# Read from query parameters for bookmarkable URLs # Read from query parameters for bookmarkable URLs
item_name = request.args.get("item") or selected_item item_name = request.args.get("item") or selected_item
rate_str = request.args.get("rate") rate_str = request.args.get("rate")
selected_recipe = request.args.get("recipe") or ""
# Parse per-item recipe overrides from query params recipe_for_<slug(item)>
# Build slug -> Items map
slug_to_item: Dict[str, Items] = { _slugify(i.value.name): i for i in Items }
overrides: Dict[Items, str] = {}
for key, value in request.args.items():
if not key.startswith("recipe_for_"):
continue
if value is None or value == "":
continue
slug = key[len("recipe_for_"):]
item_enum = slug_to_item.get(slug)
if not item_enum:
continue
# Validate that the value is a valid recipe option 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:
overrides[item_enum] = value
rate = None rate = None
if rate_str is not None and rate_str != "": if rate_str is not None and rate_str != "":
try: try:
@@ -191,13 +286,39 @@ def index():
if rate is not None: if rate is not None:
selected_rate = rate selected_rate = rate
# Determine candidate recipes for the selected output item
recipe_options: List[str] = []
item_obj_for_options = name_to_item.get(selected_item)
if item_obj_for_options is not None:
for r in Recipes:
recipe = r.value
if item_obj_for_options in recipe.outputs:
recipe_options.append(recipe.name)
recipe_options.sort()
# Validate selected_recipe against available options
if selected_recipe not in recipe_options:
selected_recipe = ""
else:
selected_recipe = ""
# Build preferred map merging top-level selection and overrides
preferred: Optional[Dict[Items, str]] = None
if selected_recipe or overrides:
preferred = {}
preferred.update(overrides)
if selected_recipe and item_obj_for_options is not None:
preferred[item_obj_for_options] = selected_recipe
# 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: if not error and item_name and rate is not None:
item_obj = name_to_item.get(item_name) item_obj = name_to_item.get(item_name)
if item_obj is None: if item_obj is None:
error = "Unknown item selected." error = "Unknown item selected."
else: else:
targets = {item_obj: rate} targets = {item_obj: rate}
raw, steps, outputs, unused = compute_chain(targets) raw, steps, outputs, unused = compute_chain(targets, preferred_by_output=preferred)
result = { result = {
"targets": {item_name: rate}, "targets": {item_name: rate},
"raw": raw, "raw": raw,
@@ -206,6 +327,46 @@ def index():
"unused": unused, "unused": unused,
} }
# 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 reset query (clear overrides)
reset_query = f"?item={selected_item}&rate={selected_rate}"
if selected_recipe:
reset_query += f"&recipe={selected_recipe}"
return render_template( return render_template(
"index.html", "index.html",
items=item_names, items=item_names,
@@ -213,6 +374,10 @@ def index():
error=error, error=error,
selected_item=selected_item, selected_item=selected_item,
selected_rate=selected_rate, selected_rate=selected_rate,
recipe_options=recipe_options,
selected_recipe=selected_recipe,
overrides_ui=overrides_ui,
reset_query=reset_query,
) )
@@ -222,4 +387,5 @@ 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)
compute_chain2({Items.ModularFrame: 45.0})

View File

@@ -1,7 +1,7 @@
from enum import Enum from enum import Enum
from typing import Dict from typing import Dict
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
class Item(BaseModel): class Item(BaseModel):
@@ -77,7 +77,9 @@ class Items(Enum):
BronzeFrame = Item(name="Bronze Frame") BronzeFrame = Item(name="Bronze Frame")
AILimiter = Item(name="AI Limiter") AILimiter = Item(name="AI Limiter")
class Recipe(BaseModel): class Recipe(BaseModel):
model_config = ConfigDict(frozen=True)
name: str # Human-friendly name name: str # Human-friendly name
building: Machines # e.g., "Smelter", "Constructor" building: Machines # e.g., "Smelter", "Constructor"
outputs: Dict[Items, float] # Produced item name outputs: Dict[Items, float] # Produced item name
@@ -216,7 +218,7 @@ class Recipes(Enum):
name="Impure Caterium Ingot", name="Impure Caterium Ingot",
building=Machines.Smelter, building=Machines.Smelter,
inputs={Items.CrushedAurovite: 40.0}, inputs={Items.CrushedAurovite: 40.0},
outputs={Items.CopperIngot: 24.0}, outputs={Items.CateriumIngot: 24.0},
) )
ZincIngot = Recipe( ZincIngot = Recipe(
name="Zinc Ingot", name="Zinc Ingot",

View File

@@ -5,5 +5,7 @@ description = "Web-based Satisfactory production calculator in Python (Flask)"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"flask>=3.0", "flask>=3.0",
"networkx>=3.5",
"pydantic>=2.12.4", "pydantic>=2.12.4",
"rich>=14.2.0",
] ]

View File

@@ -8,7 +8,7 @@
:root { --bg: #0f172a; --card: #111827; --text: #e5e7eb; --muted:#94a3b8; --accent:#22d3ee; --ok:#4ade80; } :root { --bg: #0f172a; --card: #111827; --text: #e5e7eb; --muted:#94a3b8; --accent:#22d3ee; --ok:#4ade80; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); } body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
.container { max-width: 960px; margin: 0 auto; padding: 24px; } .container { margin: 0 auto; padding: 24px; }
h1 { font-size: 1.75rem; margin: 0 0 8px; } h1 { font-size: 1.75rem; margin: 0 0 8px; }
p { color: var(--muted); margin-top: 0; } p { color: var(--muted); margin-top: 0; }
.card { background: var(--card); border: 1px solid #1f2937; border-radius: 10px; padding: 16px; } .card { background: var(--card); border: 1px solid #1f2937; border-radius: 10px; padding: 16px; }
@@ -31,7 +31,7 @@
<body> <body>
<div class="container"> <div class="container">
<h1>Satisfactory Production Calculator</h1> <h1>Satisfactory Production Calculator</h1>
<p>Compute buildings and raw inputs for a target output rate (items per minute). Data set includes a few early-game default recipes; you can extend it in <code>main.py</code>.</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">
<div class="row"> <div class="row">
@@ -48,6 +48,7 @@
<input id="rate" name="rate" type="number" step="0.01" min="0" value="{{ selected_rate }}" required> <input id="rate" name="rate" type="number" step="0.01" min="0" value="{{ selected_rate }}" required>
</div> </div>
</div> </div>
<div class="mt"> <div class="mt">
<button type="submit">Calculate</button> <button type="submit">Calculate</button>
</div> </div>
@@ -78,6 +79,42 @@
<p class="pill">No raw resources required (target is a raw resource).</p> <p class="pill">No raw resources required (target is a raw resource).</p>
{% endif %} {% endif %}
{% if overrides_ui and overrides_ui|length > 0 %}
<h3 class="mt">Recipe overrides</h3>
<form method="get" class="card" style="background: transparent; border:0; padding:0;">
<input type="hidden" name="item" value="{{ selected_item }}">
<input type="hidden" name="rate" value="{{ selected_rate }}">
{% if selected_recipe %}<input type="hidden" name="recipe" value="{{ selected_recipe }}">{% endif %}
<table>
<thead>
<tr>
<th>Item</th>
<th>Override recipe</th>
</tr>
</thead>
<tbody>
{% for ov in overrides_ui %}
<tr>
<td>{{ ov.item_name }}</td>
<td>
<select name="recipe_for_{{ ov.slug }}">
<option value="" {% if not ov.selected %}selected{% endif %}>Auto-select best</option>
{% for opt in ov.options %}
<option value="{{ opt.name }}" {% if ov.selected and opt.name==ov.selected %}selected{% endif %}>{{ opt.name }} ({{ opt.building }})</option>
{% endfor %}
</select>
</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>
{% endif %}
<h3 class="mt">Production steps</h3> <h3 class="mt">Production steps</h3>
{% if result.steps %} {% if result.steps %}
<table> <table>

56
uv.lock generated
View File

@@ -79,6 +79,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
] ]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" version = "3.0.3"
@@ -131,6 +143,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
] ]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "networkx"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.4" version = "2.12.4"
@@ -199,19 +229,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
] ]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]] [[package]]
name = "pysatcalc" name = "pysatcalc"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "flask" }, { name = "flask" },
{ name = "networkx" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "rich" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "flask", specifier = ">=3.0" }, { name = "flask", specifier = ">=3.0" },
{ name = "networkx", specifier = ">=3.5" },
{ name = "pydantic", specifier = ">=2.12.4" }, { name = "pydantic", specifier = ">=2.12.4" },
{ name = "rich", specifier = ">=14.2.0" },
]
[[package]]
name = "rich"
version = "14.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
] ]
[[package]] [[package]]