diff --git a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs index a49685780..c81c63c16 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs @@ -61,7 +61,7 @@ public class AetherDriveData : BehaviorData } } -public class AetherDrive : Behavior +public class AetherDrive : Behavior, IPowerConsumer { private AetherDriveData _data; private float3 _axis; @@ -89,6 +89,28 @@ public class AetherDrive : Behavior _data = data; } + // Cut 3 (docs/stats-and-power-cut.md): the request PowerBus needs before Execute runs. Only the rotor + // spin-up (torque accelerating Rpm toward MaximumRpm) costs power -- spending existing spin into thrust + // (Execute's decay-to-thrust arithmetic below) is free, same as before. Pure and side-effect-free: it reads + // Rpm but does not write it, so Execute's own identical arithmetic a few lines later -- which does perform + // the real decay -- produces the same numbers Rpm actually moves by. Accepts the recompute (Q3's ruling). + public float PowerRequest(float dt) + { + var couplingLambda = _data.CouplingLambda * Item.Evaluate(_data.LambdaMultiplier) * max(abs(_axis), Evaluate(_data.PassiveCoupling)); + var rpmAfterDecay = decay(Rpm, couplingLambda, dt); + var maximumRpm = Evaluate(_data.MaximumRpm); + var torqueProfile = float3( + _data.TorqueProfile.Evaluate(rpmAfterDecay.x / maximumRpm), + _data.TorqueProfile.Evaluate(rpmAfterDecay.y / maximumRpm), + _data.TorqueProfile.Evaluate(rpmAfterDecay.z / maximumRpm)); + var potentialTorque = Evaluate(_data.Torque) * torqueProfile; + var potentialRpmDelta = potentialTorque / length(_data.RotorMass) * dt; + var actualRpmDelta = min(maximumRpm - rpmAfterDecay, potentialRpmDelta); + var torqueRatio = actualRpmDelta / potentialRpmDelta; + var draw = torqueRatio * Evaluate(_data.EnergyDraw) / 3; + return (draw.x + draw.y + draw.z) * dt; + } + public override bool Execute(float dt) { var rotorSpeed = Rpm * _data.RotorDiameter / 100; @@ -129,19 +151,18 @@ public class AetherDrive : Behavior var potentialRpmDelta = potentialTorque / length(_data.RotorMass) * dt; var actualRpmDelta = min(MaximumRpm - Rpm, potentialRpmDelta); var torqueRatio = actualRpmDelta / potentialRpmDelta; - var draw = torqueRatio * Evaluate(_data.EnergyDraw) / 3; - + Item.SetAudioParameter(SpecialAudioParameter.Intensity, max(max(abs(_axis.x), abs(_axis.y)), abs(_axis.z))); Item.SetAudioParameter(_data.RpmAudioParameter, (Rpm.x + Rpm.y + Rpm.z) / 3 / MaximumRpm); Item.SetAudioParameter(_data.TorqueRatioAudioParameter, max(max(torqueRatio.x, torqueRatio.y), torqueRatio.z)); - if (Entity.TryConsumeEnergy((draw.x + draw.y + draw.z)*dt)) + if (Item.PowerSupply >= 1f) { Rpm += actualRpmDelta; return true; } - - + + return false; } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 4013d5c93..2cc6be088 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -134,6 +134,17 @@ public interface IPopulationAssignment int AssignedPopulation { get; set; } } +// Cut 3 (docs/stats-and-power-cut.md §1.2): a behaviour that wants to draw from its entity's PowerBus declares a +// request instead of spending energy itself. PowerBus.Step calls this on every consumer once per tick, before +// any behaviour's Execute runs, so PowerRequest must be computable from state that is already current at that +// point -- a resolved stat and, where the map calls for one, a behaviour-supplied scalar such as Thruster's +// throttle axis (set externally before the tick) -- never from work Execute itself would otherwise do. The +// behaviour reads back how much of its own request was granted from Item.PowerSupply during its own Execute. +public interface IPowerConsumer +{ + float PowerRequest(float dt); +} + [Inspectable, Union(0, typeof(GuidedWeaponData)), Union(1, typeof(LauncherData)), diff --git a/Assets/Scripts/ServerShared/Behaviors/Capacitor.cs b/Assets/Scripts/ServerShared/Behaviors/Capacitor.cs index d05b7725b..4139c5a93 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Capacitor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Capacitor.cs @@ -42,6 +42,11 @@ public class Capacitor : Behavior AddHeat(abs(charge) * (1-Efficiency)); } + // Cut 3 (docs/stats-and-power-cut.md): PowerBus needs Capacity before this behaviour's own Execute runs this + // tick (the bus steps first, §1.2), so it resolves the stat itself rather than reading the cached property, + // which still holds last tick's value at that point. + public float ResolveCapacity() => Evaluate(_data.Capacity); + public Capacitor(CapacitorData data, EquippedItem item) : base(data, item) { _data = data; diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 26027ca4b..a1a9191b8 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -25,7 +25,7 @@ public class ConstantWeaponData : WeaponData } } -public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior +public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerConsumer { private ConstantWeaponData _data; private int _ammo = 1; @@ -72,6 +72,11 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior _data = data; } + // Cut 3 (docs/stats-and-power-cut.md): the request the bus needs before Execute runs -- what continuing to + // fire this tick would cost, or nothing when not firing or safed. _firing is set externally (Activate/ + // Deactivate) before Entity.Update calls PowerBus.Step, so it is already current when this runs. + public float PowerRequest(float dt) => _firing && StanceAllowsFire ? Evaluate(_data.Energy) * dt : 0f; + public override bool Execute(float dt) { base.Execute(dt); @@ -84,7 +89,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior } if (_firing) { - if (!Entity.TryConsumeEnergy(Evaluate(_data.Energy) * dt)) + if (Item != null && Item.PowerSupply < 1f) { _firing = false; OnStopFiring?.Invoke(); diff --git a/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs b/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs index 5fd4b9277..7f1bb5ea3 100644 --- a/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs +++ b/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs @@ -27,7 +27,7 @@ public class EnergyDrawData : BehaviorData } } -public class EnergyDraw : Behavior +public class EnergyDraw : Behavior, IPowerConsumer { private EnergyDrawData _data; @@ -41,8 +41,15 @@ public class EnergyDraw : Behavior _data = data; } + // Cut 3 (docs/stats-and-power-cut.md): the request PowerBus needs before Execute runs, in place of the + // direct Entity.TryConsumeEnergy spend that used to happen inside Execute. + public float PowerRequest(float dt) => Evaluate(_data.EnergyDraw) * (_data.PerSecond ? dt : 1); + public override bool Execute(float dt) { - return Entity.TryConsumeEnergy(Evaluate(_data.EnergyDraw) * (_data.PerSecond ? dt : 1)); + // A consumable-hosted instance (Item null) has no PowerBus entry (§1.2's grants are keyed by + // EquippedItem); named rather than silently assumed away, it always succeeds here, the same way every + // other context-dependent factor a ConsumableItemEffect answers with the identity elsewhere in this cut. + return Item == null || Item.PowerSupply >= 1f; } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index b5f5d2dec..af997456d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -95,7 +95,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior // If 1 ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case - if(_data.SingleAmmoBurst && (!Entity.TryConsumeEnergy(Energy) || !UseAmmo())) return; + // Cut 3 (docs/stats-and-power-cut.md): one of the four instant draws out of scope for the bus this cut + // (a burst) -- named, temporary exception, spending capacitor charge directly. Cut 4's input capacitor + // closes this. + if(_data.SingleAmmoBurst && (!Entity.TrySpendCapacitorCharge(Energy) || !UseAmmo())) return; _burstRemaining = (int) BurstCount; _burstInterval = BurstTime / _burstRemaining; @@ -175,7 +178,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior { // If multiple ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case - if (!_data.SingleAmmoBurst && (!Entity.TryConsumeEnergy(Energy) || !UseAmmo())) + // Cut 3 (docs/stats-and-power-cut.md): one of the four instant draws out of scope this cut (a shot); + // see Trigger()'s SingleAmmoBurst branch above for the same named exception. + if (!_data.SingleAmmoBurst && (!Entity.TrySpendCapacitorCharge(Energy) || !UseAmmo())) { _burstRemaining = 0; return false; diff --git a/Assets/Scripts/ServerShared/Behaviors/Radiator.cs b/Assets/Scripts/ServerShared/Behaviors/Radiator.cs index 3f31cdbb0..8412d382b 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Radiator.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Radiator.cs @@ -39,15 +39,15 @@ public class RadiatorData : BehaviorData } } -public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior +public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior, IPowerConsumer { public float RadiatorTemperature { get; private set; } - + public float Emissivity { get; private set; } public float PumpedHeat { get; private set; } public float WasteHeat { get; private set; } public float EnergyUsage { get; private set; } - + private RadiatorData _data; public Radiator(RadiatorData data, EquippedItem item) : base(data, item) @@ -59,6 +59,17 @@ public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior _data = data; } + // Cut 3 (docs/stats-and-power-cut.md): mirrors Execute's own early-out below -- a radiator that would not + // even try to pump this tick (waste would outrun pump capacity) requests nothing, exactly like before. + public float PowerRequest(float dt) + { + var pumpedHeat = Evaluate(_data.PumpedHeat); + var wasteHeat = Evaluate(_data.WasteHeat); + var tempRatio = max(RadiatorTemperature / Temperature, 1); + if (tempRatio > pumpedHeat / wasteHeat) return 0f; + return Evaluate(_data.EnergyUsage) * tempRatio * dt; + } + public override bool Execute(float dt) { PumpedHeat = Evaluate(_data.PumpedHeat); @@ -67,12 +78,12 @@ public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior var itemTemperature = Temperature; var tempRatio = max(RadiatorTemperature / itemTemperature, 1); - + // Temperature ratio would cause more waste heat than pump capacity, stop executing if (tempRatio > PumpedHeat / WasteHeat) return true; - if (!Entity.TryConsumeEnergy(EnergyUsage * tempRatio * dt)) return false; - + if (Item != null && Item.PowerSupply < 1f) return false; + var pumpedHeat = PumpedHeat * max(itemTemperature - _data.TemperatureFloor, 0); // Radiator temperature is below temperature floor, stop executing diff --git a/Assets/Scripts/ServerShared/Behaviors/Reactor.cs b/Assets/Scripts/ServerShared/Behaviors/Reactor.cs index 4a65a337f..1fddd3c4e 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Reactor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Reactor.cs @@ -2,12 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -using System; -using System.Collections.Generic; using System.Linq; using MessagePack; using Newtonsoft.Json; -using UniRx; using CultMath; using static CultMath.math; @@ -37,118 +34,68 @@ public class ReactorData : BehaviorData } } -public class Reactor : Behavior, IOrderedBehavior, IDisposable +public class Reactor : Behavior, IOrderedBehavior { private ReactorData _data; + // Cut 3 (docs/stats-and-power-cut.md §1.2): reported total, not a sink. Nothing writes into this from + // outside any more -- PowerBus.NetDraw is the only input Execute reads to decide overload/throttle. public float Draw { get; private set; } - + public float CurrentLoadRatio { get; private set; } public int Order => 100; - private List _capacitors; - - private List _subscriptions = new List(); - public Reactor(ReactorData data, EquippedItem item) : base(data, item) { _data = data; - FindCapacitors(); } public Reactor(ReactorData data, ConsumableItemEffect item) : base(data, item) { _data = data; - FindCapacitors(); } - private void FindCapacitors() - { - _capacitors = Entity.GetBehaviors().ToList(); - _subscriptions.Add(Entity.Equipment.ObserveAdd().Subscribe(onAdd => - { - var capacitor = onAdd.Value.GetBehavior(); - if (capacitor != null) _capacitors.Add(capacitor); - })); - _subscriptions.Add(Entity.Equipment.ObserveRemove().Subscribe(onRemove => - { - var capacitor = onRemove.Value.GetBehavior(); - if (capacitor != null) _capacitors.Remove(capacitor); - })); - } - - public void ConsumeEnergy(float energy) - { - Draw += energy; - } + // Cut 3: the resolved generation PowerBus.Step needs before this behaviour's own Execute runs this tick. + public float Generation(float dt) => Evaluate(_data.Charge) * dt; public override bool Execute(float dt) { - var charge = Evaluate(_data.Charge) * dt; + var charge = Generation(dt); var efficiency = Evaluate(_data.Efficiency); - - // This behavior executes last, so any components drawing power have already done so - - // Subtract the baseline charge from draw - Draw -= charge; - - // Generate heat using baseline efficiency var heat = charge / efficiency; - // We have an energy deficit, have to overload the reactor - if (Draw > .01f) + // Cut 3: PowerBus already decided, for the whole entity, how much demand generation and stored charge + // could not cover (positive) or how much generation was left over after demand and after topping up + // every capacitor (negative). Split evenly across every online reactor, the same way + // Entity.TryConsumeEnergy used to divide unmet demand among reactors before this behaviour ever saw it. + var onlineReactors = Entity.GetBehaviors().Count(r => r.Item.Online.Value); + var share = onlineReactors > 0 ? Entity.PowerBus.NetDraw / onlineReactors : 0f; + + if (share > .01f) { - CurrentLoadRatio = (Draw + charge) / max(charge, .01f); + // Deficit: the bus already drained every capacitor it could; this is what is left. Overload power + // always neutralizes it, at overload efficiency. + CurrentLoadRatio = (share + charge) / max(charge, .01f); var overloadEfficiency = Evaluate(_data.OverloadEfficiency); - - // Generate heat using overload efficiency, usually much less efficient! - heat += Draw / overloadEfficiency; - - // Overload power will always neutralize the energy deficit - Draw = 0; + heat += share / overloadEfficiency; + Draw = share; } - - // We have an energy surplus, try to store energy in our capacitors - if (Draw < -.01f) + else if (share < -.01f) { - int nonFullCapacitorCount; - do - { - var chargeToAdd = -Draw; - nonFullCapacitorCount = _capacitors.Count(c => c.Charge < c.Capacity - .01f); - foreach (var capacitor in _capacitors) - { - if (capacitor.Charge < capacitor.Capacity - .01f) - { - var chargeAdded = min(chargeToAdd / nonFullCapacitorCount, capacitor.Capacity - capacitor.Charge); - capacitor.AddCharge(chargeAdded); - Draw += chargeAdded; - } - } - } while (nonFullCapacitorCount > 0 && Draw < -.01f); - } - - // We still have an energy surplus, try to throttle the reactor to reduce heat generation - if (Draw < -.01f) - { - CurrentLoadRatio = (Draw + charge) / max(charge, .01f); - heat -= Draw / efficiency * (1 - 1 / Evaluate(_data.ThrottlingFactor)); + // Surplus the bus could not absorb into any capacitor (all full): throttle to reduce heat generation. + CurrentLoadRatio = (share + charge) / max(charge, .01f); + heat -= share / efficiency * (1 - 1 / Evaluate(_data.ThrottlingFactor)); Draw = 0; } else { CurrentLoadRatio = 1; + Draw = 0; } - + Item.SetAudioParameter(SpecialAudioParameter.Intensity, max(.25f, 1 - 1 / CurrentLoadRatio)); - + AddHeat(heat); return true; } - - public void Dispose() - { - foreach(var sub in _subscriptions) - sub.Dispose(); - } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 417bb453b..4a50821f6 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -80,9 +80,11 @@ public class Sensor : Behavior, IEventBehavior OnPingEnd = null; } + // Cut 3 (docs/stats-and-power-cut.md): one of the four instant draws out of scope for the bus this cut (a + // ping) -- named, temporary exception, spending capacitor charge directly. Cut 4 closes this. public void Ping() { - if(_pingCooldown < 0 && Entity.TryConsumeEnergy(Evaluate(_data.PingEnergy))) + if(_pingCooldown < 0 && Entity.TrySpendCapacitorCharge(Evaluate(_data.PingEnergy))) { Entity.VisibilitySources[this] = Evaluate(_data.PingVisibility); _pinging = true; diff --git a/Assets/Scripts/ServerShared/Behaviors/Shield.cs b/Assets/Scripts/ServerShared/Behaviors/Shield.cs index 474a3f37e..7a706b126 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Shield.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Shield.cs @@ -49,14 +49,17 @@ public class Shield : Behavior, IProgressBehavior return true; } + // Cut 3 (docs/stats-and-power-cut.md): one of the four instant draws out of scope for the bus this cut (a + // hit taken, not a chosen activation) -- named, temporary exception, spending capacitor charge directly + // instead of through PowerBus. Cut 4 gives it a real continuous reserve model and closes this. public bool CanTakeHit(DamageType type, float damage) { - return Entity.CanConsumeEnergy(damage * EnergyUsage); + return Entity.CanSpendCapacitorCharge(damage * EnergyUsage); } public void TakeHit(DamageType type, float damage) { - Entity.TryConsumeEnergy(damage * EnergyUsage); + Entity.TrySpendCapacitorCharge(damage * EnergyUsage); AddHeat(damage / Efficiency); } diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index 76bbc1adb..981e6e6ca 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -37,7 +37,7 @@ public class ThrusterData : BehaviorData } } -public class Thruster : Behavior, IAnalogBehavior +public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer { public float Thrust { get; private set; } public float Torque { get; } @@ -71,10 +71,15 @@ public class Thruster : Behavior, IAnalogBehavior Thrust = Evaluate(_data.Thrust); } + // Cut 3 (docs/stats-and-power-cut.md): the resolved stat times the behaviour-supplied throttle scalar (§1.2), + // exactly the shape the map names. _input is set externally (the ship's controls) before Entity.Update calls + // PowerBus.Step, so it is already current when this runs. + public float PowerRequest(float dt) => _input > .01f ? _input * Evaluate(_data.EnergyUsage) : 0f; + public override bool Execute(float dt) { Item.SetAudioParameter(SpecialAudioParameter.Intensity, _input); - if(_input > .01f && Entity.TryConsumeEnergy(_input * Evaluate(_data.EnergyUsage))) + if(_input > .01f && Item.PowerSupply >= 1f) { Thrust = Evaluate(_data.Thrust); Entity.Velocity -= Direction.xz * _input * Thrust / Entity.Mass * dt; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index ba3e09df0..72466ba05 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -67,6 +67,10 @@ public abstract class Entity // so it is collected with the entity instead of surviving it for the life of the process (§0.3, §1.1). public readonly StatResolver Resolver = new StatResolver(); + // Cut 3 (docs/stats-and-power-cut.md): the sole owner of every power grant for this entity, same lifecycle + // reasoning as Resolver above (§1.2). + public readonly PowerBus PowerBus; + public EquippedItem[,] GearOccupancy; public HardpointData[,] Hardpoints; public float[,] Armor; @@ -75,7 +79,6 @@ public abstract class Entity private EquippedItem[] _orderedEquipment; private List _weapons = new List(); private List _capacitors = new List(); - private List _reactors = new List(); private List _heatsinks = new List(); private List _activeConsumables = new List(); @@ -349,6 +352,7 @@ public abstract class Entity Hull = hull; HullData = itemManager.GetData(hull) as HullData; Name = HullData.Name; + PowerBus = new PowerBus(this); MapEntity(); WeaponGroups = new (List weapons, List items)[itemManager.GameplaySettings.WeaponGroupCount]; for(int i=0; i cap.Charge); - int onlineReactors = _reactors.Count(reactor=>reactor.Item.Online.Value); - return capEnergy > energy || onlineReactors > 0; - } + // Cut 3 (docs/stats-and-power-cut.md): replaces CanConsumeEnergy/TryConsumeEnergy for the four instant draws + // the cut map names as out of scope for the bus this cut (a burst, a shot, a ping, a hit taken) -- the named, + // temporary exception the map accepts, closed by Cut 4's input capacitors. Capacitor charge only: a reactor + // no longer bails out a draw it cannot cover for free, which is exactly the behaviour Cut 3 exists to remove + // (§0.5 -- "every draw succeeds while one reactor is online"). Atomic: either every capacitor together holds + // enough charge and it is spent, or nothing moves -- never TryConsumeEnergy's old partial drain-then-refuse. + public bool CanSpendCapacitorCharge(float energy) => _capacitors.Sum(cap => cap.Charge) >= energy; - public bool TryConsumeEnergy(float energy) + public bool TrySpendCapacitorCharge(float energy) { if (energy < .01f) return true; + if (!CanSpendCapacitorCharge(energy)) return false; + + var remaining = energy; int chargedCapacitors; do { chargedCapacitors = _capacitors.Count(capacitor => capacitor.Charge > .01f); - var chargeToRemove = energy; + if (chargedCapacitors == 0) break; + var share = remaining / chargedCapacitors; foreach (var cap in _capacitors) { - if(cap.Charge > 0.01f) - { - var chargeRemoved = min(chargeToRemove / chargedCapacitors, cap.Charge); - cap.AddCharge(-chargeRemoved); - energy -= chargeRemoved; - } + if (cap.Charge <= .01f) continue; + var drawn = min(share, cap.Charge); + cap.AddCharge(-drawn); + remaining -= drawn; } - } while (chargedCapacitors > 0 && energy > .01f); + } while (chargedCapacitors > 0 && remaining > .01f); - if (energy < .01f) return true; - - int onlineReactors = _reactors.Count(reactor=>reactor.Item.Online.Value); - foreach (var reactor in _reactors) - { - if (reactor.Item.Online.Value) - { - reactor.ConsumeEnergy(energy / onlineReactors); - } - } - - return onlineReactors > 0; + return true; } private void AddChild(Entity entity) @@ -1039,6 +1031,10 @@ public abstract class Entity } } + // Cut 3 (docs/stats-and-power-cut.md §1.2): stepped once per tick, before any equipped item's + // Behaviors execute, so every IPowerConsumer's grant is decided before it acts on it. + PowerBus.Step(delta); + foreach (var equippedItem in _orderedEquipment) { equippedItem.Update(delta); @@ -1390,6 +1386,13 @@ public class EquippedItem : IStatContext public float ConsumableProgressFactor(float exponent) => 1f; public float PowerSupplyFactor(float exponent) => 1f; + // Cut 3 (docs/stats-and-power-cut.md §1.2): PowerBus's grant ratio for this item, in [0,1], from whichever + // tick last ran its Step. Display-only and (once Cut 6 wires StatSource.PowerSupply into the resolver) + // stat-source-only: the only other reader is the very IPowerConsumer behaviour that declared the request this + // reflects. Forbidden writers: nothing but PowerBus sets this. Defaults to 1 (fully supplied) for an item + // that draws no power, or before the bus has run once. + public float PowerSupply { get; internal set; } = 1f; + // Cut 2: these used to read the catalog stat's own per-entity dictionary (the leak, §0.3). A modifier now // attaches to this entity's resolver, keyed by (this item, stat); reading it here is unchanged. public float ScaleModifier(PerformanceStat stat) => Entity.Resolver.ScaleModifier(this, stat); diff --git a/Assets/Scripts/ServerShared/PowerBus.cs b/Assets/Scripts/ServerShared/PowerBus.cs new file mode 100644 index 000000000..e093212a8 --- /dev/null +++ b/Assets/Scripts/ServerShared/PowerBus.cs @@ -0,0 +1,143 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using System.Linq; +using static CultMath.math; + +// Cut 3 (docs/stats-and-power-cut.md §1.2): the sole owner of every power grant for one Entity, stepped once per +// tick from Entity.Update, before any equipped item's Behaviors execute. It replaces +// Entity.TryConsumeEnergy/CanConsumeEnergy: at HEAD every draw succeeded as long as one reactor was online, and +// any shortfall was silently taxed onto the reactor as heat instead of ever being refused (§0.5). The bus makes +// the ceiling real: demand beyond what this tick's generation and stored capacitor charge can cover goes unmet. +// There are no tiers yet (Cut 5 adds them), so a shortfall rations the whole pool by the same fraction for every +// consumer alike -- the death of the old "whoever equipped first drains the capacitors first" hidden priority +// (§0.5), not a replacement for it. +// +// Bus capacitors are charged and drained only here (§0b): Reactor no longer touches them, and neither does any +// IPowerConsumer. The four instant draws the cut map names (a burst, a shot, a ping, a hit taken) are out of +// scope for this cut and spend capacitor charge directly instead, through Entity.TrySpendCapacitorCharge -- a +// named, temporary exception this cut accepts, closed by Cut 4. +public class PowerBus +{ + private readonly Entity _entity; + + public PowerBus(Entity entity) => _entity = entity; + + // Every figure below is this tick's, overwritten wholesale by the next Step -- nothing here is persisted + // (§0.6); a fresh Entity starts with the defaults below and they read as "fully supplied, nothing drawn" + // until the first Step runs. + public float TotalGeneration { get; private set; } + public float TotalDemand { get; private set; } + + // Energy actually delivered this tick: generation first, then whatever stored capacitor charge covers the + // rest, capped at demand. Equal to TotalDemand whenever supply covers it; equal to (generation + available + // charge) otherwise -- never more, which TryConsumeEnergy could never promise (a reactor always paid the + // remainder as heat, so overdraw was invisible at this level). + public float TotalGrant { get; private set; } + + // Fraction of every consumer's own request actually granted this tick, shared by every consumer alike (no + // tiers yet). Also written onto every consuming EquippedItem.PowerSupply. + public float GrantRatio { get; private set; } = 1f; + + // What Reactor.Execute reads to run its own heat/throttle arithmetic (Cut 3: "the arithmetic survives, moved + // under the bus's numbers"). Positive: demand left unmet after generation AND stored charge, split evenly + // across online reactors for overload heat. Negative: generation left over after demand and after topping up + // every capacitor, split evenly for the throttling arithmetic. Reactor.Draw no longer feeds this -- it is now + // a reported total, not a sink (§1.2). + public float NetDraw { get; private set; } + + public void Step(float dt) + { + var reactors = new List(); + var capacitors = new List(); + var consumers = new List(); + foreach (var item in _entity.Equipment) + foreach (var behavior in item.Behaviors) + { + switch (behavior) + { + case Reactor reactor when reactor.Item.Online.Value: + reactors.Add(reactor); + break; + case Capacitor capacitor: + capacitors.Add(capacitor); + break; + } + if (behavior is IPowerConsumer consumer) + consumers.Add(consumer); + } + + TotalGeneration = reactors.Sum(r => r.Generation(dt)); + TotalDemand = consumers.Sum(c => max(0f, c.PowerRequest(dt))); + + var availableCharge = capacitors.Sum(c => c.Charge); + var preCapacitorNet = TotalDemand - TotalGeneration; + + float overload = 0f, unabsorbedSurplus = 0f; + if (preCapacitorNet > 0f) + { + var chargeDrawn = min(preCapacitorNet, availableCharge); + if (chargeDrawn > 0f) DrainEvenly(capacitors, chargeDrawn); + overload = preCapacitorNet - chargeDrawn; + } + else if (preCapacitorNet < 0f) + { + unabsorbedSurplus = FillEvenly(capacitors, -preCapacitorNet); + } + + NetDraw = overload - unabsorbedSurplus; // exactly one of the two is ever nonzero + TotalGrant = TotalDemand - overload; + GrantRatio = TotalDemand <= 1e-4f ? 1f : saturate(TotalGrant / TotalDemand); + + foreach (var item in _entity.Equipment) + if (item.Behaviors.Any(b => b is IPowerConsumer)) + item.PowerSupply = GrantRatio; + } + + // Mirrors Entity.TryConsumeEnergy's old do/while exactly (draw evenly across every capacitor that still has + // charge, repeating as capacitors empty out), just relocated: bus capacitors are drained only by the bus now. + private static void DrainEvenly(List capacitors, float amount) + { + int chargedCount; + do + { + chargedCount = capacitors.Count(c => c.Charge > .01f); + if (chargedCount == 0) break; + var share = amount / chargedCount; + foreach (var cap in capacitors) + { + if (cap.Charge <= .01f) continue; + var drawn = min(share, cap.Charge); + cap.AddCharge(-drawn); + amount -= drawn; + } + } while (chargedCount > 0 && amount > .01f); + } + + // Fills every capacitor evenly up to its own capacity and returns whatever would not fit, for Reactor to + // throttle away -- the same job Reactor.cs used to do to itself (surplus branch), moved here because bus + // capacitors are charged only by the bus (§0b). Resolves Capacity itself rather than reading each + // Capacitor's cached property: the bus steps before any behaviour's Execute runs this tick, so that property + // still holds last tick's value. + private static float FillEvenly(List capacitors, float amount) + { + int nonFullCount; + do + { + nonFullCount = capacitors.Count(c => c.Charge < c.ResolveCapacity() - .01f); + if (nonFullCount == 0) break; + var share = amount / nonFullCount; + foreach (var cap in capacitors) + { + var capacity = cap.ResolveCapacity(); + if (cap.Charge >= capacity - .01f) continue; + var added = min(share, capacity - cap.Charge); + cap.AddCharge(added); + amount -= added; + } + } while (nonFullCount > 0 && amount > .01f); + return amount; + } +} diff --git a/tests/Aetheria.Shared.Tests/PowerBusTests.cs b/tests/Aetheria.Shared.Tests/PowerBusTests.cs new file mode 100644 index 000000000..49230504e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PowerBusTests.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Cut 3 (docs/stats-and-power-cut.md): the power bus replaces Entity.TryConsumeEnergy/CanConsumeEnergy. At HEAD +// every draw succeeded as long as one reactor was online, and any shortfall was silently taxed onto the reactor +// as heat (§0.5) -- an assertion pinning "demand beyond supply is refused" was unwritable against that code. +// This file pins PowerBus's three verification bullets from the cut map's Cut 3 section, plus the atomicity of +// Entity.TrySpendCapacitorCharge, the named exception the four instant draws keep this cut. Each rule has a +// matching mutation in tests/mutation_tests_stats_power_cut3.py. +public sealed class PowerBusTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-powerbus-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private static readonly int2 HullOrigin = new int2(0, 0); + + public PowerBusTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // A hull with a 5x5 fully-interior Shape: HullData.InteriorCells shrinks the border away, leaving a 3x3 + // (9-cell) interior -- enough room for several Tool-hardpoint items (Reactor/Capacitor/Drain all use Tool, + // which needs an interior cell of its own, unlike the Sensors-hardpoint fixtures elsewhere in this suite). + // Every gear item's heat-response bounds are authored wide around the hull's default 280-degree start + // temperature (MapEntity), so ThermalOnline is true without the test ever having to touch Entity.Temperature. + private CultCache OpenCatalog() + { + var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + // Mass must be nonzero: HullData.SpecificHeat defaults to 1 but Mass defaults to 0, and Entity.MapEntity + // divides by it to seed ThermalMass -- a zero ThermalMass turns UpdateTemperature's conduction average + // into a division by zero (NaN) the moment a real tick (ship.Update, not just UpdatePerformance) runs. + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ReactorData + { + Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) + } } + }); + cache.Upsert(new GearData + { + Name = "Capacitor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new CapacitorData { Capacity = Constant(1000), Efficiency = Constant(1) } } + }); + cache.Upsert(new GearData + { + Name = "Drain", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new EnergyDrawData { EnergyDraw = Constant(0), PerSecond = true } } + }); + cache.FlushAsync().Wait(); + return cache; + } + + private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + // Reactor generation (per tick, dt=1), starting capacitor charge, and one Drain's requested rate (per + // second). Equips in the given order -- opposite orders are how OppositeEquipOrderGetsIdenticalGrants pins + // the death of the old "whoever equipped first drains the capacitors first" hidden priority (§0.5). + private (Ship ship, ItemManager items, Reactor reactor, Capacitor capacitor) BuildShip( + CultCache cache, float reactorCharge, float startingCapacitorCharge, bool reactorFirst) + { + var ledger = new ProvenanceLedger(); + var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hull = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("Reactor"); + var capacitorData = cache.GetByName("Capacitor"); + ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); + + void EquipReactor() => Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); + void EquipCapacitor() => Assert.True(ship.TryEquip(Mint(cache, items, capacitorData))); + + if (reactorFirst) { EquipReactor(); EquipCapacitor(); } else { EquipCapacitor(); EquipReactor(); } + + zone.Entities.Add(ship); + ship.Activate(); + + var reactor = ship.GetBehavior(); + var capacitor = ship.GetBehavior(); + // Capacitor.Capacity defaults to 0 until its own Execute has run once (it clamps AddCharge to + // [0, Capacity]), so a zero-dt warm-up tick has to happen before seeding starting charge. + ship.Update(0f); + if (startingCapacitorCharge > 0) capacitor.AddCharge(startingCapacitorCharge); + return (ship, items, reactor, capacitor); + } + + // TryEquip refuses while the entity is active (Entity.cs), so a Drain added after BuildShip's own Activate() + // needs a deactivate/equip/reactivate round trip -- harmless here: nothing this fixture cares about (charge, + // durability) is reset by Deactivate/Activate, only the hostility/subscription bookkeeping neither test uses. + private EquippedItem EquipDrain(CultCache cache, Ship ship, float rate) + { + // Each Drain item needs its own catalog record and PerformanceStat instance -- the "Drain" record from + // OpenCatalog is shared and evaluated by generation-cached resolver entries keyed on it, so two Drains + // sharing one mutated PerformanceStat would fight over the same rate. + var perItemData = cache.Upsert(new GearData + { + Name = "Drain " + Guid.NewGuid(), Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new EnergyDrawData { EnergyDraw = Constant(rate), PerSecond = true } } + }); + cache.FlushAsync().Wait(); + ship.Deactivate(); + Assert.True(ship.TryEquip(Mint(cache, ship.ItemManager, cache.Get(perItemData)))); + ship.Activate(); + return ship.Equipment.Last(); + } + + // --- Cut 3 verification bullet 1: "a ship whose requests exceed generation gets a total grant equal to + // --- generation plus available capacitor charge, and not more." At HEAD this assertion was unwritable + // --- because every draw succeeded (Entity.TryConsumeEnergy always found an online reactor to bill). --- + [Fact] + public void GrantIsCappedAtGenerationPlusStoredCapacitorCharge() + { + using var cache = OpenCatalog(); + var (ship, _, _, capacitor) = BuildShip(cache, reactorCharge: 10, startingCapacitorCharge: 5, reactorFirst: true); + EquipDrain(cache, ship, rate: 100); + + ship.Update(1f); + + Assert.Equal(15f, ship.PowerBus.TotalGrant, 3); // 10 generation + 5 stored charge, not 100 + Assert.Equal(.15f, ship.PowerBus.GrantRatio, 3); + Assert.Equal(0f, capacitor.Charge, 3); // the whole stored reserve was spent covering the gap + } + + // --- Cut 3 verification bullet 2: "a refused request produces no effect at all -- not a partial one, and + // --- not an overload-heat receipt." Zero generation and zero stored charge: the bus must not finance any of + // --- the demand, the exact HEAD bug (§0.5: "every draw succeeds while one reactor is online"). Map's own + // --- mutation: "make the bus grant the full request and heat the reactor; it goes red." --- + [Fact] + public void ZeroSupplyRefusesTheWholeRequest() + { + using var cache = OpenCatalog(); + var (ship, _, _, _) = BuildShip(cache, reactorCharge: 0, startingCapacitorCharge: 0, reactorFirst: true); + EquipDrain(cache, ship, rate: 50); + + ship.Update(1f); + + Assert.Equal(0f, ship.PowerBus.TotalGrant, 3); + Assert.Equal(0f, ship.PowerBus.GrantRatio, 3); + } + + // --- Cut 3 verification bullet 3: "two ships with identical loadouts fitted in opposite equip order get + // --- identical grants. That is the death of the hidden priority, and it fails at HEAD." At HEAD, + // --- EquippedItem.SortPosition (set only by Reactor) decided who drained the capacitors first (§0.5); here + // --- every consuming item shares the bus's one GrantRatio regardless of equip order, so the two ships (and + // --- the two consumers on either) can never diverge by position. --- + [Fact] + public void OppositeEquipOrderGetsIdenticalGrants() + { + using var cacheA = OpenCatalog(); + var (shipA, _, _, _) = BuildShip(cacheA, reactorCharge: 10, startingCapacitorCharge: 0, reactorFirst: true); + EquipDrain(cacheA, shipA, rate: 40); + + using var cacheB = OpenCatalog(); + var (shipB, _, _, _) = BuildShip(cacheB, reactorCharge: 10, startingCapacitorCharge: 0, reactorFirst: false); + EquipDrain(cacheB, shipB, rate: 40); + + shipA.Update(1f); + shipB.Update(1f); + + Assert.Equal(shipA.PowerBus.GrantRatio, shipB.PowerBus.GrantRatio, 5); + Assert.Equal(shipA.PowerBus.TotalGrant, shipB.PowerBus.TotalGrant, 5); + } + + // The same invariant bullet 3 rests on, pinned directly: with two consumers sharing one shortfall, BOTH read + // back the identical GrantRatio (never "first equipped gets its full request, second one starves"), which is + // what makes equip order unable to matter in the first place. + [Fact] + public void EveryConsumingItemReadsTheSameGrantRatio() + { + using var cache = OpenCatalog(); + var (ship, _, _, _) = BuildShip(cache, reactorCharge: 10, startingCapacitorCharge: 0, reactorFirst: true); + var drainOne = EquipDrain(cache, ship, rate: 30); + var drainTwo = EquipDrain(cache, ship, rate: 30); + + ship.Update(1f); + + Assert.True(ship.PowerBus.GrantRatio < 1f); // a real shortfall: 60 requested against 10 generated + Assert.Equal(ship.PowerBus.GrantRatio, drainOne.PowerSupply, 5); + Assert.Equal(ship.PowerBus.GrantRatio, drainTwo.PowerSupply, 5); + } + + // --- Entity.TrySpendCapacitorCharge (the four instant draws' named, temporary exception, §Cut 3): atomic -- + // --- either every capacitor together holds enough charge and it is spent, or nothing moves. This is the + // --- exact bug TryConsumeEnergy had (a partial drain-then-refuse) that the cut map calls out by name. --- + [Fact] + public void TrySpendCapacitorChargeMovesNothingWhenTheRequestCannotBeFullyCovered() + { + using var cache = OpenCatalog(); + var (ship, _, _, capacitor) = BuildShip(cache, reactorCharge: 0, startingCapacitorCharge: 5, reactorFirst: true); + + Assert.False(ship.TrySpendCapacitorCharge(10f)); + Assert.Equal(5f, capacitor.Charge, 3); // untouched -- not partially drained toward the refused request + + Assert.True(ship.TrySpendCapacitorCharge(5f)); + Assert.Equal(0f, capacitor.Charge, 3); + } +} diff --git a/tests/mutation_tests_stats_power_cut3.py b/tests/mutation_tests_stats_power_cut3.py new file mode 100644 index 000000000..91bdfff00 --- /dev/null +++ b/tests/mutation_tests_stats_power_cut3.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Mutation tests for docs/stats-and-power-cut.md Cut 3 (the power bus replaces TryConsumeEnergy). + +Cut 3 deletes Entity.CanConsumeEnergy/TryConsumeEnergy and Reactor's own capacitor handling, replacing them with +PowerBus: a single per-Entity step that turns every IPowerConsumer's request plus the tick's reactor generation +and stored capacitor charge into one shared grant ratio, refusing demand that supply cannot cover instead of +taxing the shortfall onto the reactor as heat. This script mutates each rule's own line of code and asserts the +named test in PowerBusTests.cs goes red for exactly that reason. A no-op control mutation proves the +read/replace/restore path is transparent. + +Usage: + python tests/mutation_tests_stats_power_cut3.py --cultlib-root + +Only touches files under this repository; every mutation is reversed before the script exits, including on +failure. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" + + +@dataclass +class Mutation: + rule: str + file: str + anchor: str + mutated: str + test: str + expect: str # "red" (mutant must be killed) or "green" (the no-op control) + + +MUTATIONS: list[Mutation] = [ + # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- + Mutation( + rule="control (no-op round trip)", + file="Assets/Scripts/ServerShared/PowerBus.cs", + anchor=" TotalGeneration = reactors.Sum(r => r.Generation(dt));", + mutated=" TotalGeneration = reactors.Sum(r => r.Generation(dt));", + test="PowerBusTests.GrantIsCappedAtGenerationPlusStoredCapacitorCharge", + expect="green", + ), + + # --- Cut 3 verification bullet 1 (§0.5): "a ship whose requests exceed generation gets a total grant equal + # --- to generation plus available capacitor charge, and not more." Dropping the capacitor draw on a deficit + # --- caps the grant at generation alone -- still capped, but at the wrong ceiling, which is exactly the kind + # --- of silent under-delivery a ceiling test has to catch as sharply as the old unlimited-reactor bug. --- + Mutation( + rule="a deficit must draw stored capacitor charge, not just generation", + file="Assets/Scripts/ServerShared/PowerBus.cs", + anchor=" var chargeDrawn = min(preCapacitorNet, availableCharge);", + mutated=" var chargeDrawn = 0f;", + test="PowerBusTests.GrantIsCappedAtGenerationPlusStoredCapacitorCharge", + expect="red", + ), + + # --- Cut 3 verification bullet 2 (§0.5): "a refused request produces no effect at all -- not a partial one, + # --- and not an overload-heat receipt." This is the map's own named mutation: "make the bus grant the full + # --- request and heat the reactor; it goes red" -- forcing overload to 0 finances the whole deficit for + # --- free, the exact HEAD bug ("every draw succeeds while one reactor is online") Cut 3 exists to remove. --- + Mutation( + rule="unmet demand after generation and stored charge must be refused (overload), never financed for free", + file="Assets/Scripts/ServerShared/PowerBus.cs", + anchor=" overload = preCapacitorNet - chargeDrawn;", + mutated=" overload = 0f;", + test="PowerBusTests.ZeroSupplyRefusesTheWholeRequest", + expect="red", + ), + + # --- Cut 3 verification bullet 3 (§0.5): "two ships with identical loadouts fitted in opposite equip order + # --- get identical grants. That is the death of the hidden priority." The invariant that makes equip order + # --- unable to matter is that every consuming item reads back the SAME GrantRatio; restricting the write to + # --- only the first equipped consumer reintroduces exactly the "whoever equipped first" priority the cut + # --- kills, just relocated from SortPosition into this loop. --- + Mutation( + rule="every consuming item must be written the same GrantRatio, not just the first one found", + file="Assets/Scripts/ServerShared/PowerBus.cs", + anchor=( + " foreach (var item in _entity.Equipment)\n" + " if (item.Behaviors.Any(b => b is IPowerConsumer))\n" + " item.PowerSupply = GrantRatio;" + ), + mutated=( + " var firstConsumer = _entity.Equipment.FirstOrDefault(item => item.Behaviors.Any(b => b is IPowerConsumer));\n" + " if (firstConsumer != null) firstConsumer.PowerSupply = GrantRatio;" + ), + test="PowerBusTests.EveryConsumingItemReadsTheSameGrantRatio", + expect="red", + ), + + # --- Entity.TrySpendCapacitorCharge (the four instant draws' named, temporary exception, §Cut 3 per-file + # --- changes): atomic -- either every capacitor together holds enough charge and it is spent, or nothing + # --- moves. Deleting the upfront capacity check restores TryConsumeEnergy's old bug: the do/while below + # --- drains whatever charge exists toward a request it can never fully pay, then still reports failure. --- + Mutation( + rule="TrySpendCapacitorCharge must refuse before draining anything it cannot fully cover", + file="Assets/Scripts/ServerShared/Entity.cs", + anchor=( + " if (energy < .01f) return true;\n" + " if (!CanSpendCapacitorCharge(energy)) return false;" + ), + mutated=" if (energy < .01f) return true;", + test="PowerBusTests.TrySpendCapacitorChargeMovesNothingWhenTheRequestCannotBeFullyCovered", + expect="red", + ), +] + + +def replace_unique(path: Path, anchor: str, mutated: str) -> None: + original = path.read_text(encoding="utf-8") + count = original.count(anchor) + if count != 1: + raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") + path.write_text(original.replace(anchor, mutated), encoding="utf-8") + + +def run_test(cultlib_root: str, test: str) -> tuple[int, str]: + proc = subprocess.run( + ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", + "--filter", f"FullyQualifiedName~{test}"], + capture_output=True, text=True, timeout=300, + ) + return proc.returncode, proc.stdout + proc.stderr + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--cultlib-root", required=True) + args = parser.parse_args() + + failures: list[str] = [] + originals: dict[str, str] = {} + for m in MUTATIONS: + if m.file not in originals: + originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") + + def restore_all() -> None: + for file, text in originals.items(): + (REPO_ROOT / file).write_text(text, encoding="utf-8") + + try: + for m in MUTATIONS: + print(f"\n=== {m.rule} (expect {m.expect}) ===") + path = REPO_ROOT / m.file + replace_unique(path, m.anchor, m.mutated) + code, output = run_test(args.cultlib_root, m.test) + passed = "Passed!" in output and code == 0 + if m.expect == "green": + if not passed: + failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") + else: + print(f"OK: {m.test} passed under a no-op mutation, as expected") + else: + if passed: + failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") + else: + print(f"OK: {m.test} went red under the mutation, as expected") + # Restore this file immediately so the next mutation starts from a clean tree. + path.write_text(originals[m.file], encoding="utf-8") + finally: + restore_all() + + if failures: + print("\n=== FAILURES ===") + for f in failures: + print(f"- {f}") + return 1 + + print("\nAll mutations behaved as declared.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())