757 lines
33 KiB
Python
757 lines
33 KiB
Python
"""design_rules.py -- SOTS1 (2006) ship-design assembly rules, derived from data.
|
|
|
|
Stdlib only. Loads the normalized catalogs written by verify/parsers/verify.py
|
|
(shipsections.json, weapons.json, tech_tree.json) plus the two positional
|
|
tables that live next to this file (data/_turrets.txt, data/_defaultweapons.txt)
|
|
and exposes:
|
|
|
|
cat = Catalog.load() # default paths, cached
|
|
validate(design, cat=None) -> [Violation]
|
|
derive_stats(design, cat=None) -> dict
|
|
applied_techs(section_use, sdef, cat) # == the save's DOpts list
|
|
design_from_save(species_idx, slots, ..) # save-reader record -> Design
|
|
|
|
Design (plain dict; every key optional except race/mission):
|
|
|
|
{
|
|
"name": "Armor",
|
|
"race": "Human", # catalog species (Human/Hiver/Tarkas/Liir/Zuul/Morrigi/_NPC)
|
|
"hidden": False, # engine-generated hidden design (rider defaults)
|
|
"command": {"section": "DECommand", "weapons": ["bal_gauss"], "options": []},
|
|
"mission": {"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"], "options": []},
|
|
"engine": {"section": "DEFission", "weapons": ["bal_gauss"], "options": []},
|
|
"known_techs": ["DRV_Fissn", ...], # omit/None -> tech gating is skipped
|
|
}
|
|
|
|
`weapons` holds one entry per `bank{}` block of the section, in file order; an
|
|
entry is a weapon stem ("bal_gauss"), a weapon file path, an int id from
|
|
_weapons.txt, or None (empty). `options` lists the chosen option techs.
|
|
|
|
Rule ids (see SHIP_DESIGN_RULES.md for the evidence behind each):
|
|
A* structure/slots, B* banks & weapon fit, C* tech gating, D* options,
|
|
E* derived stats. Violation.level is "error" (design is invalid),
|
|
"warn" (valid for the engine but not something the designer UI offers /
|
|
something only code can settle) or "info".
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import json
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
__all__ = [
|
|
"Catalog", "Violation", "validate", "derive_stats", "applied_techs",
|
|
"design_from_save", "weapon_fits_bank", "SPECIES_INDEX", "SLOTS",
|
|
"HULL_CLASS_TECH", "BANK_CLASS_ACCEPTS",
|
|
]
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
DEFAULT_CATALOG_DIR = HERE.parent / "results" / "data-catalogs"
|
|
DEFAULT_DATA_DIR = HERE / "data"
|
|
|
|
# Order of the species table in a real save (sim.species ISsp list); the
|
|
# design record's DSec[0] is an index into it.
|
|
SPECIES_INDEX = ["Human", "Hiver", "Tarkas", "Liir", "_NPC", "Zuul", "Morrigi"]
|
|
|
|
SLOTS = ("command", "mission", "engine")
|
|
|
|
# Hull-size techs implied by section_class (evidence: the save's DOpts list
|
|
# carries them for every cruiser/dreadnought section that is not a station).
|
|
HULL_CLASS_TECH = {"cruiser": "IND_CruisCon", "dreadnought": "IND_DreadCon"}
|
|
|
|
# Which weapon turretclass a bank turretclass accepts besides an exact match.
|
|
# standard <- missile : stock designs put `mis` (medium/missile) in the
|
|
# medium/standard bank of DEArmor and DEDefencePlatform
|
|
# standard <- grapple : bal_grapple/bal_disruptorwhip are turretclass grapple
|
|
# but no section has a grapple bank; _turrets.txt has
|
|
# grapple rows for medium/large only
|
|
# strafe <- standard: no weapon has turretclass strafe; the NPC
|
|
# _HeraldDefender strafe bank carries a standard-class
|
|
# beamer; _turrets.txt strafe rows mirror standard rows
|
|
BANK_CLASS_ACCEPTS = {
|
|
"standard": {"standard", "missile", "grapple"},
|
|
"strafe": {"standard"},
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# helpers
|
|
|
|
def _last(v):
|
|
"""Scalar from a catalog value; repeated keys were folded into lists."""
|
|
if isinstance(v, list):
|
|
return v[-1] if v else None
|
|
return v
|
|
|
|
|
|
def _list(v) -> list:
|
|
if v is None:
|
|
return []
|
|
return v if isinstance(v, list) else [v]
|
|
|
|
|
|
def _low(v) -> str | None:
|
|
return None if v is None else str(v).lower()
|
|
|
|
|
|
def _num(v, default=0):
|
|
v = _last(v)
|
|
if isinstance(v, bool):
|
|
return default
|
|
return v if isinstance(v, (int, float)) else default
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# catalog model
|
|
|
|
@dataclass
|
|
class BankDef:
|
|
index: int
|
|
size: str | None # small/medium/large (lower)
|
|
cls: str | None # standard/missile/... (lower)
|
|
mounts: int
|
|
fixed_weapon: str | None # NPC banks: weapon file bound in data
|
|
showturrets: bool = True
|
|
|
|
|
|
@dataclass
|
|
class OptionGroup:
|
|
techs: tuple # mutually exclusive choices, file order
|
|
kind: str = "option" # "option" | "optiondef"
|
|
|
|
|
|
@dataclass
|
|
class SectionDef:
|
|
race: str
|
|
stem: str
|
|
file: str
|
|
id: int | None
|
|
type: str | None # command/mission/engine (lower) or None
|
|
cls: str | None # destroyer/cruiser/dreadnought (lower) or None
|
|
socket_fore: str | None
|
|
socket_aft: str | None
|
|
requires: list
|
|
option_groups: list
|
|
banks: list
|
|
raw: dict
|
|
nodesign: bool = False
|
|
explicit_section: bool = False
|
|
explicit_command_section: str | None = None
|
|
explicit_engine_section: str | None = None
|
|
exclude: list = field(default_factory=list)
|
|
design_class: str | None = None
|
|
entity_class: str | None = None
|
|
|
|
@property
|
|
def has_sockets(self) -> bool:
|
|
return self.socket_fore is not None or self.socket_aft is not None
|
|
|
|
@property
|
|
def is_station(self) -> bool:
|
|
return _low(self.design_class) == "station"
|
|
|
|
def hull_class_tech(self) -> str | None:
|
|
if self.is_station:
|
|
return None
|
|
return HULL_CLASS_TECH.get(self.cls or "")
|
|
|
|
def stat(self, key, default=None):
|
|
return _last(self.raw.get(key, default))
|
|
|
|
|
|
@dataclass
|
|
class WeaponDef:
|
|
stem: str
|
|
file: str
|
|
scope: str # player / NPC
|
|
id: int | None
|
|
size: str | None
|
|
cls: str | None
|
|
weaponclass: str | None
|
|
requires: list
|
|
cost: float
|
|
exclusive_species: str | None
|
|
compatible_section: list
|
|
hidden: bool
|
|
raw: dict
|
|
|
|
|
|
@dataclass
|
|
class TurretRow:
|
|
size: str
|
|
weapon_size: str
|
|
cls: str
|
|
health: float
|
|
track_speed: float
|
|
az: float
|
|
inc: float
|
|
model: str
|
|
|
|
|
|
@dataclass
|
|
class Violation:
|
|
rule: str
|
|
level: str
|
|
msg: str
|
|
slot: str | None = None
|
|
bank: int | None = None
|
|
|
|
def __str__(self):
|
|
where = f" [{self.slot}{'' if self.bank is None else ' bank ' + str(self.bank)}]" if self.slot else ""
|
|
return f"{self.level.upper():5} {self.rule}{where}: {self.msg}"
|
|
|
|
|
|
class Catalog:
|
|
_cache: dict = {}
|
|
|
|
def __init__(self, sections_json, weapons_json, tech_json, turrets_txt, defaults_txt):
|
|
self.sections: dict[str, dict[str, SectionDef]] = {} # race -> stem.lower -> def
|
|
self.sections_by_id: dict[str, dict[int, SectionDef]] = {}
|
|
self.weapons: dict[str, WeaponDef] = {} # stem.lower -> def
|
|
self.weapons_by_file: dict[str, WeaponDef] = {}
|
|
self.weapons_by_id: dict[int, WeaponDef] = {}
|
|
self.techs: dict[str, dict] = {} # name.lower -> node
|
|
self.tech_names: dict[str, str] = {} # name.lower -> canonical
|
|
self.groups: dict[str, set] = {} # group.lower -> {tech.lower}
|
|
self.unobtainable: dict[str, set] = {} # tech.lower -> {race.lower}
|
|
self.turrets: dict[tuple, TurretRow] = {} # (size, wsize, cls) -> row
|
|
self.default_weapons: dict[tuple, str] = {} # (cls, size) -> weapon file
|
|
self._load_sections(sections_json)
|
|
self._load_weapons(weapons_json)
|
|
self._load_techs(tech_json)
|
|
self._load_turrets(turrets_txt)
|
|
self._load_defaults(defaults_txt)
|
|
|
|
# -- loading ------------------------------------------------------------
|
|
@classmethod
|
|
def load(cls, catalog_dir=None, data_dir=None) -> "Catalog":
|
|
catalog_dir = Path(catalog_dir or os.environ.get("SOTS_CATALOG_DIR") or DEFAULT_CATALOG_DIR)
|
|
data_dir = Path(data_dir or DEFAULT_DATA_DIR)
|
|
key = (str(catalog_dir), str(data_dir))
|
|
if key not in cls._cache:
|
|
cls._cache[key] = cls(
|
|
catalog_dir / "shipsections.json", catalog_dir / "weapons.json",
|
|
catalog_dir / "tech_tree.json", data_dir / "_turrets.txt",
|
|
data_dir / "_defaultweapons.txt")
|
|
return cls._cache[key]
|
|
|
|
def _load_sections(self, path):
|
|
data = json.load(open(path, encoding="utf-8"))
|
|
for s in data["sections"]:
|
|
groups = []
|
|
for o in _list(s.get("option")):
|
|
if isinstance(o, dict):
|
|
groups.append(OptionGroup(tuple(str(t) for t in _list(o.get("option")))))
|
|
else:
|
|
groups.append(OptionGroup((str(o),)))
|
|
for o in _list(s.get("optiondef")):
|
|
if isinstance(o, dict):
|
|
groups.append(OptionGroup(tuple(str(t) for t in _list(o.get("option"))), "optiondef"))
|
|
banks = []
|
|
for i, b in enumerate(_list(s.get("bank"))):
|
|
if not isinstance(b, dict):
|
|
continue
|
|
st = _last(b.get("showturrets"))
|
|
banks.append(BankDef(
|
|
index=i, size=_low(_last(b.get("turretsize"))), cls=_low(_last(b.get("turretclass"))),
|
|
mounts=len(_list(b.get("mount"))), fixed_weapon=_last(b.get("weapon")),
|
|
showturrets=(st is None or bool(st))))
|
|
sd = SectionDef(
|
|
race=s["race"], stem=s["stem"], file=s["file"], id=s.get("id"),
|
|
type=_low(_last(s.get("section_type"))), cls=_low(_last(s.get("section_class"))),
|
|
socket_fore=_last(s.get("socket_fore")), socket_aft=_last(s.get("socket_aft")),
|
|
requires=[str(r) for r in _list(s.get("requires"))], option_groups=groups, banks=banks, raw=s,
|
|
nodesign=bool(_last(s.get("nodesign", False))),
|
|
explicit_section=bool(_last(s.get("explicit_section", False))),
|
|
explicit_command_section=_last(s.get("explicit_command_section")),
|
|
explicit_engine_section=_last(s.get("explicit_engine_section")),
|
|
exclude=[str(x) for x in _list(s.get("exclude"))],
|
|
design_class=_last(s.get("design_class")), entity_class=_last(s.get("entity_class")))
|
|
self.sections.setdefault(sd.race, {})[sd.stem.lower()] = sd
|
|
if sd.id is not None:
|
|
self.sections_by_id.setdefault(sd.race, {})[int(sd.id)] = sd
|
|
|
|
def _load_weapons(self, path):
|
|
data = json.load(open(path, encoding="utf-8"))
|
|
for w in data["weapons"]:
|
|
wd = WeaponDef(
|
|
stem=w["stem"], file=w["file"], scope=w.get("scope", "player"), id=w.get("id"),
|
|
size=_low(_last(w.get("turretsize"))), cls=_low(_last(w.get("turretclass"))),
|
|
weaponclass=_low(_last(w.get("weaponclass"))),
|
|
requires=[str(r) for r in _list(w.get("requires"))], cost=_num(w.get("cost"), 0),
|
|
exclusive_species=_low(_last(w.get("exclusive_species"))),
|
|
compatible_section=[str(c) for c in _list(w.get("compatible_section"))],
|
|
hidden=bool(_last(w.get("hidden", False))), raw=w)
|
|
self.weapons[wd.stem.lower()] = wd
|
|
self.weapons_by_file[wd.file.lower()] = wd
|
|
self.weapons_by_file[os.path.basename(wd.file).lower()] = wd
|
|
if wd.id is not None:
|
|
self.weapons_by_id[int(wd.id)] = wd
|
|
|
|
def _load_techs(self, path):
|
|
data = json.load(open(path, encoding="utf-8"))
|
|
self.races = list(data.get("races", []))
|
|
for n in data["nodes"]:
|
|
self.techs[n["name"].lower()] = n
|
|
self.tech_names[n["name"].lower()] = n["name"]
|
|
if n.get("group"):
|
|
self.groups.setdefault(str(n["group"]).lower(), set()).add(n["name"].lower())
|
|
for g, members in data.get("groups", {}).items():
|
|
self.groups.setdefault(g.lower(), set()).update(m.lower() for m in members)
|
|
# race availability: a tech is unobtainable for a race when every edge
|
|
# into it writes 0% for that race (absent race -> engine default,
|
|
# believed 100%).
|
|
incoming: dict[str, list] = {}
|
|
for e in data["edges"]:
|
|
incoming.setdefault(e["to"].lower(), []).append(e)
|
|
for t, edges in incoming.items():
|
|
for race in self.races:
|
|
if all(e.get("pct", {}).get(race) == 0 for e in edges):
|
|
self.unobtainable.setdefault(t, set()).add(race.lower())
|
|
|
|
def _load_turrets(self, path):
|
|
for row in _rows(path):
|
|
if len(row) < 8:
|
|
continue
|
|
size, wsize, cls = (str(row[0]).lower(), str(row[1]).lower(), str(row[2]).lower())
|
|
self.turrets[(size, wsize, cls)] = TurretRow(
|
|
size, wsize, cls, float(row[3]), float(row[4]), float(row[5]), float(row[6]), str(row[7]))
|
|
|
|
def _load_defaults(self, path):
|
|
if not Path(path).exists():
|
|
return
|
|
for row in _rows(path):
|
|
if len(row) >= 3:
|
|
self.default_weapons[(str(row[0]).lower(), str(row[1]).lower())] = str(row[2])
|
|
|
|
# -- lookups --------------------------------------------------------------
|
|
def race_key(self, race: str) -> str | None:
|
|
for r in self.sections:
|
|
if r.lower() == str(race).lower():
|
|
return r
|
|
return None
|
|
|
|
def section(self, race: str, ref) -> SectionDef | None:
|
|
r = self.race_key(race)
|
|
if r is None or ref is None:
|
|
return None
|
|
if isinstance(ref, int):
|
|
return self.sections_by_id.get(r, {}).get(ref)
|
|
ref = str(ref)
|
|
stem = os.path.basename(ref)
|
|
stem = re.sub(r"\.shipsection$", "", stem, flags=re.I)
|
|
return self.sections[r].get(stem.lower())
|
|
|
|
def weapon(self, ref) -> WeaponDef | None:
|
|
if ref is None:
|
|
return None
|
|
if isinstance(ref, int):
|
|
return self.weapons_by_id.get(ref)
|
|
ref = str(ref)
|
|
low = ref.lower()
|
|
if low in self.weapons:
|
|
return self.weapons[low]
|
|
if low in self.weapons_by_file:
|
|
return self.weapons_by_file[low]
|
|
stem = re.sub(r"\.weapon$", "", os.path.basename(low))
|
|
return self.weapons.get(stem) or self.weapons_by_file.get(stem + ".weapon")
|
|
|
|
def tech(self, name: str) -> dict | None:
|
|
return self.techs.get(str(name).lower())
|
|
|
|
def tech_known(self, name: str, known: set) -> bool:
|
|
"""`known` is a set of lower-cased tech names. GRP_<x> is satisfied by
|
|
any known tech of group <x>."""
|
|
low = str(name).lower()
|
|
if low.startswith("grp_"):
|
|
return bool(self.groups.get(low[4:], set()) & known)
|
|
return low in known
|
|
|
|
|
|
def _rows(path) -> list[list[str]]:
|
|
"""Whitespace-positional rows with // comments and quoted tokens."""
|
|
out = []
|
|
tok = re.compile(r'"([^"]*)"|(\S+)')
|
|
for raw in Path(path).read_bytes().decode("cp1252").splitlines():
|
|
line = _strip_comment(raw).strip()
|
|
if not line:
|
|
continue
|
|
out.append([m.group(1) if m.group(1) is not None else m.group(2) for m in tok.finditer(line)])
|
|
return out
|
|
|
|
|
|
def _strip_comment(line: str) -> str:
|
|
in_q = False
|
|
for i, c in enumerate(line):
|
|
if c == '"':
|
|
in_q = not in_q
|
|
elif c == "/" and not in_q and line.startswith("//", i):
|
|
return line[:i]
|
|
return line
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# turret fit
|
|
|
|
def turret_row(cat: Catalog, bank: BankDef, weapon: WeaponDef) -> TurretRow | None:
|
|
"""The _turrets.txt row a weapon in a bank would use: keyed by the weapon's
|
|
class first (missile-in-standard picks the missile turret model), then by
|
|
the bank's class (strafe banks have their own rows, no weapon is class
|
|
strafe)."""
|
|
for cls in (weapon.cls, bank.cls):
|
|
r = cat.turrets.get((bank.size, weapon.size, cls))
|
|
if r:
|
|
return r
|
|
return None
|
|
|
|
|
|
def weapon_fits_bank(cat: Catalog, bank: BankDef, weapon: WeaponDef) -> tuple[bool, str]:
|
|
if bank.size is None or bank.cls is None:
|
|
return (weapon.file.lower() == str(bank.fixed_weapon or "").lower(),
|
|
"bank has no turretsize/turretclass (fixed NPC weapon only)")
|
|
if not (weapon.cls == bank.cls or weapon.cls in BANK_CLASS_ACCEPTS.get(bank.cls, ())):
|
|
return False, f"turretclass {weapon.cls} not accepted by {bank.cls} bank"
|
|
if turret_row(cat, bank, weapon) is None:
|
|
return False, f"no _turrets.txt row for ({bank.size} bank, {weapon.size} weapon, {weapon.cls}/{bank.cls})"
|
|
return True, "ok"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# design access
|
|
|
|
def _slot(design: dict, slot: str) -> dict | None:
|
|
v = design.get(slot)
|
|
if v is None:
|
|
return None
|
|
if isinstance(v, str):
|
|
return {"section": v}
|
|
return v
|
|
|
|
|
|
def _known_set(design: dict) -> set | None:
|
|
k = design.get("known_techs")
|
|
if k is None:
|
|
return None
|
|
return {str(t).lower() for t in k}
|
|
|
|
|
|
def applied_techs(use: dict, sdef: SectionDef, cat: Catalog | None = None) -> list[str]:
|
|
"""The techs a section instance is built with -- exactly what the save
|
|
stores per section in DOpts: hull-class tech (cruiser/dreadnought, not
|
|
stations), then `requires` in file order, then the chosen option techs in
|
|
option-group order."""
|
|
out = []
|
|
hct = sdef.hull_class_tech()
|
|
if hct:
|
|
out.append(hct)
|
|
out.extend(sdef.requires)
|
|
chosen = {str(o).lower(): str(o) for o in _list((use or {}).get("options"))}
|
|
for g in sdef.option_groups:
|
|
for t in g.techs:
|
|
if t.lower() in chosen:
|
|
out.append(chosen[t.lower()])
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# validation
|
|
|
|
def validate(design: dict, cat: Catalog | None = None) -> list[Violation]:
|
|
cat = cat or Catalog.load()
|
|
v: list[Violation] = []
|
|
race = design.get("race")
|
|
hidden = bool(design.get("hidden"))
|
|
known = _known_set(design)
|
|
rk = cat.race_key(race) if race else None
|
|
if rk is None:
|
|
v.append(Violation("A5", "error", f"unknown species catalog {race!r}"))
|
|
return v
|
|
|
|
# -- resolve sections -----------------------------------------------------
|
|
uses: dict[str, dict | None] = {s: _slot(design, s) for s in SLOTS}
|
|
defs: dict[str, SectionDef | None] = {}
|
|
for slot in SLOTS:
|
|
use = uses[slot]
|
|
if use is None:
|
|
defs[slot] = None
|
|
continue
|
|
sd = cat.section(rk, use.get("section"))
|
|
if sd is None:
|
|
v.append(Violation("A5", "error", f"section {use.get('section')!r} not in {rk} catalog", slot))
|
|
defs[slot] = sd
|
|
if defs["mission"] is None and uses["mission"] is not None:
|
|
return v
|
|
m = defs["mission"]
|
|
if m is None:
|
|
v.append(Violation("A2", "error", "mission slot is empty"))
|
|
return v
|
|
|
|
# A3 hull ship vs standalone
|
|
standalone = not m.has_sockets
|
|
if m.explicit_command_section or m.explicit_engine_section:
|
|
for slot, want in (("command", m.explicit_command_section), ("engine", m.explicit_engine_section)):
|
|
got = defs[slot]
|
|
if want and (got is None or got.stem.lower() != want.lower()):
|
|
v.append(Violation("A8", "error", f"{m.stem} requires {slot} section {want}, got {got.stem if got else None}", slot))
|
|
elif standalone:
|
|
for slot in ("command", "engine"):
|
|
if uses[slot] is not None:
|
|
v.append(Violation("A3", "error", f"{m.stem} has no sockets (standalone hull) but {slot} slot is filled", slot))
|
|
else:
|
|
for slot in ("command", "engine"):
|
|
if defs[slot] is None:
|
|
v.append(Violation("A3", "error", f"{m.stem} has sockets; {slot} section required", slot))
|
|
|
|
present = [(s, d) for s, d in defs.items() if d is not None]
|
|
|
|
# A4 slot/type, A9 nodesign, A8 explicit partner sections
|
|
for slot, sd in present:
|
|
if sd.type is None:
|
|
if slot == "mission" and standalone and (hidden or _low(sd.design_class) == "rider"):
|
|
v.append(Violation("A4", "info", f"{sd.stem} has no section_type (rider hull in a hidden design)", slot))
|
|
else:
|
|
v.append(Violation("A4", "error", f"{sd.stem} has no section_type and cannot fill the {slot} slot", slot))
|
|
elif sd.type != slot:
|
|
v.append(Violation("A4", "error", f"{sd.stem} is a {sd.type} section, placed in {slot} slot", slot))
|
|
if sd.nodesign and not hidden:
|
|
v.append(Violation("A9", "warn", f"{sd.stem} is nodesign (not offered by the design UI)", slot))
|
|
if sd.explicit_section and slot != "mission":
|
|
owner = m.explicit_command_section if slot == "command" else m.explicit_engine_section
|
|
if not owner or owner.lower() != sd.stem.lower():
|
|
v.append(Violation("A8", "error", f"{sd.stem} is an explicit_section usable only with its owning station", slot))
|
|
|
|
# A6 class equality
|
|
classes = {sd.cls for _, sd in present if sd.cls}
|
|
if len(classes) > 1:
|
|
v.append(Violation("A6", "error", f"mixed section_class: {sorted(classes)}"))
|
|
|
|
# A7 exclude
|
|
stems = {sd.stem.lower(): slot for slot, sd in present}
|
|
for slot, sd in present:
|
|
for ex in sd.exclude:
|
|
if ex.lower() in stems:
|
|
v.append(Violation("A7", "error", f"{sd.stem} excludes {ex} (in {stems[ex.lower()]} slot)", slot))
|
|
|
|
# -- banks & weapons ---------------------------------------------------------
|
|
for slot, sd in present:
|
|
use = uses[slot] or {}
|
|
weapons = _list(use.get("weapons"))
|
|
if len(weapons) != len(sd.banks):
|
|
v.append(Violation("B1", "error", f"{sd.stem} has {len(sd.banks)} banks, design lists {len(weapons)} weapons", slot))
|
|
for i, bank in enumerate(sd.banks):
|
|
ref = weapons[i] if i < len(weapons) else None
|
|
wd = cat.weapon(ref) if ref is not None else None
|
|
if ref is not None and wd is None:
|
|
v.append(Violation("B5", "error", f"unknown weapon {ref!r}", slot, i))
|
|
continue
|
|
if bank.fixed_weapon:
|
|
fixed = cat.weapon(bank.fixed_weapon)
|
|
if wd is None:
|
|
wd = fixed
|
|
elif fixed is not None and wd.file.lower() != fixed.file.lower():
|
|
v.append(Violation("B3", "error", f"bank is bound to {os.path.basename(bank.fixed_weapon)}, design has {wd.stem}", slot, i))
|
|
continue
|
|
if wd is None:
|
|
v.append(Violation("B2", "error", f"bank {i} ({bank.size} {bank.cls}, {bank.mounts} mounts) has no weapon", slot, i))
|
|
continue
|
|
ok, why = weapon_fits_bank(cat, bank, wd)
|
|
if not ok:
|
|
v.append(Violation("B4", "error", f"{wd.stem} ({wd.size} {wd.cls}) does not fit bank {i} ({bank.size} {bank.cls}): {why}", slot, i))
|
|
if wd.scope != "player" and rk != "_NPC" and not bank.fixed_weapon:
|
|
v.append(Violation("B5", "error", f"{wd.stem} is an NPC-only weapon", slot, i))
|
|
if wd.exclusive_species and wd.exclusive_species != rk.lower():
|
|
v.append(Violation("B6", "error", f"{wd.stem} is exclusive to {wd.exclusive_species}", slot, i))
|
|
if wd.hidden and not hidden and not bank.fixed_weapon:
|
|
v.append(Violation("B8", "error", f"{wd.stem} is a hidden (engine-only) weapon", slot, i))
|
|
if wd.compatible_section:
|
|
if not any(cat.section(rk, cs) for cs in wd.compatible_section):
|
|
v.append(Violation("B7", "error", f"{wd.stem} needs a rider section {wd.compatible_section} that {rk} lacks", slot, i))
|
|
# tech gating for weapons
|
|
if known is not None and not bank.fixed_weapon:
|
|
for t in wd.requires:
|
|
if not cat.tech_known(t, known):
|
|
v.append(Violation("C2", "error", f"{wd.stem} requires {t}", slot, i))
|
|
|
|
# -- options ----------------------------------------------------------------
|
|
for slot, sd in present:
|
|
use = uses[slot] or {}
|
|
chosen = [str(o) for o in _list(use.get("options"))]
|
|
seen_groups: dict[int, str] = {}
|
|
for o in chosen:
|
|
gi = next((i for i, g in enumerate(sd.option_groups) if o.lower() in {t.lower() for t in g.techs}), None)
|
|
if gi is None:
|
|
v.append(Violation("D1", "error", f"{o} is not an option of {sd.stem}", slot))
|
|
elif gi in seen_groups:
|
|
v.append(Violation("D1", "error", f"{o} and {seen_groups[gi]} are in the same option group of {sd.stem}", slot))
|
|
else:
|
|
seen_groups[gi] = o
|
|
if known is not None and cat.tech(o) is not None and not cat.tech_known(o, known):
|
|
v.append(Violation("C3", "error", f"option {o} is not researched", slot))
|
|
|
|
# -- tech gating ---------------------------------------------------------------
|
|
for slot, sd in present:
|
|
for t in sd.requires:
|
|
if cat.tech(t) is None and not t.lower().startswith("grp_"):
|
|
v.append(Violation("C1", "warn", f"{sd.stem} requires unknown tech {t}", slot))
|
|
elif known is not None and not cat.tech_known(t, known):
|
|
v.append(Violation("C1", "error", f"{sd.stem} requires {t}", slot))
|
|
if rk.lower() in cat.unobtainable.get(t.lower(), ()):
|
|
v.append(Violation("C5", "error", f"{sd.stem} requires {t}, which {rk} can never research", slot))
|
|
hct = sd.hull_class_tech()
|
|
if hct and known is not None and not cat.tech_known(hct, known):
|
|
v.append(Violation("C4", "error", f"{sd.stem} is a {sd.cls} section; {hct} not researched", slot))
|
|
return v
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# derived stats
|
|
|
|
_CAPACITY_KEYS = (
|
|
"refueling_capacity", "repair_capacity", "construction_capacity", "mining_capacity", "mining_rate",
|
|
"prisoner_capacity", "colonizer_pop", "colonizer_infra", "colonizer_terra", "scanrange",
|
|
"tacticalsensorrange", "range", "split_traffic_volume", "refinery", "freighter", "freighterq",
|
|
"police", "tradingpost", "monitor", "spy", "spytender", "science", "propaganda", "ewar", "gateship",
|
|
"ramscoop", "node_bore", "node_missile", "gravboat_bonus", "protectorate", "huge", "defence_platform",
|
|
)
|
|
|
|
_OPTION_COST_NOTE = ("cost multiplies section cost by the product of the chosen options' "
|
|
"tech option_cost values (assumption -- option_cost is the only cost-shaped "
|
|
"field on option techs; the multiplier's base and stacking order are code)")
|
|
|
|
|
|
def derive_stats(design: dict, cat: Catalog | None = None) -> dict:
|
|
cat = cat or Catalog.load()
|
|
rk = cat.race_key(design.get("race", "")) or design.get("race")
|
|
out: dict[str, Any] = {
|
|
"race": rk, "name": design.get("name"), "sections": {}, "hull_class": None,
|
|
"mass": 0.0, "section_cost": 0.0, "section_cost_with_options": 0.0, "weapon_cost_per_bank": 0.0,
|
|
"weapon_cost_per_mount": 0.0, "health_total": 0.0, "crew": 0, "cpoints": 0.0, "turrets": 0,
|
|
"banks": 0, "command_cost": 0, "command_quota": None, "maintenance_cost": 0,
|
|
"ftlspeed": None, "nodespeed": None, "engine_techera": None, "netforcelimits": None,
|
|
"capacities": {}, "applied_techs": {}, "notes": [
|
|
"health is tracked per section by the engine (ShipHealth = 3 floats); health_total is only a sum",
|
|
_OPTION_COST_NOTE,
|
|
"weapon cost: one turret per mount is the visual model; whether cost is per bank or per mount is code (both reported)",
|
|
"tactical speed/turn: engine section netforcelimits are reported; the combat integrator (mass vs force) is code",
|
|
],
|
|
}
|
|
for slot in SLOTS:
|
|
use = _slot(design, slot)
|
|
if use is None:
|
|
continue
|
|
sd = cat.section(rk, use.get("section"))
|
|
if sd is None:
|
|
continue
|
|
opt_mult = 1.0
|
|
for o in _list(use.get("options")):
|
|
n = cat.tech(o)
|
|
if n and n.get("option_cost") is not None:
|
|
opt_mult *= float(n["option_cost"])
|
|
sec = {
|
|
"section": sd.stem, "type": sd.type, "class": sd.cls, "mass": _num(sd.stat("mass")),
|
|
"cost": _num(sd.stat("cost")), "option_cost_multiplier": opt_mult, "health": _num(sd.stat("health")),
|
|
"crew": _num(sd.stat("crew")), "cpoints": _num(sd.stat("cpoints")), "banks": [],
|
|
}
|
|
weapons = _list(use.get("weapons"))
|
|
for i, bank in enumerate(sd.banks):
|
|
ref = weapons[i] if i < len(weapons) else None
|
|
wd = cat.weapon(ref) if ref is not None else (cat.weapon(bank.fixed_weapon) if bank.fixed_weapon else None)
|
|
row = turret_row(cat, bank, wd) if wd else None
|
|
sec["banks"].append({
|
|
"size": bank.size, "class": bank.cls, "mounts": bank.mounts, "weapon": wd.stem if wd else None,
|
|
"weapon_cost": wd.cost if wd else 0, "turret_model": row.model if row else None,
|
|
"turret_health": row.health if row else None, "turret_track_speed": row.track_speed if row else None,
|
|
})
|
|
out["banks"] += 1
|
|
out["turrets"] += bank.mounts
|
|
if wd:
|
|
out["weapon_cost_per_bank"] += wd.cost
|
|
out["weapon_cost_per_mount"] += wd.cost * max(bank.mounts, 1)
|
|
out["sections"][slot] = sec
|
|
out["mass"] += sec["mass"]
|
|
out["section_cost"] += sec["cost"]
|
|
out["section_cost_with_options"] += sec["cost"] * opt_mult
|
|
out["health_total"] += sec["health"]
|
|
out["crew"] += int(sec["crew"] or 0)
|
|
out["cpoints"] += sec["cpoints"]
|
|
out["command_cost"] += int(_num(sd.stat("command_cost")))
|
|
out["maintenance_cost"] += int(_num(sd.stat("maintenance_cost")))
|
|
if sd.stat("command_quota") is not None:
|
|
out["command_quota"] = (out["command_quota"] or 0) + int(_num(sd.stat("command_quota")))
|
|
out["hull_class"] = out["hull_class"] or sd.cls
|
|
out["applied_techs"][slot] = applied_techs(use, sd, cat)
|
|
for k in _CAPACITY_KEYS:
|
|
if k in sd.raw:
|
|
out["capacities"][k] = _last(sd.raw[k])
|
|
if sd.type == "engine" or (slot == "mission" and out["ftlspeed"] is None and sd.stat("ftlspeed") is not None):
|
|
out["ftlspeed"] = sd.stat("ftlspeed")
|
|
out["nodespeed"] = sd.stat("nodespeed")
|
|
out["engine_techera"] = sd.stat("engine_techera")
|
|
out["netforcelimits"] = sd.raw.get("netforcelimits")
|
|
out["total_cost_estimate"] = out["section_cost_with_options"] + out["weapon_cost_per_bank"]
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# save-record conversion
|
|
|
|
def design_from_save(name: str, slots: Iterable[dict], cat: Catalog | None = None, *,
|
|
hidden: bool = False, known_techs=None, race: str | None = None) -> dict:
|
|
"""Build a Design from a save-reader design record.
|
|
|
|
`slots` is the DSec list in save order (command, mission, engine, +2 unused),
|
|
each as {"species": int, "section_id": int, "weapons": [wid|file|None per bank],
|
|
"options": [tech...]} with species/section_id (0, 0) meaning empty. The
|
|
species index is resolved through SPECIES_INDEX; if the design mixes
|
|
species the result carries "mixed_species": [...] for the validator.
|
|
"""
|
|
cat = cat or Catalog.load()
|
|
d: dict = {"name": name, "hidden": hidden, "race": race}
|
|
species_seen = []
|
|
for slot, rec in zip(SLOTS, slots):
|
|
if not rec or (rec.get("species", 0) == 0 and rec.get("section_id", 0) == 0):
|
|
d[slot] = None
|
|
continue
|
|
sp = SPECIES_INDEX[rec["species"]] if rec.get("species") is not None else race
|
|
species_seen.append(sp)
|
|
sd = cat.section(sp, int(rec["section_id"]))
|
|
d[slot] = {
|
|
"section": sd.stem if sd else f"#{rec['section_id']}",
|
|
"weapons": list(rec.get("weapons", [])),
|
|
"options": list(rec.get("options", [])),
|
|
"save_opts": list(rec.get("options", [])),
|
|
}
|
|
if sd is not None:
|
|
# DOpts holds hull-class tech + requires + chosen options; the
|
|
# design's own `options` are the chosen ones only.
|
|
structural = {t.lower() for t in sd.requires}
|
|
hct = sd.hull_class_tech()
|
|
if hct:
|
|
structural.add(hct.lower())
|
|
d[slot]["options"] = [o for o in rec.get("options", []) if o.lower() not in structural]
|
|
if d["race"] is None:
|
|
d["race"] = species_seen[0] if species_seen else "Human"
|
|
if len(set(species_seen)) > 1:
|
|
d["mixed_species"] = species_seen
|
|
if known_techs is not None:
|
|
d["known_techs"] = list(known_techs)
|
|
return d
|
|
|
|
|
|
if __name__ == "__main__": # tiny smoke demo
|
|
cat = Catalog.load()
|
|
demo = {
|
|
"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"],
|
|
}
|
|
for viol in validate(demo, cat) or ["no violations"]:
|
|
print(viol)
|
|
st = derive_stats(demo, cat)
|
|
print({k: st[k] for k in ("mass", "section_cost", "weapon_cost_per_bank", "crew", "ftlspeed", "nodespeed", "turrets")})
|