feat: auto-gate radiators on heat network surplus

Radiators and Thermal Canister Missile launchers pull from the same heat
pool with identical NetworkResourceOutput settings, and priorities.rules
defines no ordering for heat. A constantly-running radiator therefore
spends missile ammunition to make steam.

Gate radiator dissipation on three network signals instead of the raw
PowerToggle: a 98%/94% hysteresis band on the thermal battery, whether
any launcher is actually set to thermal ammo, and an override driven by
the engine's own OverflowHeatStorage as a safety net.

Storage fullness causes no damage on its own -- only overflow does, and
only above 350 status per tile -- so the band sits high deliberately to
keep the network full as a missile reserve.

Signal polarity is "hold", not "vent", so any ship missing a broadcaster
falls back to vanilla behaviour.

Includes an offline validation suite that resolves every patch target,
component reference and component Type against the installed game data,
since all three otherwise fail only as a crash on game load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oLWY1G6S8U35bi2FxVWni
This commit is contained in:
2026-08-28 23:24:32 +02:00
co-authored by Claude Opus 5
commit 831a628b53
10 changed files with 1049 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
# ABOUT ME
# Minimal parser for Cosmoteer ".rules" files.
#
# Why this exists: a Cosmoteer mod patches the game by naming paths into vanilla
# .rules data (e.g. "<ships/terran/radiator/radiator.rules>/Part/Components/
# HeatDissipationConverter"). A single wrong path is not caught until the game
# crashes on load, with only a log file to go on. This parser resolves those
# paths offline so the test suite can prove every target exists before launch.
#
# Scope: structure only -- named groups, lists, and the raw text of scalar
# values. It does not evaluate the "&" reference expressions, arithmetic, or
# the ":" inheritance chains, because the tests only need to know that a node
# exists and which keys it declares.
import re
_TOKEN = re.compile(
r"""
(?P<string>"(?:[^"\\]|\\.)*") # quoted string
| (?P<punct>[{}\[\]=:;]) # structural punctuation
| (?P<comma>,) # separator, carries no meaning
| (?P<space>\s+)
| (?P<word>[^\s{}\[\]=:;,"]+) # bare word / expression fragment
""",
re.VERBOSE,
)
# Tokens that can only begin a new entry, never continue a scalar value.
_ENTRY_STARTERS = ("=", "{", "[", ":")
class Node:
"""A parsed .rules node: a group, a list, or a scalar."""
def __init__(self, kind, value=None):
self.kind = kind # "group" | "list" | "scalar"
self.children = {} # name (or str index) -> Node
self.order = [] # child names in declaration order
self.bases = [] # raw text of ":" inheritance sources
self.value = value # raw text, for scalars
def add(self, name, node):
if name not in self.children:
self.order.append(name)
self.children[name] = node
def get(self, path):
"""Resolve a "/"-separated path. Returns None if any step is missing."""
node = self
for part in [p for p in path.split("/") if p]:
if node is None or part not in node.children:
return None
node = node.children[part]
return node
def __contains__(self, name):
return name in self.children
def __repr__(self):
if self.kind == "scalar":
return f"Scalar({self.value!r})"
return f"{self.kind.title()}({', '.join(self.order)})"
def _strip_comments(text):
"""Remove // comments, leaving anything inside a quoted string alone."""
out = []
in_string = False
i = 0
while i < len(text):
ch = text[i]
if in_string:
if ch == "\\":
out.append(text[i : i + 2])
i += 2
continue
if ch == '"':
in_string = False
out.append(ch)
elif ch == '"':
in_string = True
out.append(ch)
elif ch == "/" and text[i : i + 2] == "//":
while i < len(text) and text[i] != "\n":
i += 1
continue
else:
out.append(ch)
i += 1
return "".join(out)
def _tokenize(text):
tokens = []
for match in _TOKEN.finditer(_strip_comments(text)):
if match.lastgroup in ("space", "comma"):
continue
tokens.append(match.group())
return tokens
class _Parser:
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
def peek(self):
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def next(self):
token = self.peek()
self.pos += 1
return token
def parse_group(self, terminator=None):
node = Node("group")
while True:
token = self.peek()
if token is None:
break
if token == terminator:
self.next()
break
if token in ("}", "]"):
self.next()
break
if token == ";":
self.next()
continue
name = self.next().strip('"')
self.parse_entry(node, name)
return node
def parse_list(self):
node = Node("list")
index = 0
while True:
token = self.peek()
if token is None or token == "]":
self.next()
break
if token == ";":
self.next()
continue
if token == "{":
self.next()
node.add(str(index), self.parse_group("}"))
elif token == "[":
self.next()
node.add(str(index), self.parse_list())
else:
node.add(str(index), Node("scalar", self.next()))
index += 1
return node
def parse_entry(self, parent, name):
"""Parse one "name ..." entry and attach it to parent."""
bases = []
while self.peek() == ":":
self.next()
bases.append(self.next())
token = self.peek()
if token == "{":
self.next()
child = self.parse_group("}")
elif token == "[":
self.next()
child = self.parse_list()
elif token == "=":
self.next()
child = self.parse_value()
else:
# A bare name with only inheritance, e.g. "Foo : Bar" with no body.
child = Node("group")
child.bases = bases
parent.add(name, child)
def parse_value(self):
token = self.peek()
if token == "{":
self.next()
return self.parse_group("}")
if token == "[":
self.next()
return self.parse_list()
# Scalar: consume tokens until a structural boundary. Expressions such as
# "(&../Foo/MAX) * 2" arrive as several bare words, so collect them all.
#
# The .rules format has no statement terminator, so the only way to know
# a value has ended is to look one token ahead: a bare word followed by
# "=", "{", "[" or ":" is the name of the *next* entry, not part of this
# value. Without this lookahead, "DescriptionKey = "x"" swallows the
# "Resources" that follows it and the rest of the file shifts.
parts = []
while True:
token = self.peek()
if token is None or token in (";", "}", "]", "{", "["):
break
if parts:
lookahead = self.tokens[self.pos + 1] if self.pos + 1 < len(self.tokens) else None
if lookahead in _ENTRY_STARTERS:
break
parts.append(self.next())
return Node("scalar", " ".join(parts))
def parse(text):
return _Parser(_tokenize(text)).parse_group()
def parse_file(path):
with open(path, "r", encoding="utf-8", errors="replace") as handle:
return parse(handle.read())
+322
View File
@@ -0,0 +1,322 @@
# 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:
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())