give every recipe one shape: a list of output groups

Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced
together, or outputs of which exactly one happened -- and every rule over them
was written twice, selected by `building == ReprocessingPlant`: sizing a
buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an
item. RecipeDef now holds output groups, each a weight and a list of items, and
a cycle yields exactly one group. One group is the ordinary recipe, so the old
two cases are the same shape with one and with several, and all four rules
collapse to one expression apiece with no building-type test left.

rollReprocessingOutput becomes rollOutputGroup, where a single group returns
without drawing or testing eligibility. That early-out is load-bearing twice
over. Drawing there would consume entropy for every ordinary recipe and shift
every later random outcome; and eligibility must not apply either, since
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it -- testing it would stop the building
producing rather than gate a drop. Past the early-out a group is eligible only
when all of its items are unlocked, being produced whole.

Threat follows the recipe's shape rather than the building, and the per-unit
value now divides by the group's amount as well as its odds. That moves no
number today: every item resolved through this path has amount 1, which is why
the threat expectations are untouched.

Config keeps `outputs = [...]` as the single-group form, so only the two
reprocessing recipes change shape. The recipe summary gains "/" between groups
and keeps "+" within one, which also fixes the plant reading as though a cycle
produced all of its items at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-17 12:47:09 +02:00
parent 41c45d73ce
commit 9c275e283c
27 changed files with 535 additions and 234 deletions

View File

@@ -58,6 +58,22 @@ def consumes_scrap(recipe):
return any(inp["item"] == "scrap" for inp in recipe.get("inputs", []))
def output_groups(recipe):
"""The recipe's output groups, whichever form the config writes them in.
`outputs = [...]` is the single-group shorthand; `[[recipe.output_group]]` is the
several-group form (REQ-MAT-OUTPUT-GROUP). A cycle yields exactly one group.
"""
if "output_group" in recipe:
return recipe["output_group"]
return [{"items": recipe.get("outputs", [])}]
def picks_one_of_several(recipe):
"""True when a cycle picks between groups, which is what makes a yield random."""
return len(output_groups(recipe)) > 1
def recipe_threat_per_unit(recipe, output, item_threat):
threat = recipe["duration_seconds"]
for inp in recipe.get("inputs", []):
@@ -69,15 +85,18 @@ def recipe_threat_per_unit(recipe, output, item_threat):
def resolve_items(recipes, scrap_threat):
"""Return {item: threat} resolved per REQ-THREAT-ITEM."""
non_repro = [r for r in recipes if r["building"] != "reprocessing_plant"]
repro = [r for r in recipes if r["building"] == "reprocessing_plant"]
# What decides the model is the recipe's shape, not the building running it: a recipe
# picking between groups costs its items by their odds, one yielding a single group
# every cycle costs them outright (REQ-MAT-OUTPUT-GROUP, REQ-THREAT-ITEM).
non_repro = [r for r in recipes if not picks_one_of_several(r)]
repro = [r for r in recipes if picks_one_of_several(r)]
# Items with at least one scrap-free producer: their scrap-consuming
# recipes never participate (fallback rule).
scrap_free_items = set()
for recipe in non_repro:
if not consumes_scrap(recipe):
for output in recipe.get("outputs", []):
for output in output_groups(recipe)[0]["items"]:
scrap_free_items.add(output["item"])
def eligible(recipe, output):
@@ -93,7 +112,7 @@ def resolve_items(recipes, scrap_threat):
# pass earlier than the base path would win and underprice the item.
recipes_per_item = {}
for recipe in non_repro:
for output in recipe.get("outputs", []):
for output in output_groups(recipe)[0]["items"]:
if eligible(recipe, output):
recipes_per_item.setdefault(output["item"], []).append(
(recipe, output))
@@ -121,22 +140,28 @@ def resolve_items(recipes, scrap_threat):
for recipe in repro:
scrap_per_cycle = sum(inp["amount"]
for inp in recipe.get("inputs", []))
total_weight = sum(out.get("probability", 1.0)
for out in recipe.get("outputs", []))
for output in recipe.get("outputs", []):
# Reprocessing defines an item's threat only when nothing
# else produces it (REQ-THREAT-ITEM).
if output["item"] in item_threat:
continue
if output["item"] in scrap_free_items:
continue
probability = output.get("probability", 1.0) / total_weight
groups = output_groups(recipe)
total_weight = sum(g.get("probability", 1.0) for g in groups)
for group in groups:
probability = group.get("probability", 1.0) / total_weight
if probability <= 0.0:
continue
item_threat[output["item"]] = (
(scrap_threat * scrap_per_cycle
+ recipe["duration_seconds"]) / probability)
progress = True
for output in group["items"]:
# This model defines an item's threat only when nothing else
# produces it (REQ-THREAT-ITEM).
if output["item"] in item_threat:
continue
if output["item"] in scrap_free_items:
continue
# Per unit: the cycle's cost over the odds of getting this group at
# all, then over how many units the group yields.
divisor = probability * output["amount"]
if divisor <= 0.0:
continue
item_threat[output["item"]] = (
(scrap_threat * scrap_per_cycle
+ recipe["duration_seconds"]) / divisor)
progress = True
return progress
# Iterate to a fixpoint: items downstream of reprocessing-only items
@@ -225,9 +250,10 @@ def main():
" building) ==")
producers = {} # item -> [(recipe id, items/s per building)]
for recipe in recipes:
if recipe["building"] == "reprocessing_plant":
# A recipe that picks between groups has no steady per-item rate to quote.
if picks_one_of_several(recipe):
continue
for output in recipe.get("outputs", []):
for output in output_groups(recipe)[0]["items"]:
rate = output["amount"] / recipe["duration_seconds"]
producers.setdefault(output["item"], []).append((recipe["id"], rate))
for recipe in recipes: