add python script to verify module constraints against hulls
This commit is contained in:
@@ -110,7 +110,10 @@ Lower decks hold supports and 2x2 point-defense m guns.
|
|||||||
### Verified gating matrix
|
### Verified gating matrix
|
||||||
|
|
||||||
Checked programmatically against the configs (all four mask rotations,
|
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 |
|
| Footprint | drone | frigate | destroyer | cruiser | battlecruiser | battleship | dreadnought | carrier |
|
||||||
|-----------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|
|-----------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|
||||||
|
|||||||
170
tools/verify_layouts.py
Normal file
170
tools/verify_layouts.py
Normal 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()
|
||||||
Reference in New Issue
Block a user