Cut 3: PowerBus replaces TryConsumeEnergy/CanConsumeEnergy
Deletes Entity.CanConsumeEnergy/TryConsumeEnergy and Reactor's own capacitor draining/filling (the demand-mechanism halves of Reactor.cs:90-141); adds PowerBus (one per Entity, stepped once per tick before equipment executes), IPowerConsumer on the five continuous draw sites (EnergyDraw, Thruster, AetherDrive, Radiator, ConstantWeapon), and EquippedItem.PowerSupply as the bus's grant ratio. Reactor keeps only its heat/throttle arithmetic, now driven by PowerBus.NetDraw instead of an accumulator any behaviour could add into. The four instant draws (InstantWeapon's burst/shot, Sensor's ping, Shield's hit taken) are out of scope for the bus this cut per the map; they spend capacitor charge directly and atomically via the new Entity.TrySpendCapacitorCharge/ CanSpendCapacitorCharge, replacing TryConsumeEnergy's old partial-drain-then- refuse bug for that path too. Named, temporary exception closed by Cut 4. docs/stats-and-power-cut.md Cut 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
bfdde9c33d
commit
f1fd64ac60
15 changed files with 707 additions and 135 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Capacitor> _capacitors;
|
||||
|
||||
private List<IDisposable> _subscriptions = new List<IDisposable>();
|
||||
|
||||
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<Capacitor>().ToList();
|
||||
_subscriptions.Add(Entity.Equipment.ObserveAdd().Subscribe(onAdd =>
|
||||
{
|
||||
var capacitor = onAdd.Value.GetBehavior<Capacitor>();
|
||||
if (capacitor != null) _capacitors.Add(capacitor);
|
||||
}));
|
||||
_subscriptions.Add(Entity.Equipment.ObserveRemove().Subscribe(onRemove =>
|
||||
{
|
||||
var capacitor = onRemove.Value.GetBehavior<Capacitor>();
|
||||
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<Reactor>().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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Weapon> _weapons = new List<Weapon>();
|
||||
private List<Capacitor> _capacitors = new List<Capacitor>();
|
||||
private List<Reactor> _reactors = new List<Reactor>();
|
||||
private List<Radiator> _heatsinks = new List<Radiator>();
|
||||
|
||||
private List<ConsumableItemEffect> _activeConsumables = new List<ConsumableItemEffect>();
|
||||
|
|
@ -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<Weapon> weapons, List<EquippedItem> items)[itemManager.GameplaySettings.WeaponGroupCount];
|
||||
for(int i=0; i<itemManager.GameplaySettings.WeaponGroupCount; i++)
|
||||
|
|
@ -608,8 +612,6 @@ public abstract class Entity
|
|||
}
|
||||
if (b is Capacitor capacitor)
|
||||
_capacitors.Remove(capacitor);
|
||||
if (b is Reactor reactor)
|
||||
_reactors.Remove(reactor);
|
||||
if (b is Radiator heatsink)
|
||||
_heatsinks.Remove(heatsink);
|
||||
if (b is Shield)
|
||||
|
|
@ -793,8 +795,6 @@ public abstract class Entity
|
|||
_weapons.Add(weapon);
|
||||
if(b is Capacitor capacitor)
|
||||
_capacitors.Add(capacitor);
|
||||
if(b is Reactor reactor)
|
||||
_reactors.Add(reactor);
|
||||
if(b is Radiator heatsink)
|
||||
_heatsinks.Add(heatsink);
|
||||
if (b is Shield shield)
|
||||
|
|
@ -858,44 +858,36 @@ public abstract class Entity
|
|||
return true;
|
||||
}
|
||||
|
||||
public bool CanConsumeEnergy(float energy)
|
||||
{
|
||||
var capEnergy = _capacitors.Sum(cap => 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);
|
||||
|
|
|
|||
143
Assets/Scripts/ServerShared/PowerBus.cs
Normal file
143
Assets/Scripts/ServerShared/PowerBus.cs
Normal file
|
|
@ -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<Reactor>();
|
||||
var capacitors = new List<Capacitor>();
|
||||
var consumers = new List<IPowerConsumer>();
|
||||
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<Capacitor> 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<Capacitor> 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;
|
||||
}
|
||||
}
|
||||
224
tests/Aetheria.Shared.Tests/PowerBusTests.cs
Normal file
224
tests/Aetheria.Shared.Tests/PowerBusTests.cs
Normal file
|
|
@ -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<ItemData>(design),
|
||||
Durability = design is EquippableItemData equippable ? equippable.Durability : 0,
|
||||
Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
|
||||
};
|
||||
|
||||
// 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<HullData>("Skiff"));
|
||||
var ship = new Ship(items, zone, hull, new EntitySettings());
|
||||
|
||||
var reactorData = cache.GetByName<GearData>("Reactor");
|
||||
var capacitorData = cache.GetByName<GearData>("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<Reactor>();
|
||||
var capacitor = ship.GetBehavior<Capacitor>();
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
180
tests/mutation_tests_stats_power_cut3.py
Normal file
180
tests/mutation_tests_stats_power_cut3.py
Normal file
|
|
@ -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 <path-to-CultLib-45c2f40-worktree>
|
||||
|
||||
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())
|
||||
Loading…
Reference in a new issue