Files
cosmoteer-pressure-relief-r…/tests/test_mod.py
T
TotenKnopfandClaude Opus 5 da331e955c fix: measure the network, not a battery's storage
v2.0.0 held heat forever: thermal batteries visibly full, parts taking
heat damage, radiators still idle. That is the dangerous direction.

The ceiling read a thermal battery's own HeatStorage with a percentage
threshold. That storage is wrapped by a NetworkResourceStore, and the
local read never crossed 98% however full the network actually got, so
the battery broadcast "hold" permanently.

Measure the network's remaining room directly instead, from the radiator
itself, with a bare NetworkResourceInput -- the same way the heat
exchanger decides whether it can still push heat. Vent below ~2000 status
heat of headroom, hold again above ~6000. Absolute headroom is also the
better metric: what matters is room against the generation rate, not a
percentage of whatever storage happens to be installed.

Polarity is now fail-safe. Holding requires positive evidence of
headroom, so anything missing or unmeasurable vents rather than holds --
the opposite of the failure above.

This deletes both broadcasters. The thermal battery and base_part_overclock
are no longer patched at all, and the mod touches two parts instead of
four. The launcher ammo signal stays; it is confirmed working in both
directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oLWY1G6S8U35bi2FxVWni
2026-08-29 00:49:53 +02:00

667 lines
26 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",
)
# ---------------------------------------------------------------------------
# 6b. Every field written on a component must exist in that component's real
# schema, read out of the game assembly's metadata.
#
# Cosmoteer ignores fields it does not recognise rather than reporting them,
# so a misspelled or invented field name produces a mod that loads perfectly
# and silently does nothing. This is the only check that catches that.
# ---------------------------------------------------------------------------
ASSEMBLY_CANDIDATES = [
os.path.join(os.path.dirname(DATA or ""), "Bin", "Cosmoteer.dll") if DATA else "",
]
def find_assembly():
override = os.environ.get("COSMOTEER_ASSEMBLY")
if override and os.path.isfile(override):
return override
for path in ASSEMBLY_CANDIDATES:
if path and os.path.isfile(path):
return path
return None
def test_component_fields_exist_in_schema():
assembly = find_assembly()
if assembly is None:
print(" (skipped: Cosmoteer.dll not found)")
return
sys.path.insert(0, os.path.join(MOD_ROOT, "tools"))
import dotnet_enum
tables = dotnet_enum.load(assembly)
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
schema = dotnet_enum.schema(tables, f"Part{type_node.value}Rules")
check(schema is not None, f"{name}: no schema found for Type '{type_node.value}'")
if schema is None:
continue
# Some members are complex objects the serializer accepts inline,
# so their own fields are legal at the component's top level too.
for member, inline_type in (("NetworkFilter", "PartNetworkFilter"),):
if member in schema:
nested = dotnet_enum.schema(tables, inline_type)
if nested:
schema = schema | nested
for field in component.order:
if field == "Type":
continue
check(
field in schema,
f"{name}: {component_name} ({type_node.value}) has no field "
f"'{field}' -- it would be silently ignored",
)
# ---------------------------------------------------------------------------
# 6c. Every network component the mod adds must be reachable by a route.
#
# A NetworkValue or NetworkValueReceiver is only attached to the network if
# a route leads to it: the part's NetworkPort anchors on one component, and
# a NetworkRouter bridges that anchor to the rest. An unrouted network
# component is not an error -- it simply never connects and always reads as
# absent. That is exactly how v1.1.0 and v1.2.0 failed: they loaded clean,
# the components existed on the parts, and every signal read zero.
# ---------------------------------------------------------------------------
NETWORK_COMPONENT_TYPES = {"NetworkValue", "NetworkValueReceiver", "NetworkResourceInput"}
def collect_route_names(node, found=None):
"""Every component name appearing in any Routes list under this node."""
if found is None:
found = set()
if node.kind not in ("group", "list"):
return found
for name in node.order:
child = node.children[name]
if name == "Routes" and child.kind == "list":
for key in child.order:
route = child.children[key]
if route.kind == "list":
for slot in route.order:
value = route.children[slot]
if value.kind == "scalar":
found.add(value.value.strip())
collect_route_names(child, found)
return found
def addbase_targets():
"""Yield (mod_file_path, target_part_rules_path) 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 = action.get("AddBaseTo")
base = action.get("BaseToAdd")
if target is None or base is None:
continue
match = re.match(r"^&?<([^>]+)>$", base.value.strip())
if match is None:
continue
part_path, _inner = split_target(target.value)
yield os.path.join(MOD_ROOT, match.group(1)), part_path
def test_network_components_are_routed():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
# Routes the mod contributes, keyed by the part they land on.
routed = {}
for mod_file, part_path in addbase_targets():
added = rules_parser.parse_file(mod_file)
routed.setdefault(part_path, set()).update(collect_route_names(added))
for action in iter_actions(mod):
kind = action.get("Action")
add_to = action.get("AddTo")
if not kind or kind.value != "AddMany" or add_to is None:
continue
if "Routes" not in add_to.value:
continue
part_path, _inner = split_target(add_to.value)
entries = action.get("ManyToAdd")
if entries is None:
continue
for key in entries.order:
route = entries.children[key]
if route.kind != "list":
continue
for slot in route.order:
value = route.children[slot]
if value.kind == "scalar":
routed.setdefault(part_path, set()).add(value.value.strip())
for mod_file, part_path in addbase_targets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
reachable = routed.get(part_path, set())
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 or type_node.value not in NETWORK_COMPONENT_TYPES:
continue
check(
component_name in reachable,
f"{name}: {component_name} ({type_node.value}) is not named in any route, "
f"so it never attaches to the network and always reads as absent",
)
# ---------------------------------------------------------------------------
# 6d. An Overrides value that names a component must name one that exists.
#
# The whole point of the Overrides actions is to repoint vanilla components
# at components this mod adds. If such a value names something that is not
# there -- deleted, renamed, or never written -- the mod still loads and the
# gate silently reverts to doing nothing.
# ---------------------------------------------------------------------------
def test_override_values_name_real_components():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
# What each part offers: its vanilla components plus what the mod adds.
available = {}
for mod_file, part_path in addbase_targets():
added = rules_parser.parse_file(mod_file)
vanilla_components = resolve(f"<{part_path}>/Part/Components")
names = set(added.order)
if vanilla_components is not None:
names |= set(vanilla_components.order)
available.setdefault(part_path, set()).update(names)
for action in iter_actions(mod):
kind = action.get("Action")
target = action.get("OverrideIn")
if not kind or kind.value != "Overrides" or target is None:
continue
part_path, _inner = split_target(target.value)
known = available.get(part_path)
if not known:
continue
overrides = action.get("Overrides")
if overrides is None:
continue
for field in overrides.order:
value = overrides.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
check(
ref in known,
f"Overrides sets {field} = '{ref}' on {os.path.basename(part_path)}, "
f"but no such component exists on that part",
)
# ---------------------------------------------------------------------------
# 7. Added indicators must bind to a real toggle and a real atlas sprite.
# ---------------------------------------------------------------------------
def test_added_indicators_are_valid():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
radiator_components = resolve("<ships/terran/radiator/radiator.rules>/Part/Components")
added = rules_parser.parse_file(os.path.join(MOD_ROOT, "components", "radiator_auto.rules"))
known = set(added.order) | set(radiator_components.order if radiator_components else [])
atlas = vanilla("gui/game/indicators/indicators.rules")
check(atlas is not None, "cannot read the indicators atlas")
for action in iter_actions(mod):
kind = action.get("Action")
add_to = action.get("AddTo")
if not kind or kind.value != "AddMany" or add_to is None:
continue
if "Indicators" not in add_to.value:
continue
entries = action.get("ManyToAdd")
check(entries is not None, "indicator AddMany has no ManyToAdd list")
if entries is None:
continue
for key in entries.order:
entry = entries.children[key]
toggle = entry.get("Toggle")
check(toggle is not None, "an added indicator has no Toggle")
if toggle is not None:
check(
toggle.value.strip() in known,
f"added indicator binds to unknown component '{toggle.value.strip()}'",
)
sprite = entry.get("AtlasSprite/File")
check(sprite is not None, "an added indicator has no AtlasSprite/File")
if sprite is not None and atlas is not None:
name = sprite.value.strip().rsplit("/", 1)[-1]
check(
name in atlas.children,
f"added indicator uses unknown atlas sprite '{name}'",
)
# ---------------------------------------------------------------------------
# 8. 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())