A power request may not depend on power supply (Cut 6/F6), and continuous consumers brown out through a power term on their own performance stats (Cut 7). For Radiator (PumpedHeat) and AetherDrive (Torque) those were the same stat, making six shipped catalog records illegal and leaving six BrownoutTests cases skipped. Resolved per the operator's ruling: a power request is evaluated nominally -- what the item wants at full supply, not what it is currently managing. EquippedItem.EvaluateNominalPower (mirroring ConditionRatio's own NominalContext) pins only PowerSupplyFactor to 1; Heat/Durability stay live, since they are not the term a request would be circular through. Every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now reads a StatValidation.PowerRequestFields stat through it, so the same stat can carry a brownout curve and be a request field without the request depending on its own answer. StatValidation.ValidateNoPowerSupplyOnRequest (F6's static half of the rule, forbidding the term outright) is deleted -- nominal evaluation removes the circularity at the root instead. Only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) still refuses anything, since EvaluateNominalPower forwards ScaleModifier/ ConstantModifier unchanged (real, not nominal). Un-skips BrownoutTests' six Radiator/AetherDrive cases and adds two new regression tests (a direct EvaluateNominalPower probe, and a two-tick Radiator demand check) backed by a new mutation harness. Updates the four PowerCurveTests/AetheriaStoresTests cases that pinned the now-superseded static refusal to instead assert acceptance. GameData/Aetheria.cc is unchanged and still validates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
128 lines
No EOL
5.7 KiB
C#
128 lines
No EOL
5.7 KiB
C#
/* 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 MessagePack;
|
|
using Newtonsoft.Json;
|
|
using CultMath;
|
|
using static CultMath.math;
|
|
|
|
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
|
|
public class RadiatorData : BehaviorData
|
|
{
|
|
[Inspectable, JsonProperty("emissivity"), Key(1), RuntimeInspectable]
|
|
public PerformanceStat Emissivity = new PerformanceStat();
|
|
|
|
[Inspectable, JsonProperty("pumpedHeat"), Key(2), RuntimeInspectable]
|
|
public PerformanceStat PumpedHeat = new PerformanceStat();
|
|
|
|
[InspectableTemperature, JsonProperty("temperatureFloor"), Key(3), RuntimeInspectable]
|
|
public float TemperatureFloor;
|
|
|
|
[Inspectable, JsonProperty("wasteHeat"), Key(4), RuntimeInspectable]
|
|
public PerformanceStat WasteHeat = new PerformanceStat();
|
|
|
|
[Inspectable, JsonProperty("energyUsage"), Key(5), RuntimeInspectable]
|
|
public PerformanceStat EnergyUsage = new PerformanceStat();
|
|
|
|
[Inspectable, JsonProperty("thermalMass"), Key(6), RuntimeInspectable]
|
|
public PerformanceStat ThermalMass = new PerformanceStat();
|
|
|
|
public override Behavior CreateInstance(EquippedItem item)
|
|
{
|
|
return new Radiator(this, item);
|
|
}
|
|
|
|
public override Behavior CreateInstance(ConsumableItemEffect item)
|
|
{
|
|
return new Radiator(this, item);
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
_data = data;
|
|
}
|
|
public Radiator(RadiatorData data, ConsumableItemEffect item) : base(data, item)
|
|
{
|
|
_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.
|
|
//
|
|
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PumpedHeat, WasteHeat and
|
|
// EnergyUsage are all registered request fields (StatValidation.PowerRequestFields) -- read nominally (what
|
|
// full power would pump) so PumpedHeat carrying its own PowerSupply term (Cut 7's brownout curve) no longer
|
|
// makes this tick's request depend on this tick's own grant. Execute below is unchanged: it calls the real,
|
|
// curved Evaluate, so the actual pumping still degrades with whatever the bus actually grants.
|
|
public float PowerRequest(float dt)
|
|
{
|
|
var pumpedHeat = EvaluateNominalPower(_data.PumpedHeat);
|
|
var wasteHeat = EvaluateNominalPower(_data.WasteHeat);
|
|
var tempRatio = max(RadiatorTemperature / Temperature, 1);
|
|
if (tempRatio > pumpedHeat / wasteHeat) return 0f;
|
|
return EvaluateNominalPower(_data.EnergyUsage) * tempRatio * dt;
|
|
}
|
|
|
|
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Critical -- the closest thing this game has to
|
|
// life support. A starved radiator cascades into wear and shutdown across every other item on the hull.
|
|
public int DefaultPowerTier => PowerTiers.Critical;
|
|
|
|
public override bool Execute(float dt)
|
|
{
|
|
PumpedHeat = Evaluate(_data.PumpedHeat);
|
|
WasteHeat = Evaluate(_data.WasteHeat);
|
|
EnergyUsage = Evaluate(_data.EnergyUsage);
|
|
|
|
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;
|
|
|
|
// Cut 7 (docs/stats-and-power-target.md): no separate power gate at all. PumpedHeat above is already a
|
|
// plain Evaluate() read, so a PumpedHeat stat carrying a PowerSupply term already pumps less under a
|
|
// partial grant -- waste heat below is unaffected by the curve, so a starved radiator falls behind and
|
|
// the ship heats up, which is the reduced-performance failure the ruling asks for instead of the pump
|
|
// simply refusing to run. F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PowerSupply is a
|
|
// multiplier PerformanceStat.Evaluate applies to the whole resolved value, not a term blended into the
|
|
// Min/Max interpolation, so at true zero supply PumpedHeat resolves to exactly 0 regardless of Min -- so
|
|
// "produces nothing" already falls out of Evaluate() without a special case here.
|
|
var pumpedHeat = PumpedHeat * max(itemTemperature - _data.TemperatureFloor, 0);
|
|
|
|
// Radiator temperature is below temperature floor, stop executing
|
|
if (pumpedHeat < 0.01f) return true;
|
|
|
|
var wasteHeat = WasteHeat * tempRatio;
|
|
|
|
AddHeat((wasteHeat - pumpedHeat) * dt);
|
|
RadiatorTemperature += pumpedHeat / Evaluate(_data.ThermalMass) * dt;
|
|
|
|
return true;
|
|
}
|
|
|
|
public void Update(float delta)
|
|
{
|
|
Emissivity = Evaluate(_data.Emissivity);
|
|
var rad = pow(RadiatorTemperature, ItemManager.GameplaySettings.HeatRadiationExponent) * ItemManager.GameplaySettings.HeatRadiationMultiplier * Emissivity;
|
|
RadiatorTemperature -= rad * delta;
|
|
Entity.VisibilitySources[this] = rad;
|
|
}
|
|
|
|
public void Initialize()
|
|
{
|
|
RadiatorTemperature = Temperature;
|
|
}
|
|
} |