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]