sots-re/verify/design-rules/test_design_rules.py

397 lines
16 KiB
Python

"""Tests for design_rules.py.
python3 test_design_rules.py # unittest + printed ground-truth report
python3 test_design_rules.py -v
Ground truth = every design in the three real saves (stock_designs.json,
regenerated from the .sav files by stock_designs.py when missing) and the
scenario fleet templates (data/*_FleetTemplates.csv).
"""
from __future__ import annotations
import csv
import json
import sys
import unittest
from collections import Counter
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from design_rules import (Catalog, SLOTS, applied_techs, derive_stats, validate, # noqa: E402
weapon_fits_bank)
STOCK = HERE / "stock_designs.json"
def stock_players() -> list[dict]:
if not STOCK.exists():
import stock_designs
stock_designs.main([])
return json.loads(STOCK.read_text())
def errors(viols):
return [v for v in viols if v.level == "error"]
class TestCatalog(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.cat = Catalog.load()
def test_counts(self):
c = self.cat
self.assertEqual(sum(len(v) for v in c.sections.values()), 875)
self.assertEqual(len(c.weapons), 207)
self.assertEqual(len(c.techs), 293)
self.assertEqual(len(c.turrets), 42)
self.assertEqual(len(c.default_weapons), 31)
def test_lookup_case_insensitive(self):
c = self.cat
self.assertIsNotNone(c.section("human", "decommand"))
self.assertIsNotNone(c.section("Human", "Species/Human/sections/DEArmor.shipsection"))
self.assertIs(c.weapon("BAL_GAUSS"), c.weapon(8))
self.assertIs(c.weapon("Weapons/mis.weapon"), c.weapon(21))
self.assertTrue(c.tech_known("GRP_Torps", {"wep_phottrp"}))
self.assertFalse(c.tech_known("GRP_Torps", {"wep_redlas"}))
def test_race_gating(self):
c = self.cat
self.assertIn("tarkas", c.unobtainable["drv_node"])
self.assertNotIn("human", c.unobtainable["drv_node"])
self.assertIn("human", c.unobtainable["drv_hyper"])
def test_every_player_bank_has_a_fitting_player_weapon(self):
"""Catalog self-consistency: every bank of every player-race section can
take at least one player weapon under the fit rule."""
c = self.cat
player_weapons = [w for w in c.weapons.values() if w.scope == "player"]
unfit = []
for race, secs in c.sections.items():
if race == "_NPC":
continue
for sd in secs.values():
for b in sd.banks:
if b.fixed_weapon or b.size is None:
continue
if not any(weapon_fits_bank(c, b, w)[0] for w in player_weapons):
unfit.append((race, sd.stem, b.index, b.size, b.cls))
self.assertEqual(unfit, [])
def test_every_player_weapon_fits_some_bank(self):
c = self.cat
banks = [b for race, secs in c.sections.items() if race != "_NPC"
for sd in secs.values() for b in sd.banks if b.size]
orphans = []
for w in c.weapons.values():
if w.scope != "player":
continue
if not any(weapon_fits_bank(c, b, w)[0] for b in banks):
orphans.append((w.stem, w.size, w.cls))
self.assertEqual(orphans, [])
class TestRules(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.cat = Catalog.load()
def armor(self, **over):
d = {
"name": "Armor", "race": "Human",
"command": {"section": "DECommand", "weapons": ["bal_gauss"]},
"mission": {"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"]},
"engine": {"section": "DEFission", "weapons": ["bal_gauss"]},
"known_techs": ["DRV_Fissn", "DRV_Node", "WEP_GsDrvr"],
}
d.update(over)
return d
def rules(self, design):
return sorted({v.rule for v in errors(validate(design, self.cat))})
def test_valid(self):
self.assertEqual(validate(self.armor(), self.cat), [])
def test_A2_missing_mission(self):
self.assertEqual(self.rules(self.armor(mission=None)), ["A2"])
def test_A3_hull_needs_command_and_engine(self):
self.assertEqual(self.rules(self.armor(engine=None)), ["A3"])
def test_A3_standalone_cannot_take_partners(self):
d = self.armor(mission={"section": "DEDefencePlatform",
"weapons": ["bal_gauss"] * 4 + ["mis"]})
self.assertEqual(self.rules(d), ["A3"])
d.update(command=None, engine=None)
self.assertEqual(self.rules(d), [])
def test_A4_wrong_slot(self):
d = self.armor(command={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"]})
self.assertEqual(self.rules(d), ["A4"])
def test_A5_wrong_race(self):
self.assertEqual(self.rules(self.armor(race="Klingon", known_techs=None)), ["A5"])
# DENodeMissile exists only in the Human catalog
d = self.armor(race="Zuul", known_techs=None,
mission={"section": "DENodeMissile", "weapons": ["mis_node"]})
self.assertEqual(self.rules(d), ["A5"])
def test_A6_class_mix(self):
d = self.armor(engine={"section": "CRFission", "weapons": ["bal_gauss"] * 2}, known_techs=None)
# CRFission has 2 banks in the Human catalog? use whatever the catalog says
sd = self.cat.section("Human", "CRFission")
d["engine"]["weapons"] = ["bal_gauss"] * len(sd.banks)
self.assertIn("A6", self.rules(d))
def test_A7_exclude(self):
cat = self.cat
cmd = cat.section("Hiver", "DERamScoop")
eng = cat.section("Hiver", "DEFission")
mis = cat.section("Hiver", "DEArmor")
def fill(sd):
return {"section": sd.stem, "weapons": [None] * len(sd.banks)}
d = {"race": "Hiver", "command": fill(cmd), "mission": fill(mis), "engine": fill(eng)}
self.assertIn("A7", self.rules(d))
def test_A8_station_partners(self):
cat = self.cat
st = cat.section("Human", "DNStationCommand")
fore = cat.section("Human", "DNStationCommand_Fore")
aft = cat.section("Human", "DNStationCommand_Aft")
def fill(sd):
return {"section": sd.stem, "weapons": ["bal_gauss"] * len(sd.banks)}
d = {"race": "Human", "command": fill(fore), "mission": fill(st), "engine": fill(aft)}
self.assertNotIn("A8", self.rules(d))
d["command"] = fill(cat.section("Human", "DNCommand"))
self.assertIn("A8", self.rules(d))
def test_B1_bank_count(self):
d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss"]})
self.assertEqual(self.rules(d), ["B1", "B2"])
def test_B4_fit(self):
# large-only torpedo into a small standard bank
d = self.armor(command={"section": "DECommand", "weapons": ["trp_photon"]}, known_techs=None)
self.assertEqual(self.rules(d), ["B4"])
# PD (tiny) fits small, not medium
d = self.armor(mission={"section": "DEArmor", "weapons": ["las_pd", "las_pd", "las_pd"]}, known_techs=None)
self.assertEqual([v.bank for v in errors(validate(d, self.cat))], [2])
def test_B4_missile_in_standard(self):
d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"]})
self.assertEqual(self.rules(d), [])
d = self.armor(mission={"section": "DEArmor", "weapons": ["mis", "bal_gauss", "mis"]})
self.assertEqual(self.rules(d), ["B4"]) # medium missile does not fit a small bank
def test_B6_exclusive_species(self):
d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "bal_grapple"]}, known_techs=None)
self.assertEqual(self.rules(d), ["B6"])
def test_C_tech_gating(self):
self.assertEqual(self.rules(self.armor(known_techs=["DRV_Fissn", "DRV_Node"])), ["C2"])
self.assertEqual(self.rules(self.armor(known_techs=["WEP_GsDrvr", "DRV_Node"])), ["C1"])
d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"],
"options": ["IND_PlyAlloy"]})
self.assertEqual(self.rules(d), ["C3"])
def test_C4_hull_class_tech(self):
cat = self.cat
def fill(stem):
sd = cat.section("Human", stem)
return {"section": sd.stem, "weapons": ["bal_gauss"] * len(sd.banks)}
d = {"race": "Human", "command": fill("CRCommand"), "mission": fill("CRArmor"), "engine": fill("CRFission"),
"known_techs": ["DRV_Fissn", "DRV_Node", "WEP_GsDrvr"]}
self.assertEqual(self.rules(d), ["C4"])
d["known_techs"].append("IND_CruisCon")
self.assertEqual(self.rules(d), [])
def test_C5_race_can_never(self):
cat = self.cat
sd = cat.section("Tarkas", "DEFission") # requires DRV_Hyper: fine for Tarkas
self.assertTrue(all(v.rule != "C5" for v in validate(
{"race": "Tarkas", "command": {"section": "DECommand", "weapons": ["bal_gauss"] * 1},
"mission": {"section": "DEArmor", "weapons": ["bal_gauss", "mis"]},
"engine": {"section": sd.stem, "weapons": ["bal_gauss"] * len(sd.banks)}}, cat)))
def test_D1_options(self):
d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"],
"options": ["IND_PlyAlloy", "IND_MagLat"]},
known_techs=["DRV_Fissn", "DRV_Node", "WEP_GsDrvr", "IND_PlyAlloy", "IND_MagLat", "SLD_MkOne"])
self.assertEqual(self.rules(d), ["D1"])
d["mission"]["options"] = ["SLD_MkOne"]
self.assertEqual(self.rules(d), ["D1"])
d["mission"]["options"] = ["IND_MagLat"]
self.assertEqual(self.rules(d), [])
def test_applied_techs_order(self):
cat = self.cat
sd = cat.section("_NPC", "_DEEngine")
self.assertEqual(applied_techs({"options": ["IND_RefCoat", "DRV_RecFiss", "IND_PlyAlloy"]}, sd, cat),
["DRV_Fissn", "DRV_Node", "IND_PlyAlloy", "IND_RefCoat", "DRV_RecFiss"])
sd = cat.section("_NPC", "_CRRipperCommand")
self.assertEqual(applied_techs({"options": ["IND_MagLat", "IND_RefCoat"]}, sd, cat),
["IND_CruisCon", "IND_MagLat", "IND_RefCoat"])
sd = cat.section("_NPC", "_AsteroidMonitor") # station: no hull-class tech
self.assertEqual(applied_techs({"options": ["IND_QrkRes"]}, sd, cat), ["IND_QrkRes"])
def test_derive_stats(self):
st = derive_stats(self.armor(), self.cat)
self.assertEqual(st["mass"], 800 + 2000 + 2500)
self.assertEqual(st["section_cost"], 2000 + 4000 + 5000)
self.assertEqual(st["turrets"], 1 + 3 + 3 + 1 + 1)
self.assertEqual(st["weapon_cost_per_bank"], 50 * 4 + 3000)
self.assertEqual(st["ftlspeed"], 0.2)
self.assertEqual(st["applied_techs"]["engine"], ["DRV_Fissn", "DRV_Node"])
class TestStockDesigns(unittest.TestCase):
"""Every design in the real saves must validate (structure + weapons +
options + tech gating against that player's researched techs) and its
DOpts must equal applied_techs()."""
@classmethod
def setUpClass(cls):
cls.cat = Catalog.load()
cls.players = stock_players()
def all_designs(self):
for p in self.players:
for d in p["designs"]:
yield p, d
def test_have_designs(self):
n = sum(1 for _ in self.all_designs())
self.assertGreater(n, 30)
def test_no_mixed_species(self):
for p, d in self.all_designs():
self.assertNotIn("mixed_species", d, f"{p['save']} {d['name']}")
def test_player_designs_validate(self):
failures = []
for p, d in self.all_designs():
if p["npc"]:
continue
errs = errors(validate(d, self.cat))
if errs:
failures.append((p["save"], p["player"], d["name"], [str(e) for e in errs]))
self.assertEqual(failures, [])
def test_hidden_default_rider_designs_bypass_tech_gating(self):
"""Code rule the data does not show: the engine creates the hidden
'Default Assault Shuttle' design for every race at game start even
when the rider section's `requires` is unresearched (Tarkas
_AssaultShuttle requires DRN_AdvFrm)."""
seen = 0
for p, d in self.all_designs():
if d["name"] != "Default Assault Shuttle" or d["race"] != "Tarkas":
continue
seen += 1
viols = validate(d, self.cat)
self.assertTrue(d["hidden"])
self.assertEqual(errors(viols), [])
self.assertIn("C1", {v.rule for v in viols if v.level == "warn"})
self.assertGreaterEqual(seen, 3)
def test_npc_designs_validate_structurally(self):
failures = []
for p, d in self.all_designs():
if not p["npc"]:
continue
dd = dict(d)
dd["known_techs"] = None if p["n_techs"] == 0 else p["researched"]
errs = errors(validate(dd, self.cat))
if errs:
failures.append((p["save"], p["player"], d["name"], [str(e) for e in errs]))
self.assertEqual(failures, [])
def test_dopts_equals_applied_techs(self):
mism = []
for p, d in self.all_designs():
for slot in SLOTS:
use = d.get(slot)
if not use:
continue
sd = self.cat.section(d["race"], use["section"])
got = applied_techs(use, sd, self.cat)
want = use.get("save_opts", [])
if [g.lower() for g in got] != [w.lower() for w in want]:
mism.append((p["save"], d["name"], slot, want, got))
self.assertEqual(mism, [])
def test_bank_count_matches_save(self):
for p, d in self.all_designs():
for slot, raw in zip(SLOTS, d["raw_slots"]):
use = d.get(slot)
if not use:
continue
sd = self.cat.section(d["race"], use["section"])
self.assertEqual(raw["bank_count"], len(sd.banks), f"{d['name']} {slot} {sd.stem}")
class TestScenarioTemplates(unittest.TestCase):
"""Scenario fleet templates are (command, mission, engine) stems; check the
structural rules for every race that carries all three sections."""
@classmethod
def setUpClass(cls):
cls.cat = Catalog.load()
def templates(self):
for f in sorted((HERE / "data").glob("*FleetTemplates.csv")):
for row in csv.reader(l for l in f.read_text().splitlines() if l.strip() and not l.startswith("#")):
if len(row) >= 4:
yield f.name, row[0].strip(), row[1].strip(), row[2].strip(), row[3].strip()
def test_templates_structurally_valid(self):
cat = self.cat
checked = 0
failures = []
for fname, fleet, c, m, e in self.templates():
for race in cat.sections:
if race == "_NPC":
continue
sds = [cat.section(race, x) for x in (c, m, e)]
if any(s is None for s in sds):
continue
d = {"race": race, "hidden": True}
for slot, sd in zip(SLOTS, sds):
d[slot] = {"section": sd.stem, "weapons": [None] * len(sd.banks)}
errs = [v for v in validate(d, cat) if v.level == "error" and v.rule not in ("B2",)]
checked += 1
if errs:
failures.append((fname, fleet, race, c, m, e, [str(x) for x in errs]))
self.assertGreater(checked, 100)
self.assertEqual(failures, [])
def report():
cat = Catalog.load()
players = stock_players()
print("\n=== ground truth: designs in real saves ===")
tot = Counter()
for p in players:
for d in p["designs"]:
viols = validate(d, cat)
errs = errors(viols)
tot["designs"] += 1
tot["with_errors"] += bool(errs)
secs = " + ".join(f"{d[s]['section']}" for s in SLOTS if d.get(s))
flag = "FAIL" if errs else "ok "
print(f"{flag} {p['save']:16} {p['player'][:18]:18} {d['race']:7} {d['name'][:24]:24} {secs}")
for v in viols:
print(f" {v}")
print(f"{tot['designs']} designs, {tot['with_errors']} with error-level violations")
if __name__ == "__main__":
if "--report" in sys.argv:
sys.argv.remove("--report")
report()
unittest.main()