"""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): with open(path, encoding="utf-8") as f: data = json.load(f) 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): with open(path, encoding="utf-8") as f: data = json.load(f) 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): with open(path, encoding="utf-8") as f: data = json.load(f) 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_ is satisfied by any known tech of group .""" 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 --------------------------------------------------------------- # Engine-generated hidden designs (the per-race "Default Assault Shuttle") # exist in every save regardless of research: the Tarkas _AssaultShuttle # requires DRN_AdvFrm and the design is there on turn 1. So for hidden # designs tech gating is reported, but only as a warning. gate_level = "warn" if hidden else "error" 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", gate_level, 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", gate_level, f"{sd.stem} is a {sd.cls} section; {hct} not researched", slot)) if hidden: for x in v: if x.rule in ("C2", "C3") and x.level == "error": x.level = "warn" 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")})