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
144 lines
5.3 KiB
Python
144 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify the recipe tree is closed and consistent.
|
|
|
|
Reads recipes.toml, ships.toml, modules.toml, and visuals.toml, then checks:
|
|
|
|
1. Producers — every item consumed anywhere (recipe inputs, ship hull
|
|
materials, module materials) is produced by some recipe. 'scrap' is
|
|
exempt: it drops from destroyed ships.
|
|
2. Visuals — every item that exists in the economy has an [items.*]
|
|
entry in visuals.toml, and visuals.toml has no entries for items
|
|
that no longer exist.
|
|
3. Orphans — items that are produced but never consumed (warning only;
|
|
'building_block' is exempt: the HQ consumes it).
|
|
|
|
It also prints which items are obtainable ONLY through reprocessing —
|
|
the combat-gated materials — so changes to that gate are visible.
|
|
|
|
Usage (from the repository root or anywhere else):
|
|
|
|
python dota_factory/tools/verify_recipes.py
|
|
python dota_factory/tools/verify_recipes.py --config-dir path/to/config
|
|
|
|
By default the config directory is resolved relative to this script
|
|
(../bin/app/data/config). Requires the 'toml' package on Python < 3.11
|
|
(pip install --user toml); on 3.11+ the standard tomllib is used.
|
|
|
|
Exits 1 if a producer or visuals check fails, 0 otherwise (warnings do
|
|
not affect the exit code).
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
|
|
WORLD_SOURCED_ITEMS = {"scrap"} # dropped by destroyed ships
|
|
IMPLICITLY_CONSUMED_ITEMS = {"building_block"} # consumed by the HQ
|
|
|
|
|
|
def load_toml(path):
|
|
try:
|
|
import tomllib
|
|
with open(path, "rb") as fh:
|
|
return tomllib.load(fh)
|
|
except ImportError:
|
|
import toml
|
|
return toml.load(path)
|
|
|
|
|
|
def recipe_outputs(recipe):
|
|
"""Every item the recipe can produce, across all of its output groups.
|
|
|
|
`outputs = [...]` is the single-group shorthand; `[[recipe.output_group]]` is the
|
|
several-group form (REQ-MAT-OUTPUT-GROUP).
|
|
"""
|
|
if "output_group" in recipe:
|
|
return [out for group in recipe["output_group"] for out in group["items"]]
|
|
return recipe.get("outputs", [])
|
|
|
|
|
|
def main():
|
|
default_dir = os.path.normpath(os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
"..", "bin", "app", "data", "config"))
|
|
parser = argparse.ArgumentParser(
|
|
description="Check recipe tree consistency across the config files.")
|
|
parser.add_argument("--config-dir", default=default_dir,
|
|
help="directory containing the config toml files"
|
|
" (default: %(default)s)")
|
|
args = parser.parse_args()
|
|
|
|
recipes = load_toml(os.path.join(args.config_dir, "recipes.toml"))["recipe"]
|
|
ships = load_toml(os.path.join(args.config_dir, "ships.toml"))["ship"]
|
|
modules = load_toml(os.path.join(args.config_dir, "modules.toml"))["module"]
|
|
visuals = load_toml(os.path.join(args.config_dir, "visuals.toml"))
|
|
|
|
produced = {} # item id -> [producer descriptions]
|
|
consumed = {} # item id -> [consumer descriptions]
|
|
|
|
for recipe in recipes:
|
|
for output in recipe_outputs(recipe):
|
|
produced.setdefault(output["item"], []).append(
|
|
"recipe '{}'".format(recipe["id"]))
|
|
for inp in recipe.get("inputs", []):
|
|
consumed.setdefault(inp["item"], []).append(
|
|
"recipe '{}'".format(recipe["id"]))
|
|
|
|
for ship in ships:
|
|
for material in ship["schematic"]["materials"]:
|
|
consumed.setdefault(material["item"], []).append(
|
|
"ship '{}'".format(ship["id"]))
|
|
|
|
for module in modules:
|
|
for material in module["materials"]:
|
|
consumed.setdefault(material["item"], []).append(
|
|
"module '{}'".format(module["id"]))
|
|
|
|
all_items = set(produced) | set(consumed) | WORLD_SOURCED_ITEMS
|
|
visual_items = set(visuals.get("items", {}))
|
|
|
|
errors = []
|
|
warnings = []
|
|
|
|
for item in sorted(consumed):
|
|
if item not in produced and item not in WORLD_SOURCED_ITEMS:
|
|
errors.append("no producer for '{}' (consumed by {})".format(
|
|
item, ", ".join(sorted(set(consumed[item])))))
|
|
|
|
for item in sorted(all_items - visual_items):
|
|
errors.append("no [items.{}] entry in visuals.toml".format(item))
|
|
for item in sorted(visual_items - all_items):
|
|
warnings.append("visuals.toml entry [items.{}] matches no known item"
|
|
.format(item))
|
|
|
|
for item in sorted(produced):
|
|
if item not in consumed and item not in IMPLICITLY_CONSUMED_ITEMS:
|
|
warnings.append("'{}' is produced but never consumed (by {})"
|
|
.format(item, ", ".join(sorted(set(produced[item])))))
|
|
|
|
reprocessing_only = sorted(
|
|
item for item, producers in produced.items()
|
|
if all("reprocessing" in p for p in producers))
|
|
|
|
print("{} items, {} recipes, {} ships, {} modules".format(
|
|
len(all_items), len(recipes), len(ships), len(modules)))
|
|
print("obtainable only via reprocessing: {}".format(
|
|
", ".join(reprocessing_only) if reprocessing_only else "(none)"))
|
|
print()
|
|
|
|
for warning in warnings:
|
|
print("WARNING: {}".format(warning))
|
|
for error in errors:
|
|
print("ERROR: {}".format(error))
|
|
if not errors and not warnings:
|
|
print("all checks passed")
|
|
elif not errors:
|
|
print("no errors")
|
|
|
|
return 1 if errors else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|