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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user