#!/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 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.get("outputs", []): 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())