diff --git a/components/launcher_ammo_signal.rules b/components/launcher_ammo_signal.rules index a7d0fac..7e569c4 100644 --- a/components/launcher_ammo_signal.rules +++ b/components/launcher_ammo_signal.rules @@ -34,3 +34,22 @@ AutoRad_NetworkedThermalAmmo Categories = [ auto_radiator_ammo_user ] IncludeInBlueprints = false } + +// Routes the broadcast onto the heat network. NetworkHeatOutputProxy is what +// the launcher's thermal ports anchor on. Those ports are only operational in +// thermal missile mode, so a launcher loaded with anything else is simply not +// on the network -- which is the behaviour we want anyway. +AutoRad_Router +{ + Type = NetworkRouter + RouteGenerators + [ + { + Type = Bidirectional + Routes + [ + [NetworkHeatOutputProxy, AutoRad_NetworkedThermalAmmo, 0] + ] + } + ] +} diff --git a/components/overclock_overflow_signal.rules b/components/overclock_overflow_signal.rules index 47293b3..494d801 100644 --- a/components/overclock_overflow_signal.rules +++ b/components/overclock_overflow_signal.rules @@ -46,3 +46,21 @@ AutoRad_NetworkedForceVent Categories = [ auto_radiator_force_vent ] IncludeInBlueprints = false } + +// Routes the broadcast onto the heat network. NetworkHeatInput is what this +// base's thermal ports anchor on (BASE_THERMAL_PORT: RouteSource = +// &../HEAT_NETWORK_INPUT). +AutoRad_Router +{ + Type = NetworkRouter + RouteGenerators + [ + { + Type = Bidirectional + Routes + [ + [NetworkHeatInput, AutoRad_NetworkedForceVent, 0] + ] + } + ] +} diff --git a/components/thermal_battery_vent.rules b/components/thermal_battery_vent.rules index e2f51ca..a67c59d 100644 --- a/components/thermal_battery_vent.rules +++ b/components/thermal_battery_vent.rules @@ -90,3 +90,24 @@ AutoRad_NetworkedHold Categories = [ auto_radiator_hold ] IncludeInBlueprints = false } + +// A network component is only actually attached to the network if a route +// reaches it. The part's NetworkPort anchors on one component -- here +// NetworkedHeatStorage -- and a NetworkRouter bridges that anchor to every +// other network component on the part. The thermal battery ships no router, +// because vanilla has nothing else to reach, so the broadcast above needs one +// or it is never connected to anything and reads as absent. +AutoRad_Router +{ + Type = NetworkRouter + RouteGenerators + [ + { + Type = Bidirectional + Routes + [ + [NetworkedHeatStorage, AutoRad_NetworkedHold, 0] + ] + } + ] +} diff --git a/mod.rules b/mod.rules index e7807b4..1ee0916 100644 --- a/mod.rules +++ b/mod.rules @@ -16,7 +16,7 @@ ID = ganter.auto_radiator Name = "Auto Radiator" -Version = 1.2.0 +Version = 1.3.0 CompatibleGameVersions = ["0.30.4c", "0.30.4"] ModifiesGameplay = true Author = "Tobias Ganter" @@ -69,6 +69,25 @@ Actions BaseToAdd = & } + // --- Attach the radiator's receivers to the network ------------------- + // + // A network component only reaches the network if a route leads to it. The + // radiator's ports anchor on NetworkedHeatBuffer, and its existing Router + // bridges that anchor to the other network components -- which is how + // vanilla's own NetworkedThermalDissipationLoadValue gets connected. + // Without these three entries the receivers are inert and read nothing, + // which makes the vent gate permanently open. + { + Action = AddMany + AddTo = "/Part/Components/Router/RouteGenerators/0/Routes" + ManyToAdd + [ + [NetworkedHeatBuffer, AutoRad_HoldSignal, 0] + [NetworkedHeatBuffer, AutoRad_ThermalAmmoSignal, 0] + [NetworkedHeatBuffer, AutoRad_ForceVentSignal, 0] + ] + } + // --- Rewire the radiator --------------------------------------------- // // The radiator's dissipation is gated on PowerToggle directly, not on its diff --git a/tests/test_mod.py b/tests/test_mod.py index 4c2de67..750965a 100644 --- a/tests/test_mod.py +++ b/tests/test_mod.py @@ -432,6 +432,106 @@ def test_component_fields_exist_in_schema(): ) +# --------------------------------------------------------------------------- +# 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"} + + +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", + ) + + # --------------------------------------------------------------------------- # 7. Added indicators must bind to a real toggle and a real atlas sprite. # ---------------------------------------------------------------------------