397 lines
16 KiB
Python
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()
|