From 881415d5e330ee698b2c3699bbac49c152b6accb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 05:06:31 +0200 Subject: [PATCH] F2: gate the bus's reactors and consumers on Active, not Online PowerBus.Step billed every equipped IPowerConsumer's request unconditionally and admitted a reactor on Item.Online alone, but Entity.Update only ever runs a behaviour's Execute when the item is Active (Enabled && Online). A consumer the player switched off never executes and never spends anything, yet was still billed, starving live consumers and draining capacitors for nobody. A reactor switched off stayed Online and kept generating even though Reactor.Execute (which produces the heat receipt) never ran for it: free, heat-free power. Both directions now gate on the same predicate the rest of Entity uses: item.Active.Value. Pinned in tests/Aetheria.Shared.Tests/PowerBusTests.cs: DisabledConsumerContributesNoDemand and DisabledReactorGeneratesNoPower -- both fail under the reverted (Online-only / unconditional) mutation. Co-Authored-By: Claude Opus 5 --- Assets/Scripts/ServerShared/PowerBus.cs | 14 ++++++- tests/Aetheria.Shared.Tests/PowerBusTests.cs | 42 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/PowerBus.cs b/Assets/Scripts/ServerShared/PowerBus.cs index 6942688ae..36d27c41f 100644 --- a/Assets/Scripts/ServerShared/PowerBus.cs +++ b/Assets/Scripts/ServerShared/PowerBus.cs @@ -93,14 +93,24 @@ public class PowerBus { switch (behavior) { - case Reactor reactor when reactor.Item.Online.Value: + // F2 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this must agree with the one predicate + // Entity.Update actually gates Execute on (Active.Value, Entity.cs -- Enabled && Online), in both + // directions. Gating on Online alone let a reactor the player switched off (Enabled = false, so + // Active is false but Online stays true) keep generating here even though Reactor.Execute -- the + // behaviour that produces the heat receipt -- never runs for it: free, heat-free power. Capacitor + // has no IPowerConsumer/generation role and is unconditionally a store regardless of its own + // item's state (mirrors pre-existing behaviour; nothing in the ruling asks for that to change). + case Reactor reactor when item.Active.Value: reactors.Add(reactor); break; case Capacitor capacitor: capacitors.Add(capacitor); break; } - if (behavior is IPowerConsumer consumer) + // 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) { // 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/PowerBusTests.cs b/tests/Aetheria.Shared.Tests/PowerBusTests.cs index 07384a0bc..1fb04274d 100644 --- a/tests/Aetheria.Shared.Tests/PowerBusTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerBusTests.cs @@ -208,4 +208,46 @@ public sealed class PowerBusTests : IDisposable Assert.Equal(ship.PowerBus.GrantRatio, drainOne.PowerSupply, 5); Assert.Equal(ship.PowerBus.GrantRatio, drainTwo.PowerSupply, 5); } + + // --- F2 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "one predicate, both directions." Step used to + // --- collect every equipped IPowerConsumer's request unconditionally, but Entity.Update only ever runs a + // --- behaviour's Execute when the item is Active (Enabled && Online) -- so a consumer the player switched + // --- off (Enabled = false) never executes, never spends anything, and yet was still billed here, starving + // --- every live consumer and draining the capacitors for energy nobody spent. A disabled consumer must + // --- contribute nothing to TotalDemand at all. --- + [Fact] + public void DisabledConsumerContributesNoDemand() + { + using var cache = OpenCatalog(); + var (ship, _, _, capacitor) = BuildShip(cache, reactorCharge: 0, startingCapacitorCharge: 100, reactorFirst: true); + var drain = EquipDrain(cache, ship, rate: 50); + drain.Enabled.Value = false; + Assert.False(drain.Active.Value); + + ship.Update(1f); + + Assert.Equal(0f, ship.PowerBus.TotalDemand, 3); + Assert.Equal(100f, capacitor.Charge, 3); // nothing was spent on a request nobody will ever execute + } + + // --- The other direction of the same rule: PowerBus.Step gated a reactor on Item.Online alone, but + // --- Reactor.Execute (the behaviour that actually produces the heat receipt) only runs when Item.Active -- + // --- Enabled && Online -- is true. A reactor the player switched off stays Online (thermal/durability are + // --- fine) but is not Active, so it must not generate here either, or it supplies the whole ship for free + // --- with no heat cost at all. --- + [Fact] + public void DisabledReactorGeneratesNoPower() + { + using var cache = OpenCatalog(); + var (ship, _, reactor, _) = BuildShip(cache, reactorCharge: 100, startingCapacitorCharge: 0, reactorFirst: true); + reactor.Item.Enabled.Value = false; + Assert.True(reactor.Item.Online.Value); + Assert.False(reactor.Item.Active.Value); + EquipDrain(cache, ship, rate: 100); + + ship.Update(1f); + + Assert.Equal(0f, ship.PowerBus.TotalGeneration, 3); + Assert.Equal(0f, ship.PowerBus.TotalGrant, 3); + } }