# ABOUT ME # Validation suite for the Auto Radiator mod. # # Why this exists: Cosmoteer applies a mod's "Actions" to vanilla data at load # time. A path that does not resolve, a component reference that points at # nothing, or a misspelled component Type all fail the same way -- the game # crashes on startup and leaves a log file. These tests catch all three offline, # against the player's actual installed game data, before the game is launched. # # Run: python3 tests/test_mod.py # The game install is found automatically, or set COSMOTEER_DATA to override. import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import rules_parser MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CANDIDATE_DATA_DIRS = [ "/mnt/SSD4TB/SteamLibrary/steamapps/common/Cosmoteer/Data", os.path.expanduser("~/.local/share/Steam/steamapps/common/Cosmoteer/Data"), os.path.expanduser("~/.steam/steam/steamapps/common/Cosmoteer/Data"), ] # Component fields whose value names another component in the same part. COMPONENT_REF_FIELDS = { "OperationalToggle", "Toggle", "ToggleSource", "ValueFrom", "ValueSource", "ResourceStorage", "StartTrigger", "StopTrigger", "ResetTrigger", "OnTrigger", "OffTrigger", "Trigger", "FromStorage", "ToStorage", } _failures = [] _checks = 0 def check(condition, message): global _checks _checks += 1 if not condition: _failures.append(message) def find_data_dir(): override = os.environ.get("COSMOTEER_DATA") if override: return override for path in CANDIDATE_DATA_DIRS: if os.path.isdir(path): return path return None DATA = find_data_dir() _vanilla_cache = {} def vanilla(rel_path): """Parse a vanilla .rules file, relative to the Data folder.""" if rel_path not in _vanilla_cache: full = os.path.join(DATA, rel_path) _vanilla_cache[rel_path] = rules_parser.parse_file(full) if os.path.isfile(full) else None return _vanilla_cache[rel_path] def split_target(target): """Split '/A/B' into ('file.rules', 'A/B').""" match = re.match(r"^<([^>]+)>/?(.*)$", target.strip().strip('"')) if not match: return None, None return match.group(1), match.group(2) def resolve(target): """Resolve a mod action target against vanilla data. Returns a Node or None.""" rel_path, inner = split_target(target) if rel_path is None: return None root = vanilla(rel_path) if root is None: return None return root.get(inner) if inner else root # --------------------------------------------------------------------------- # 1. Parser self-check, against facts read directly out of the vanilla files. # If these fail the parser is broken and every later result is meaningless. # --------------------------------------------------------------------------- def test_parser_reads_vanilla_correctly(): radiator = vanilla("ships/terran/radiator/radiator.rules") check(radiator is not None, "cannot read vanilla radiator.rules") if radiator is None: return power = radiator.get("Part/Components/PowerToggle") check(power is not None, "radiator PowerToggle not found by parser") check( power is not None and power.get("Type").value == "UIToggle", "radiator PowerToggle should be a UIToggle", ) converter = radiator.get("Part/Components/HeatDissipationConverter") check(converter is not None, "radiator HeatDissipationConverter not found") check( converter is not None and converter.get("OperationalToggle").value == "PowerToggle", "vanilla HeatDissipationConverter should gate on PowerToggle", ) battery = vanilla("ships/terran/thermal_battery/thermal_battery.rules") storage = battery.get("Part/Components/HeatStorage") if battery else None check(storage is not None, "thermal battery HeatStorage not found") check( storage is not None and storage.get("Type").value == "ResourceStorage", "thermal battery HeatStorage should be a ResourceStorage", ) overclock = vanilla("ships/base_part_overclock.rules") overflow = overclock.get("Part/Components/OverflowHeatStorage") if overclock else None check(overflow is not None, "base_part_overclock OverflowHeatStorage not found") check( overflow is not None and overflow.get("Type").value == "ResourceConsumingStorage", "OverflowHeatStorage should be a ResourceConsumingStorage", ) # --------------------------------------------------------------------------- # 2. Every path the mod's actions touch must exist in vanilla data. # --------------------------------------------------------------------------- def iter_actions(mod): actions = mod.get("Actions") if actions is None: return [] return [actions.children[key] for key in actions.order] def test_action_targets_resolve(): mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules")) actions = iter_actions(mod) check(len(actions) > 0, "mod.rules declares no Actions") for action in actions: kind = action.get("Action") kind = kind.value if kind else "?" for field in ("Replace", "OverrideIn", "AddTo", "AddBaseTo", "Remove"): node = action.get(field) if node is None or node.kind != "scalar": continue target = node.value check( resolve(target) is not None, f"{kind} action target does not exist in vanilla data: {target}", ) # --------------------------------------------------------------------------- # 3. Component references must resolve. # A component the mod adds may reference its own siblings or any component # already on the vanilla part it is being merged into. # --------------------------------------------------------------------------- def added_component_sets(): """Yield (mod_file, vanilla_components_node) for each AddBase action.""" mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules")) for action in iter_actions(mod): kind = action.get("Action") if not kind or kind.value != "AddBase": continue target_node = action.get("AddBaseTo") base_node = action.get("BaseToAdd") if target_node is None or base_node is None: continue base_ref = base_node.value.strip() match = re.match(r"^&?<([^>]+)>$", base_ref) check(match is not None, f"cannot parse BaseToAdd reference: {base_ref}") if match is None: continue mod_file = os.path.join(MOD_ROOT, match.group(1)) check(os.path.isfile(mod_file), f"BaseToAdd file missing: {match.group(1)}") if not os.path.isfile(mod_file): continue yield mod_file, resolve(target_node.value) def test_component_references_resolve(): for mod_file, vanilla_components in added_component_sets(): added = rules_parser.parse_file(mod_file) name = os.path.basename(mod_file) check( vanilla_components is not None, f"{name}: AddBaseTo target missing from vanilla data", ) if vanilla_components is None: continue known = set(added.order) | set(vanilla_components.order) for component_name in added.order: component = added.children[component_name] if component.kind != "group": continue for field in component.order: if field not in COMPONENT_REF_FIELDS: continue value = component.children[field] if value.kind != "scalar": continue ref = value.value.strip() if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", ref): continue # a path or expression, not a plain sibling name check( ref in known, f"{name}: {component_name}.{field} references unknown component '{ref}'", ) toggles = component.get("Toggles") if toggles is not None and toggles.kind == "list": for key in toggles.order: ref = toggles.children[key].value.strip() check( ref in known, f"{name}: {component_name}.Toggles references unknown component '{ref}'", ) # --------------------------------------------------------------------------- # 4. Every component Type the mod uses must be a Type the engine already knows, # proven by it appearing somewhere in vanilla data. Catches misspellings. # --------------------------------------------------------------------------- _vanilla_types = None def vanilla_types(): global _vanilla_types if _vanilla_types is None: found = set() pattern = re.compile(r"Type\s*=\s*([A-Za-z_][A-Za-z0-9_]*)") for root, _dirs, files in os.walk(DATA): for filename in files: if not filename.endswith(".rules"): continue with open(os.path.join(root, filename), "r", encoding="utf-8", errors="replace") as handle: found.update(pattern.findall(handle.read())) _vanilla_types = found return _vanilla_types def test_component_types_exist(): for mod_file, _ in added_component_sets(): added = rules_parser.parse_file(mod_file) name = os.path.basename(mod_file) for component_name in added.order: component = added.children[component_name] if component.kind != "group": continue type_node = component.get("Type") check(type_node is not None, f"{name}: {component_name} has no Type") if type_node is None: continue check( type_node.value in vanilla_types(), f"{name}: {component_name} uses unknown component Type '{type_node.value}'", ) # --------------------------------------------------------------------------- # 5. The mod manifest must declare the fields Cosmoteer requires. # --------------------------------------------------------------------------- def test_manifest_is_well_formed(): mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules")) for field in ("ID", "Name", "Version", "CompatibleGameVersions", "ModifiesGameplay"): check(field in mod, f"mod.rules is missing required field '{field}'") mod_id = mod.get("ID") check( mod_id is not None and "." in mod_id.value and not mod_id.value.startswith("cosmoteer."), "mod ID must be 'author.mod_name' and must not use the reserved 'cosmoteer' author", ) def main(): if DATA is None or not os.path.isdir(DATA): print("SKIP: Cosmoteer Data folder not found. Set COSMOTEER_DATA to run these tests.") return 0 print(f"Cosmoteer data: {DATA}\n") for name, test in sorted(globals().items()): if name.startswith("test_") and callable(test): before = len(_failures) test() status = "PASS" if len(_failures) == before else "FAIL" print(f" [{status}] {name}") print(f"\n{_checks} checks run.") if _failures: print(f"{len(_failures)} FAILED:\n") for failure in _failures: print(f" - {failure}") return 1 print("All checks passed.") return 0 if __name__ == "__main__": sys.exit(main())