diff --git a/tools/verify_layouts.py b/tools/verify_layouts.py new file mode 100644 index 0000000..6ec1e18 --- /dev/null +++ b/tools/verify_layouts.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Verify which module surface masks fit which ship layout grids. + +Reads ships.toml and modules.toml, then checks every module footprint +against every ship layout the same way the game does (all four mask +rotations, every placement position). Prints three reports: + + 1. Fit matrix — can the footprint be placed on the hull at all? + 2. Max simultaneous — how many disjoint copies of a footprint fit at + once (only computed for footprints of 4+ cells; + smaller ones would be slow and uninteresting). + 3. Cell counts — buildable cells per hull. + +Use it after editing layout grids or surface masks to confirm the +footprint-gating rules in docs/content_design.md still hold, e.g. that +no 2x2 area exists on s-class hulls or that the drone hangar fits the +carrier only. + +Usage (from the repository root or anywhere else): + + python dota_factory/tools/verify_layouts.py + python dota_factory/tools/verify_layouts.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. + +The script is informational only — it always exits 0. Read the matrix +and compare it against the intended gating in docs/content_design.md. +""" + +import argparse +import os + + +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 grid_cells(rows): + """Set of (x, y) for every 'O' cell in a list of layout/mask strings.""" + return {(x, y) + for y, row in enumerate(rows) + for x, ch in enumerate(row) + if ch == "O"} + + +def rotate_cw(shape): + height = max(y for x, y in shape) + 1 + return {(height - 1 - y, x) for x, y in shape} + + +def normalize(shape): + min_x = min(x for x, y in shape) + min_y = min(y for x, y in shape) + return frozenset((x - min_x, y - min_y) for x, y in shape) + + +def orientations(shape): + """All distinct 90-degree rotations of a shape, normalized to (0, 0).""" + result = [] + current = shape + for _ in range(4): + norm = normalize(current) + if norm not in result: + result.append(norm) + current = rotate_cw(current) + return result + + +def placements(layout, shape): + """Every position (in any rotation) where shape fits fully on layout.""" + found = [] + layout_w = max(x for x, y in layout) + 1 + layout_h = max(y for x, y in layout) + 1 + for orient in orientations(shape): + shape_w = max(x for x, y in orient) + 1 + shape_h = max(y for x, y in orient) + 1 + for off_x in range(layout_w - shape_w + 1): + for off_y in range(layout_h - shape_h + 1): + cells = frozenset((x + off_x, y + off_y) for x, y in orient) + if cells <= layout and cells not in found: + found.append(cells) + return found + + +def max_disjoint(layout, shape): + """Maximum number of non-overlapping placements of shape on layout.""" + options = placements(layout, shape) + best = [0] + + def recurse(start_index, used, count): + if count > best[0]: + best[0] = count + for i in range(start_index, len(options)): + if not (options[i] & used): + recurse(i + 1, used | options[i], count + 1) + + recurse(0, frozenset(), 0) + return best[0] + + +def shape_label(shape): + width = max(x for x, y in shape) + 1 + height = max(y for x, y in shape) + 1 + if len(shape) == width * height: + return "{}x{}".format(width, height) + return "{}x{}-{}c".format(width, height, len(shape)) + + +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 module surface masks against ship layout grids.") + parser.add_argument("--config-dir", default=default_dir, + help="directory containing ships.toml and modules.toml" + " (default: %(default)s)") + args = parser.parse_args() + + ships = load_toml(os.path.join(args.config_dir, "ships.toml"))["ship"] + modules = load_toml(os.path.join(args.config_dir, "modules.toml"))["module"] + + layouts = [(s["id"], grid_cells(s["layout"])) for s in ships] + + # Group modules that share the same footprint (up to rotation). + footprint_modules = {} # canonical shape -> [module ids] + for module in modules: + shape = grid_cells(module["surface_mask"]) + canonical = min(orientations(shape), key=sorted) + footprint_modules.setdefault(canonical, []).append(module["id"]) + footprints = sorted(footprint_modules.items(), key=lambda e: len(e[0])) + + column_header = "".join("{:>8}".format(ship_id[:7]) + for ship_id, _ in layouts) + + print("Fit matrix (YES = at least one placement exists)") + print("{:48}{}".format("footprint (modules)", column_header)) + for shape, module_ids in footprints: + name = "{:8}{}".format(shape_label(shape), ", ".join(module_ids)) + row = "".join("{:>8}".format("YES" if placements(layout, shape) else "-") + for _, layout in layouts) + print("{:48}{}".format(name[:47], row)) + + print() + print("Max simultaneous (disjoint) placements, footprints of 4+ cells") + print("{:48}{}".format("footprint (modules)", column_header)) + for shape, module_ids in footprints: + if len(shape) < 4: + continue + name = "{:8}{}".format(shape_label(shape), ", ".join(module_ids)) + row = "".join("{:>8}".format(max_disjoint(layout, shape)) + for _, layout in layouts) + print("{:48}{}".format(name[:47], row)) + + print() + print("Buildable cells per hull") + for ship_id, layout in layouts: + print("{:16}{}".format(ship_id, len(layout))) + + +if __name__ == "__main__": + main() diff --git a/tools/verify_recipes.py b/tools/verify_recipes.py new file mode 100644 index 0000000..fbeb821 --- /dev/null +++ b/tools/verify_recipes.py @@ -0,0 +1,132 @@ +#!/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())