133 lines
4.9 KiB
Python
133 lines
4.9 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 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())
|