add verification scripts for ship layouts and recipes

This commit is contained in:
2026-06-14 14:24:18 +02:00
parent 997a7778e0
commit 0a1b58442c
2 changed files with 302 additions and 0 deletions

170
tools/verify_layouts.py Normal file
View File

@@ -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()

132
tools/verify_recipes.py Normal file
View File

@@ -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())