From 74d2cf8fc4e992f9a0a5acb019a601b578c093b8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 20:46:30 +0200 Subject: [PATCH] Power bus: a consumer its thermostat has closed is not billed The bus billed every active IPowerConsumer, but a behaviour group stops at its Thermotoggle: a heater above its target never runs its EnergyDraw, yet its full draw (1000-2500/s) was charged every tick. On an idle station that kept the reactor in permanent deficit, overloading at overload efficiency. The bus now skips a consumer whose group has a closed thermostat ahead of it, the same rule it already applies to inactive items; Thermotoggle.Open is the one statement of when a thermostat is open, and Execute returns it. Tests (Soul's F2; S17 survived before): from a hull at one uniform temperature, one tick below the heater's target raises its cells and one above does not, so the heater's own contribution is measured with no reactor heat in it; and the bus bills the heater's draw below its target and not above. This does not keep an idle station warm (Soul's F1, reported separately): the reactor's throttled idle heat alone still drives it past its plateau. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/Thermotoggle.cs | 8 ++-- Assets/Scripts/ServerShared/PowerBus.cs | 14 +++++- tests/Aetheria.Shared.Tests/RunStartTests.cs | 48 +++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Thermotoggle.cs b/Assets/Scripts/ServerShared/Behaviors/Thermotoggle.cs index 05a354d8b..f2ed29ff0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thermotoggle.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thermotoggle.cs @@ -47,8 +47,8 @@ public class Thermotoggle : Behavior TargetTemperature = data.TargetTemperature; } - public override bool Execute(float dt) - { - return Temperature < TargetTemperature ^ _data.HighPass; - } + // Whether the rest of this behaviour's group runs this tick: below the target, or above it for a high-pass one. + public bool Open => Temperature < TargetTemperature ^ _data.HighPass; + + public override bool Execute(float dt) => Open; } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/PowerBus.cs b/Assets/Scripts/ServerShared/PowerBus.cs index 488ea07d2..d0c53b0df 100644 --- a/Assets/Scripts/ServerShared/PowerBus.cs +++ b/Assets/Scripts/ServerShared/PowerBus.cs @@ -92,6 +92,16 @@ public class PowerBus public Draw(EquippedItem item, int tier, float request) { Item = item; Tier = tier; Request = request; } } + private static bool ClosedByThermostat(EquippedItem item, Behavior consumer) + { + foreach (var behavior in item.BehaviorGroups[consumer.Data.Group].Behaviors) + { + if (behavior == consumer) return false; + if (behavior is Thermotoggle { Open: false }) return true; + } + return false; + } + public void Step(float dt) { var reactors = new List(); @@ -119,7 +129,9 @@ public class PowerBus // The other direction of the same rule: a consumer switched off or shut down (Active.Value false) // never runs its Execute, so it must never be billed either -- otherwise a disabled item's demand // starves every live consumer and drains the capacitors for energy nobody spends. - if (behavior is IPowerConsumer consumer && item.Active.Value) + // And a consumer whose group a thermostat has closed ahead of it does not run this tick either, so it is + // not billed: a heater above its target draws nothing. + if (behavior is IPowerConsumer consumer && item.Active.Value && !ClosedByThermostat(item, behavior)) { // Cut 5 (docs/stats-and-power-cut.md ยง1.3): the item's own stored choice wins once it has one; // PowerTiers.Unassigned only survives past EquippedItem's constructor for a consumer added to diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index e6814f0d2..31ea78fc1 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -438,6 +438,54 @@ public sealed class RunStartTests : IDisposable Assert.True(coldest > Freezing, $"the heater's cells fell to {coldest} K (station now {station.MinTemp}..{station.MaxTemp} K)"); } + // The heater itself warms the station: from a hull at one uniform temperature (so nothing conducts and interior + // cells radiate nothing), one tick below the heater's target raises its cells, and one tick above it does not. + [Fact] + public void AStationHeaterHeatsBelowItsTargetAndNotAbove() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + var heater = station.Equipment.Single(IsHeater); + var target = heater.Behaviors.OfType().Single().TargetTemperature; + for (var tick = 0; tick < 10; tick++) zone.Update(.1f); // the power bus is running + + float Step(float start) + { + foreach (var v in station.HullData.Shape.Coordinates) station.Temperature[v.x, v.y] = start; + zone.Update(.1f); + return heater.Temperature - start; + } + var below = Step(target - 20); + var above = Step(target + 20); + Assert.True(below > 0, $"below its target the heater's cells moved {below} K"); + Assert.True(above <= 0, $"above its target the heater's cells moved {above} K"); + } + + // A consumer runs only when its group's thermostat is open, and is billed only then: a heater above its target + // puts no demand on the power bus, below it the bus bills its draw. + [Fact] + public void AHeaterAboveItsTargetDrawsNoPower() + { + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0); + var heater = station.Equipment.Single(IsHeater); + var target = heater.Behaviors.OfType().Single().TargetTemperature; + var request = heater.Behaviors.OfType().Single().PowerRequest(.1f); + Assert.True(request > 0, "the heater asks for power"); + for (var tick = 0; tick < 10; tick++) zone.Update(.1f); // the heater is online + Assert.True(heater.Active.Value, "the heater is active"); + + float Demand(float temperature) + { + foreach (var v in station.HullData.Shape.Coordinates) station.Temperature[v.x, v.y] = temperature; + station.PowerBus.Step(.1f); + return station.PowerBus.TotalDemand; + } + var cold = Demand(target - 20); + var warm = Demand(target + 20); + Assert.True(cold - warm >= request * .99f, $"demand {cold} below the target, {warm} above; the heater asks {request}"); + } + // A station hull with no room left for a heater fails its generation by name, rather than spawning a station // that will freeze. The only station hull on offer is one whose interior takes the smallest docking bay and no more. [Fact]