commit 831a628b53306f4f072175847b75946adc39cab8 Author: Tobias Ganter Date: Fri Aug 28 23:24:32 2026 +0200 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) Claude-Session: https://claude.ai/code/session_011oLWY1G6S8U35bi2FxVWni diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b346a5 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# Auto Radiator + +A Cosmoteer mod. Radiators stop dumping heat the ship still wants. + +## The problem + +Thermal Canister Missiles are built from the ship's own heat. The game says so +itself, in `Data/strings/en.rules`: + +> Manufactures guided Thermal Canister Missiles that take thermal energy from +> their own ship's heat pipe network and inflict it upon the target. + +The launcher charges each missile from the heat network — `HEAT_PER_MISSILE = +8500`, two missiles buffered per launcher. A radiator pulls from that same +network, through a component that is behaviourally identical to the launcher's: + +| | Radiator | Thermal launcher | +|---|---|---| +| Type | `NetworkResourceOutput` | `NetworkResourceOutput` | +| Mode | `DistributeProportionallyByAvailable` | `DistributeProportionallyByAvailable` | +| Categories | `[ thermal_storage ]` | `[ thermal_storage ]` | + +`Data/ships/priorities.rules` defines priorities for crew and supply only — +nothing for heat. So the two compete flat, proportionally, and a radiator +running constantly is spending your ammunition to make steam. + +## Why holding heat is safe + +Heat storage being full does not hurt you. Damage comes only from *overflow*. +Every overclocked part routes its heat through this +(`Data/ships/base_part_overclock.rules`): + +``` +HeatDistributionStorage +{ + Type = MultiResourceStorage + ResourceStorages = [NetworkHeatInput, OverflowHeatStorage] + AddMode = InOrder +} +``` + +`InOrder` means the network is tried first, and `OverflowHeatStorage` only +receives what the network refuses. Overflow then applies the `cosmoteer.heat` +status to the producing part's own tiles. + +From `Data/statuses/heat/heat.rules`, that status does nothing whatsoever below +**350** per tile (`RequireValueRangeForTick = [350, Infinity]`). Fire chance +ramps from 350 to 2500; thermal damage begins at 700. + +An overclocked large reactor produces 2750 heat/sec over its 4×4 footprint — +about **172 status/sec per tile** while fully overflowing, so roughly **2 +seconds of continuous, total overflow** before fire risk even arms. Radiators +react within **0.5 s** (`HeatDissipationConverter { Interval = 0.5 }`). + +At a 98% ceiling on a single 15000-capacity thermal battery, the overshoot +during that reaction window is about 625 status heat spread over 16 tiles — +roughly **39 per tile**, against a threshold of 350. Holding the network near +full is safe with a wide margin, and every percent of ceiling given up is +ammunition thrown away for nothing. + +The genuine constraint is not the threshold. It is total radiator throughput +(1100/sec each) versus sustained generation. If you are short on radiators you +overflow continuously and no threshold value saves you — which is what the +safety net below is for. + +## What the mod does + +Radiators vent when **any** of these is true: + +1. The heat network is not asking them to hold — the thermal battery is at or + above 98% full (dropping the hold again at 94%, a deadband so radiators do + not chatter at the threshold). +2. No launcher on the network is set to Thermal Canister Missiles. If you are + not spending heat as ammunition there is nothing to hoard, so radiators + behave exactly as in vanilla. +3. **Safety net:** some part is actually overflowing heat right now. This reads + the engine's own `OverflowHeatStorage`, so it fires the moment the network + genuinely refuses heat — regardless of thresholds, pipe topology, or whether + your radiator capacity was ever sufficient. Latched for 3 seconds so a + stream of per-tick overflow events becomes one steady signal. + +Your manual radiator on/off switch still overrides all of it. The automation can +only idle a radiator you have left switched on. + +Ships with no thermal battery have no broadcaster, so nothing changes for them. +The signal polarity is deliberately "hold", not "vent", so that the absence of +any part of this mod's machinery always falls back to vanilla behaviour. + +No stat or number is changed. Only *when* radiators run. + +## Layout + +``` +mod.rules manifest + patch actions +components/thermal_battery_vent.rules decides when there is surplus +components/launcher_ammo_signal.rules reports thermal ammo in use +components/overclock_overflow_signal.rules safety net, reports real overflow +components/radiator_auto.rules combines the signals into one gate +tests/ offline validation suite +install.sh validate + copy into the Mods folder +``` + +## Install + +```sh +./install.sh +``` + +Then enable **Auto Radiator** in the game's Mods menu. `./install.sh --uninstall` +removes it. If the script cannot find your Mods folder, set `COSMOTEER_MODS_DIR`. + +## Tests + +```sh +python3 tests/test_mod.py +``` + +Cosmoteer applies mod actions to vanilla data at load time, and a path that does +not resolve, a component reference pointing at nothing, or a misspelled +component `Type` all fail identically: the game crashes on startup and leaves a +log file. The suite parses the installed game's `.rules` tree and checks all +three offline. It also self-checks the parser against known vanilla facts first, +so a broken parser fails loudly instead of passing everything. + +Set `COSMOTEER_DATA` to point at a different install. + +## Status + +Validated against game data version 0.30.4c. The logic is verified statically — +every patch target, component reference and component type is checked against +the shipped `.rules` files — but it has not yet been run in-game. Worth watching +on the first run: whether `NetworkToggleReceiver` with `AllowUncategorized = +false` reads *off* on a ship with no broadcaster, which is the assumption the +vanilla-fallback behaviour rests on. + +## Possible additions + +- A per-radiator UI toggle for auto/manual, which needs a new `ChoiceID` wired + into `Data/gui/game/parts/part_toggles.rules`. +- Making the 98% / 94% band configurable. diff --git a/components/launcher_ammo_signal.rules b/components/launcher_ammo_signal.rules new file mode 100644 index 0000000..e629293 --- /dev/null +++ b/components/launcher_ammo_signal.rules @@ -0,0 +1,23 @@ +// ABOUT ME +// A single component merged into the Missile Launcher. It broadcasts, over the +// heat network, whether this launcher is currently set to Thermal Canister +// Missiles. +// +// Why it exists: banking heat is only worth anything if something on the ship +// actually spends heat as ammunition. On a ship with no thermal launchers, +// holding the heat network near full buys nothing, so the radiators should just +// behave as they do in vanilla. Radiators treat the absence of this broadcast +// as permission to vent freely. +// +// ThermalMissilesToggle is the launcher's own vanilla mode toggle +// (ModeToggle, Mode = MissileType, OnMode = 4), so this signal follows the +// player's ammunition selection live, including mid-battle switches. + +NetworkedThermalAmmoUser +{ + Type = NetworkToggle + ToggleSource = ThermalMissilesToggle + Categories = [ auto_radiator_ammo_user ] + IncludeInPhysical = true + IncludeInBlueprints = false +} diff --git a/components/overclock_overflow_signal.rules b/components/overclock_overflow_signal.rules new file mode 100644 index 0000000..66726c7 --- /dev/null +++ b/components/overclock_overflow_signal.rules @@ -0,0 +1,34 @@ +// ABOUT ME +// Components merged into base_part_overclock, the shared base for every +// overclockable part. They form the mod's safety net. +// +// Why it exists: the threshold band on the thermal battery is a prediction. It +// can be wrong -- if the ship generates heat faster than its radiators can +// clear it, or if heat pipe topology cannot deliver to the batteries quickly +// enough, heat overflows regardless of what percentage the band is set to. +// +// OverflowHeatStorage is the engine's own ground truth for that: heat only ever +// reaches it when the network has already refused the heat, which is the exact +// moment the part starts cooking itself. Any part in that state forces every +// radiator on the network to vent, ignoring the band entirely. +// +// The timer holds the signal for a few seconds so a stream of per-tick overflow +// events becomes one steady "vent now" rather than a flicker. + +Overflow_VentTimer +{ + Type = Timer + Duration = 3 + StartExpired = true + StartTrigger = OverflowHeatStorage + ResetTrigger = OverflowHeatStorage +} + +Overflow_NetworkedForceVent +{ + Type = NetworkToggle + ToggleSource = Overflow_VentTimer + Categories = [ auto_radiator_force_vent ] + IncludeInPhysical = true + IncludeInBlueprints = false +} diff --git a/components/radiator_auto.rules b/components/radiator_auto.rules new file mode 100644 index 0000000..6420a3d --- /dev/null +++ b/components/radiator_auto.rules @@ -0,0 +1,68 @@ +// ABOUT ME +// Components merged into the Radiator. They combine the three network signals +// this mod broadcasts into a single gate, AutoPowerToggle, which mod.rules then +// substitutes for the radiator's vanilla PowerToggle on the parts of the +// radiator that actually dissipate heat. +// +// The player's manual on/off switch (PowerToggle) is preserved and still wins: +// a radiator the player has switched off stays off. The automation can only +// idle a radiator the player has left on. +// +// A radiator vents when ANY of these hold: +// - the heat network is not asking it to hold (no battery, or battery full) +// - no launcher on the network is using thermal ammunition +// - some part is overflowing heat right now (safety net, overrides all) + +HoldVentReceiver +{ + Type = NetworkToggleReceiver + Categories = [ auto_radiator_hold ] + Mode = Any + AllowUncategorized = false +} + +ThermalAmmoReceiver +{ + Type = NetworkToggleReceiver + Categories = [ auto_radiator_ammo_user ] + Mode = Any + AllowUncategorized = false +} + +ForceVentReceiver +{ + Type = NetworkToggleReceiver + Categories = [ auto_radiator_force_vent ] + Mode = Any + AllowUncategorized = false +} + +NotHolding +{ + Type = MultiToggle + Toggles = [ HoldVentReceiver ] + Mode = None +} + +NoThermalAmmo +{ + Type = MultiToggle + Toggles = [ ThermalAmmoReceiver ] + Mode = None +} + +VentAllowed +{ + Type = MultiToggle + Toggles = [ NotHolding, NoThermalAmmo, ForceVentReceiver ] + Mode = Any +} + +// The replacement gate. PowerToggle is the radiator's vanilla UIToggle, so the +// player's switch remains the master control. +AutoPowerToggle +{ + Type = MultiToggle + Toggles = [ PowerToggle, VentAllowed ] + Mode = All +} diff --git a/components/thermal_battery_vent.rules b/components/thermal_battery_vent.rules new file mode 100644 index 0000000..da8c32d --- /dev/null +++ b/components/thermal_battery_vent.rules @@ -0,0 +1,80 @@ +// ABOUT ME +// Components merged into the Thermal Battery. The battery is the ship's heat +// gauge, so it is what decides whether the heat network has surplus worth +// venting, and broadcasts that decision to every radiator on the network. +// +// Polarity is deliberate: the battery broadcasts "HOLD" (do not vent), not +// "vent". A ship with no thermal battery therefore has no broadcaster, every +// radiator's receiver reads off, and radiators behave exactly as in vanilla. +// +// Heat storage fullness causes no damage on its own -- damage only occurs when +// the network refuses heat and it overflows onto the producing part +// (base_part_overclock.rules: HeatDistributionStorage adds InOrder, network +// first, OverflowHeatStorage second). So the band sits high on purpose, keeping +// the network full as a deep ammunition reserve for Thermal Canister Missiles. + +// Vent when the battery is essentially full. +VentCeiling +{ + Type = ThresholdToggle + ValueFrom = HeatStorage + MinAmount = 98% +} + +// Stop venting once there is real headroom again. +VentFloor +{ + Type = ThresholdToggle + ValueFrom = HeatStorage + MaxAmount = 94% +} + +VentCeilingTrigger +{ + Type = ToggleTrigger + Toggle = VentCeiling + TriggerWhenOn = true + TriggerWhenOff = false +} + +VentFloorTrigger +{ + Type = ToggleTrigger + Toggle = VentFloor + TriggerWhenOn = true + TriggerWhenOff = false +} + +// The deadband: latches on at the ceiling, stays on until the floor is reached. +// Without this the radiators would chatter on and off at the threshold. +VentLatch +{ + Type = TriggeredToggle + OnTrigger = VentCeilingTrigger + OffTrigger = VentFloorTrigger +} + +// OR the raw ceiling back in, so a ship that loads already above the ceiling +// vents immediately instead of waiting for a threshold crossing that never comes. +VentActive +{ + Type = MultiToggle + Toggles = [ VentLatch, VentCeiling ] + Mode = Any +} + +HoldVent +{ + Type = MultiToggle + Toggles = [ VentActive ] + Mode = None +} + +NetworkedHoldVent +{ + Type = NetworkToggle + ToggleSource = HoldVent + Categories = [ auto_radiator_hold ] + IncludeInPhysical = true + IncludeInBlueprints = false +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..653ed39 --- /dev/null +++ b/install.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# ABOUT ME +# Installs the Auto Radiator mod into Cosmoteer's user Mods folder. +# +# Why it exists: Cosmoteer loads mods from the user profile directory, which on +# a Proton install lives deep inside the Steam compatdata prefix and is awkward +# to find by hand. This script locates it, validates the mod against the +# installed game data first, and copies only the files the game needs. +# +# Usage: ./install.sh install (validates first) +# ./install.sh --uninstall remove the installed copy + +set -euo pipefail + +MOD_ID="auto_radiator" +SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +find_mods_dir() { + # A Cosmoteer profile folder is named after the Steam ID, so glob for it. + local candidates=( + "$HOME"/.local/share/Steam/steamapps/compatdata/799600/pfx/drive_c/users/steamuser/"Saved Games"/Cosmoteer/*/Mods + /mnt/*/SteamLibrary/steamapps/compatdata/799600/pfx/drive_c/users/steamuser/"Saved Games"/Cosmoteer/*/Mods + "$HOME"/"Saved Games"/Cosmoteer/*/Mods + "$HOME"/Documents/"My Games"/Cosmoteer/*/Mods + ) + for path in "${candidates[@]}"; do + [ -d "$path" ] && printf '%s\n' "$path" && return 0 + done + return 1 +} + +MODS_DIR="${COSMOTEER_MODS_DIR:-$(find_mods_dir || true)}" + +if [ -z "$MODS_DIR" ]; then + echo "Could not find the Cosmoteer Mods folder." >&2 + echo "Set COSMOTEER_MODS_DIR to it and run again." >&2 + exit 1 +fi + +TARGET="$MODS_DIR/$MOD_ID" + +if [ "${1:-}" = "--uninstall" ]; then + rm -rf "$TARGET" + echo "Removed $TARGET" + exit 0 +fi + +echo "Validating against installed game data..." +python3 "$SOURCE_DIR/tests/test_mod.py" + +echo +echo "Installing to $TARGET" +rm -rf "$TARGET" +mkdir -p "$TARGET" +cp "$SOURCE_DIR/mod.rules" "$TARGET/" +cp -r "$SOURCE_DIR/components" "$TARGET/" + +echo "Done. Enable \"Auto Radiator\" in the game's Mods menu." diff --git a/mod.rules b/mod.rules new file mode 100644 index 0000000..b3f993b --- /dev/null +++ b/mod.rules @@ -0,0 +1,108 @@ +// ABOUT ME +// Manifest and patch list for "Auto Radiator". +// +// What it does: radiators stop dumping heat that the ship still wants. Thermal +// Canister Missiles are built from the ship's own heat, so a radiator running +// flat out is competing with the launchers for ammunition -- in vanilla they +// pull from the same pool, with the same Mode, in the same category, and +// Data/ships/priorities.rules defines no ordering between them. +// +// The patches below add components to four vanilla parts and then repoint the +// radiator's three heat-dissipating components from the raw PowerToggle to the +// AutoPowerToggle gate defined in components/radiator_auto.rules. +// +// Nothing here changes a stat or a number. It only changes when radiators run. + +ID = ganter.auto_radiator +Name = "Auto Radiator" +Version = 1.0.0 +CompatibleGameVersions = ["0.30.4c", "0.30.4"] +ModifiesGameplay = true +Author = "Tobias Ganter" + +Description = "Radiators only vent surplus heat instead of running constantly.\n\n"\ + "Thermal Canister Missiles are built from your ship's own heat, and in vanilla "\ + "your radiators compete with your launchers for it -- the heat network is drained "\ + "to keep radiators busy, and your missiles reload at whatever rate your reactors "\ + "happen to produce.\n\n"\ + "With this mod, radiators hold off while the heat network still has room, keeping "\ + "your thermal batteries topped up as a deep missile reserve, and vent once the "\ + "network is nearly full.\n\n"\ + "- Only applies when a launcher is actually set to Thermal Canister Missiles. "\ + "Otherwise radiators behave exactly as in vanilla.\n\n"\ + "- Ships with no thermal battery are unaffected.\n\n"\ + "- Your manual radiator on/off switch still overrides everything.\n\n"\ + "- Safety net: if any part is actually overflowing heat, every radiator vents "\ + "immediately regardless of the threshold." + +Actions +[ + // --- Broadcasters ----------------------------------------------------- + + // The thermal battery decides when the network has surplus. + { + Action = AddBase + AddBaseTo = "/Part/Components" + BaseToAdd = & + } + + // The missile launcher reports whether it is set to thermal ammunition. + { + Action = AddBase + AddBaseTo = "/Part/Components" + BaseToAdd = & + } + + // Every overclockable part reports when it is actually overflowing heat. + { + Action = AddBase + AddBaseTo = "/Part/Components" + BaseToAdd = & + } + + // --- Receiver --------------------------------------------------------- + + { + Action = AddBase + AddBaseTo = "/Part/Components" + BaseToAdd = & + } + + // --- Rewire the radiator --------------------------------------------- + // + // The radiator's dissipation is gated on PowerToggle directly, not on its + // IsOperational toggle, so each of these three components is repointed at + // the new gate. IsOperational and CommandConsumer are deliberately left + // alone: an idling radiator still holds its command points, because it is + // still assigned and will resume without player input. + + // The actual heat removal. + { + Action = Overrides + OverrideIn = "/Part/Components/HeatDissipationConverter" + Overrides + { + OperationalToggle = AutoPowerToggle + } + } + + // The steam plume, so an idling radiator looks idle. + { + Action = Overrides + OverrideIn = "/Part/Components/SteamEffects" + Overrides + { + OperationalToggle = AutoPowerToggle + } + } + + // The dissipation value that drives the glow and the network load readout. + { + Action = Overrides + OverrideIn = "/Part/Components/DissipationSmoothingTargetValue" + Overrides + { + Toggle = AutoPowerToggle + } + } +] diff --git a/tests/rules_parser.py b/tests/rules_parser.py new file mode 100644 index 0000000..1a7d62b --- /dev/null +++ b/tests/rules_parser.py @@ -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. "/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"(?:[^"\\]|\\.)*") # quoted string + | (?P[{}\[\]=:;]) # structural punctuation + | (?P,) # separator, carries no meaning + | (?P\s+) + | (?P[^\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()) diff --git a/tests/test_mod.py b/tests/test_mod.py new file mode 100644 index 0000000..872743e --- /dev/null +++ b/tests/test_mod.py @@ -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 '/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())