v1.0.0 loaded cleanly and did nothing in game. Every vanilla use of NetworkToggle and NetworkToggleReceiver -- all three of them -- sets IncludeInPhysical = false. They are blueprint stat-display plumbing and carry no signal on a real ship, so the receivers always read off, the vent gate stayed open, and radiators behaved exactly as in vanilla. Broadcast 1/0 through NetworkValue and threshold it back into a toggle on the receiving side. That is what the thermal amplification pump uses to drive physical behaviour, and vanilla never sets IncludeInPhysical on it anywhere. Also repoint the radiator's power indicator at the gate, so an auto-idled radiator is visibly idle instead of looking identical to a working one. Tests grow a guard against the blueprint-only types, plus a check that re-verifies that premise against the installed game data so the guard cannot go stale, and a name-collision check against vanilla components. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011oLWY1G6S8U35bi2FxVWni
408 lines
15 KiB
Python
408 lines
15 KiB
Python
# 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 '<file.rules>/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:
|
|
entry = toggles.children[key]
|
|
if entry.kind == "group":
|
|
# Inline form: { Toggle = Something; Invert = true }
|
|
inner = entry.get("Toggle")
|
|
ref = inner.value.strip() if inner is not None else None
|
|
check(
|
|
ref is not None,
|
|
f"{name}: {component_name}.Toggles has an inline entry with no Toggle",
|
|
)
|
|
else:
|
|
ref = entry.value.strip()
|
|
if ref is None:
|
|
continue
|
|
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. Signals must use a mechanism that works on a physical ship.
|
|
# Regression guard: version 1.0.0 broadcast with NetworkToggle, which loaded
|
|
# without error and did nothing in game. Every NetworkToggle and
|
|
# NetworkToggleReceiver in vanilla sets IncludeInPhysical = false -- they are
|
|
# blueprint stat-display plumbing only. NetworkValue carries the signal on a
|
|
# real ship, and is what vanilla's thermal amplification pump uses.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
BLUEPRINT_ONLY_TYPES = {"NetworkToggle", "NetworkToggleReceiver"}
|
|
|
|
|
|
def test_no_blueprint_only_signal_types():
|
|
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")
|
|
if type_node is None:
|
|
continue
|
|
check(
|
|
type_node.value not in BLUEPRINT_ONLY_TYPES,
|
|
f"{name}: {component_name} uses '{type_node.value}', which vanilla only ever "
|
|
f"uses with IncludeInPhysical = false and which carries no signal in game",
|
|
)
|
|
|
|
|
|
def test_vanilla_marks_those_types_blueprint_only():
|
|
"""The premise of the guard above, re-checked against the installed game."""
|
|
pattern = re.compile(
|
|
r"Type\s*=\s*(NetworkToggle|NetworkToggleReceiver)\b(.*?)(?=\n\s*\})",
|
|
re.DOTALL,
|
|
)
|
|
sites = 0
|
|
physical_false = 0
|
|
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:
|
|
for match in pattern.finditer(handle.read()):
|
|
sites += 1
|
|
if "IncludeInPhysical = false" in match.group(2):
|
|
physical_false += 1
|
|
check(sites > 0, "expected vanilla to use NetworkToggle somewhere")
|
|
check(
|
|
sites == physical_false,
|
|
f"premise broken: {sites - physical_false} of {sites} vanilla NetworkToggle sites "
|
|
f"are physical, so the guard in test_no_blueprint_only_signal_types may be wrong",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Added components must not silently overwrite a vanilla component.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_name_collisions_with_vanilla():
|
|
for mod_file, vanilla_components in added_component_sets():
|
|
if vanilla_components is None:
|
|
continue
|
|
added = rules_parser.parse_file(mod_file)
|
|
name = os.path.basename(mod_file)
|
|
for component_name in added.order:
|
|
check(
|
|
component_name not in vanilla_components.children,
|
|
f"{name}: '{component_name}' already exists on the vanilla part and would "
|
|
f"be overwritten",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. 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())
|