From dbf334c829bac3caf88b378aac8f10d0567006d1 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Fri, 12 Jun 2026 18:20:40 +0200 Subject: [PATCH] add python script to verify module constraints against hulls --- docs/content_design.md | 5 +- tools/verify_layouts.py | 170 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 tools/verify_layouts.py diff --git a/docs/content_design.md b/docs/content_design.md index c75b287..c8c3639 100644 --- a/docs/content_design.md +++ b/docs/content_design.md @@ -110,7 +110,10 @@ Lower decks hold supports and 2x2 point-defense m guns. ### Verified gating matrix Checked programmatically against the configs (all four mask rotations, -all placements): +all placements) with `tools/verify_layouts.py` — re-run it after editing +layout grids or surface masks: + + python dota_factory/tools/verify_layouts.py | Footprint | drone | frigate | destroyer | cruiser | battlecruiser | battleship | dreadnought | carrier | |-----------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:| 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()