diff --git a/README.md b/README.md index 3b346a5..003b698 100644 --- a/README.md +++ b/README.md @@ -124,14 +124,37 @@ so a broken parser fails loudly instead of passing everything. Set `COSMOTEER_DATA` to point at a different install. +## How to tell it is working + +There is no separate "auto" mode and nothing to reset. The radiator's on/off +switch is an AND gate: + +- switch **off** → radiator is off, and stays off. The mod never turns it back on. +- switch **on** → the automation decides, moment to moment. + +An auto-idled radiator shows the same "powered off" indicator icon as a manually +switched-off one, and stops emitting its steam plume. That icon appearing on a +radiator you did *not* switch off is the mod working. + ## 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. +Validated against game data version 0.30.4c, and confirmed to load cleanly +(`Enabled mods: [User Folder] - ganter.auto_radiator`). + +**v1.0.0 loaded without error and did nothing in game.** It broadcast its +signals with `NetworkToggle` / `NetworkToggleReceiver`. All three vanilla uses of +those types set `IncludeInPhysical = false` — they are blueprint stat-display +plumbing and carry no signal on a real ship, so every receiver read off, the +vent gate was permanently open, and radiators behaved exactly as in vanilla. + +**v1.1.0** carries the signals as `NetworkValue` / `NetworkValueReceiver` +instead, which is what vanilla's thermal amplification pump uses to drive actual +physical behaviour, and which never sets `IncludeInPhysical` anywhere in the +shipped data. `tests/test_mod.py` now fails on any use of the blueprint-only +types, and separately re-checks that premise against the installed game so the +guard cannot quietly go stale. + +The v1.1.0 logic is verified statically but has not yet been confirmed in-game. ## Possible additions diff --git a/components/launcher_ammo_signal.rules b/components/launcher_ammo_signal.rules index e629293..a7d0fac 100644 --- a/components/launcher_ammo_signal.rules +++ b/components/launcher_ammo_signal.rules @@ -1,23 +1,36 @@ // 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. +// Components merged into the Missile Launcher. They broadcast, 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. +// behave as they do in vanilla. Radiators treat a received 0 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. +// +// Carried as a NetworkValue rather than a NetworkToggle: vanilla marks every +// NetworkToggle IncludeInPhysical = false, so they do not carry a signal on a +// real ship. -NetworkedThermalAmmoUser +AutoRad_One { Type = StaticValue; Value = 1 } +AutoRad_Zero { Type = StaticValue; Value = 0 } + +AutoRad_ThermalAmmoValue { - Type = NetworkToggle - ToggleSource = ThermalMissilesToggle + Type = ToggleValueSelector + Toggle = ThermalMissilesToggle + ToggleOnValue = AutoRad_One + ToggleOffValue = AutoRad_Zero +} + +AutoRad_NetworkedThermalAmmo +{ + Type = NetworkValue + ValueSource = AutoRad_ThermalAmmoValue Categories = [ auto_radiator_ammo_user ] - IncludeInPhysical = true IncludeInBlueprints = false } diff --git a/components/overclock_overflow_signal.rules b/components/overclock_overflow_signal.rules index 66726c7..47293b3 100644 --- a/components/overclock_overflow_signal.rules +++ b/components/overclock_overflow_signal.rules @@ -14,8 +14,12 @@ // // 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. +// +// Carried as a NetworkValue rather than a NetworkToggle: vanilla marks every +// NetworkToggle IncludeInPhysical = false, so they do not carry a signal on a +// real ship. -Overflow_VentTimer +AutoRad_OverflowTimer { Type = Timer Duration = 3 @@ -24,11 +28,21 @@ Overflow_VentTimer ResetTrigger = OverflowHeatStorage } -Overflow_NetworkedForceVent +AutoRad_One { Type = StaticValue; Value = 1 } +AutoRad_Zero { Type = StaticValue; Value = 0 } + +AutoRad_OverflowValue { - Type = NetworkToggle - ToggleSource = Overflow_VentTimer + Type = ToggleValueSelector + Toggle = AutoRad_OverflowTimer + ToggleOnValue = AutoRad_One + ToggleOffValue = AutoRad_Zero +} + +AutoRad_NetworkedForceVent +{ + Type = NetworkValue + ValueSource = AutoRad_OverflowValue Categories = [ auto_radiator_force_vent ] - IncludeInPhysical = true IncludeInBlueprints = false } diff --git a/components/radiator_auto.rules b/components/radiator_auto.rules index 6420a3d..73d51d5 100644 --- a/components/radiator_auto.rules +++ b/components/radiator_auto.rules @@ -1,60 +1,77 @@ // 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. +// Components merged into the Radiator. They receive the three network values +// this mod broadcasts and combine them 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. +// +// Each broadcast arrives as a number, 1 or 0, summarised across the network by +// a NetworkValueReceiver and turned back into a toggle by a threshold at 0.5. +// A network with no broadcaster at all yields 0, which is why a ship without +// thermal batteries or thermal launchers keeps vanilla behaviour. // // 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) +// - the 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) +// - some part is overflowing heat right now (safety net, overrides all) -HoldVentReceiver +AutoRad_HoldSignal { - Type = NetworkToggleReceiver + Type = NetworkValueReceiver Categories = [ auto_radiator_hold ] - Mode = Any + Mode = Max AllowUncategorized = false } -ThermalAmmoReceiver +AutoRad_ThermalAmmoSignal { - Type = NetworkToggleReceiver + Type = NetworkValueReceiver Categories = [ auto_radiator_ammo_user ] - Mode = Any + Mode = Max AllowUncategorized = false } -ForceVentReceiver +AutoRad_ForceVentSignal { - Type = NetworkToggleReceiver + Type = NetworkValueReceiver Categories = [ auto_radiator_force_vent ] - Mode = Any + Mode = Max AllowUncategorized = false } -NotHolding +AutoRad_IsHolding { - Type = MultiToggle - Toggles = [ HoldVentReceiver ] - Mode = None + Type = ThresholdToggle + ValueFrom = AutoRad_HoldSignal + MinAmount = 0.5 } -NoThermalAmmo +AutoRad_ThermalAmmoInUse { - Type = MultiToggle - Toggles = [ ThermalAmmoReceiver ] - Mode = None + Type = ThresholdToggle + ValueFrom = AutoRad_ThermalAmmoSignal + MinAmount = 0.5 } -VentAllowed +AutoRad_ForceVent +{ + Type = ThresholdToggle + ValueFrom = AutoRad_ForceVentSignal + MinAmount = 0.5 +} + +AutoRad_VentAllowed { Type = MultiToggle - Toggles = [ NotHolding, NoThermalAmmo, ForceVentReceiver ] + Toggles + [ + { Toggle = AutoRad_IsHolding; Invert = true } + { Toggle = AutoRad_ThermalAmmoInUse; Invert = true } + AutoRad_ForceVent + ] Mode = Any } @@ -63,6 +80,6 @@ VentAllowed AutoPowerToggle { Type = MultiToggle - Toggles = [ PowerToggle, VentAllowed ] + Toggles = [ PowerToggle, AutoRad_VentAllowed ] Mode = All } diff --git a/components/thermal_battery_vent.rules b/components/thermal_battery_vent.rules index da8c32d..e2f51ca 100644 --- a/components/thermal_battery_vent.rules +++ b/components/thermal_battery_vent.rules @@ -3,18 +3,26 @@ // 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. +// The broadcast is a NetworkValue carrying 1 (hold) or 0 (vent), not a +// NetworkToggle. Every NetworkToggle in vanilla sets IncludeInPhysical = false +// -- they exist only for blueprint stat display and do not carry a signal on a +// real ship. NetworkValue has no such restriction and is what the thermal +// amplification pump uses to drive actual physical behaviour. +// +// Polarity is deliberate: the battery broadcasts "HOLD", not "vent". A ship +// with no thermal battery has no broadcaster, every radiator reads 0, 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. +// +// All names are prefixed to avoid colliding with vanilla component names. // Vent when the battery is essentially full. -VentCeiling +AutoRad_VentCeiling { Type = ThresholdToggle ValueFrom = HeatStorage @@ -22,59 +30,63 @@ VentCeiling } // Stop venting once there is real headroom again. -VentFloor +AutoRad_VentFloor { Type = ThresholdToggle ValueFrom = HeatStorage MaxAmount = 94% } -VentCeilingTrigger +AutoRad_CeilingTrigger { Type = ToggleTrigger - Toggle = VentCeiling + Toggle = AutoRad_VentCeiling TriggerWhenOn = true TriggerWhenOff = false } -VentFloorTrigger +AutoRad_FloorTrigger { Type = ToggleTrigger - Toggle = VentFloor + Toggle = AutoRad_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 +AutoRad_VentLatch { Type = TriggeredToggle - OnTrigger = VentCeilingTrigger - OffTrigger = VentFloorTrigger + OnTrigger = AutoRad_CeilingTrigger + OffTrigger = AutoRad_FloorTrigger } // 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 +AutoRad_VentActive { Type = MultiToggle - Toggles = [ VentLatch, VentCeiling ] + Toggles = [ AutoRad_VentLatch, AutoRad_VentCeiling ] Mode = Any } -HoldVent +AutoRad_One { Type = StaticValue; Value = 1 } +AutoRad_Zero { Type = StaticValue; Value = 0 } + +// 1 while the network should hold its heat, 0 while it should vent. +AutoRad_HoldValue { - Type = MultiToggle - Toggles = [ VentActive ] - Mode = None + Type = ToggleValueSelector + Toggle = AutoRad_VentActive + ToggleOnValue = AutoRad_Zero + ToggleOffValue = AutoRad_One } -NetworkedHoldVent +AutoRad_NetworkedHold { - Type = NetworkToggle - ToggleSource = HoldVent + Type = NetworkValue + ValueSource = AutoRad_HoldValue Categories = [ auto_radiator_hold ] - IncludeInPhysical = true IncludeInBlueprints = false } diff --git a/mod.rules b/mod.rules index b3f993b..c19bb87 100644 --- a/mod.rules +++ b/mod.rules @@ -8,14 +8,15 @@ // 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. +// radiator's heat dissipation, steam plume, dissipation readout and power +// indicator 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 +Version = 1.1.0 CompatibleGameVersions = ["0.30.4c", "0.30.4"] ModifiesGameplay = true Author = "Tobias Ganter" @@ -105,4 +106,16 @@ Actions Toggle = AutoPowerToggle } } + + // The "powered off" indicator icon, so an idling radiator is visibly idle + // rather than looking identical to a working one. Without this the only cue + // that the mod is doing anything is the absence of the steam plume. + { + Action = Overrides + OverrideIn = "/Part/Components/Indicators/Indicators/0" + Overrides + { + Toggle = AutoPowerToggle + } + } ] diff --git a/tests/test_mod.py b/tests/test_mod.py index 872743e..a1dd9d2 100644 --- a/tests/test_mod.py +++ b/tests/test_mod.py @@ -231,7 +231,19 @@ def test_component_references_resolve(): toggles = component.get("Toggles") if toggles is not None and toggles.kind == "list": for key in toggles.order: - ref = toggles.children[key].value.strip() + 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}'", @@ -280,7 +292,80 @@ def test_component_types_exist(): # --------------------------------------------------------------------------- -# 5. The mod manifest must declare the fields Cosmoteer requires. +# 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():