From 8bbf280756a6f2494f0e4e88e1cc2d641d6ff6c9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 06:58:57 +0200 Subject: [PATCH] Resolve request/brownout conflict: evaluate power requests nominally 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 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 5 - .../ServerShared/Behaviors/AetherDrive.cs | 15 +- .../ServerShared/Behaviors/Behaviors.cs | 7 + .../ServerShared/Behaviors/ConstantWeapon.cs | 7 +- .../ServerShared/Behaviors/EnergyDraw.cs | 6 +- .../ServerShared/Behaviors/InstantWeapon.cs | 11 +- .../ServerShared/Behaviors/Radiator.cs | 12 +- .../Scripts/ServerShared/Behaviors/Sensor.cs | 8 +- .../Scripts/ServerShared/Behaviors/Shield.cs | 11 +- .../ServerShared/Behaviors/StatModifier.cs | 29 ++-- .../ServerShared/Behaviors/Thruster.cs | 7 +- Assets/Scripts/ServerShared/Entity.cs | 53 +++++- Assets/Scripts/ServerShared/ItemData.cs | 68 +++----- .../AetheriaStoresTests.cs | 25 +-- tests/Aetheria.Shared.Tests/BrownoutTests.cs | 131 +++++++++++--- .../Aetheria.Shared.Tests/PowerCurveTests.cs | 111 ++++++------ tests/mutation_tests_stats_power_cut6.py | 50 ++---- ...ation_tests_stats_power_nominal_request.py | 161 ++++++++++++++++++ tools/AetherDb/Program.cs | 20 ++- 19 files changed, 528 insertions(+), 209 deletions(-) create mode 100644 tests/mutation_tests_stats_power_nominal_request.py diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index e50ca974d..cd2589dc0 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -35,8 +35,6 @@ public static class AetheriaStores { StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - // Cut 6 (docs/stats-and-power-cut.md): a power request may not depend on power supply. - StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors); // Cut 7 (docs/stats-and-power-cut.md): a stat naming a role its design lacks is refused. StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); } @@ -45,7 +43,6 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) { StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); } return cache; @@ -87,13 +84,11 @@ public static class CultRecordRefs { StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); } if (document is ConsumableItemData consumable) { StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors); - StatValidation.ValidateNoPowerSupplyOnRequest(consumable.Name, consumable.Behaviors); StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors); } } diff --git a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs index 72da7b29c..e8498ca7f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs @@ -99,20 +99,27 @@ public class AetherDrive : Behavior, IPowerConsumer // (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). + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): Torque, LambdaMultiplier, + // MaximumRpm, PassiveCoupling and EnergyDraw are all registered request fields (StatValidation. + // PowerRequestFields) -- read nominally (what full power would produce) so Torque 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 spin-up still + // degrades with whatever the bus actually grants. public float PowerRequest(float dt) { - var couplingLambda = _data.CouplingLambda * Item.Evaluate(_data.LambdaMultiplier) * max(abs(_axis), Evaluate(_data.PassiveCoupling)); + var couplingLambda = _data.CouplingLambda * Item.EvaluateNominalPower(_data.LambdaMultiplier) * max(abs(_axis), EvaluateNominalPower(_data.PassiveCoupling)); var rpmAfterDecay = decay(Rpm, couplingLambda, dt); - var maximumRpm = Evaluate(_data.MaximumRpm); + var maximumRpm = EvaluateNominalPower(_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 potentialTorque = EvaluateNominalPower(_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; + var draw = torqueRatio * EvaluateNominalPower(_data.EnergyDraw) / 3; return (draw.x + draw.y + draw.z) * dt; } diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 714a64e12..dd72122f4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -58,6 +58,13 @@ public abstract class Behavior } public float Evaluate(PerformanceStat stat) => Item?.Evaluate(stat) ?? Consumable.Evaluate(stat); + + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): the read a PowerRequest + // implementation uses for a stat named in StatValidation.PowerRequestFields, in place of Evaluate above. A + // consumable-hosted instance (Item == null) never reaches PowerBus.Step at all (see e.g. EnergyDraw.Execute's + // own comment), and ConsumableItemEffect.PowerSupplyFactor is already the identity (1) unconditionally, so + // Consumable.Evaluate is already nominal with respect to power and needs no separate context. + public float EvaluateNominalPower(PerformanceStat stat) => Item?.EvaluateNominalPower(stat) ?? Consumable.Evaluate(stat); protected void AddHeat(float heat) => Item?.AddHeat(heat); // TODO: Heat for Consumables protected void CauseDamage(float damage) diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 107b6f847..5f7e3293d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -75,7 +75,12 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // 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; + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): Energy is a registered + // request field (StatValidation.PowerRequestFields) -- read nominally, same reasoning as every other + // IPowerConsumer in this cut. Damage (the field Cut 7 curves) is a separate stat base.Execute reads with the + // real Evaluate. + public float PowerRequest(float dt) => _firing && StanceAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon. public int DefaultPowerTier => PowerTiers.Low; diff --git a/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs b/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs index ee57853f6..5a1d8a424 100644 --- a/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs +++ b/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs @@ -43,7 +43,11 @@ public class EnergyDraw : Behavior, IPowerConsumer // 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); + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): EnergyDraw is a + // registered request field (StatValidation.PowerRequestFields) -- read nominally, same reasoning as every + // other IPowerConsumer in this cut. + public float PowerRequest(float dt) => EvaluateNominalPower(_data.EnergyDraw) * (_data.PerSecond ? dt : 1); // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Utility -- the generic/unclassified draw. An item // that declares power use without asking for a specific tier should not accidentally outrank a named one. diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index cd4f0def0..1e0185ed1 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -128,11 +128,16 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // bus grants the full request; RateOverride lets an owner replace that (ChargedWeapon does). protected virtual float RateOverride => 0f; + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): Count, Energy and + // Cooldown are all registered request fields (StatValidation.PowerRequestFields) -- read nominally here so + // the capacitor's own Capacity/Rate this feeds into PowerRequest below never depends on this tick's own + // grant. Nothing here needs the real, curved value: PowerRequest only ever asks "how much would a full shot + // cost," and the actual charge added back in Execute is separately scaled by Item.PowerSupply. protected void RefreshInputCapacitor() { - var burstCount = max(1, (int) Evaluate(_data.Count)); - var perShotEnergy = Evaluate(_data.Energy) / burstCount; - var cooldown = Evaluate(_data.Cooldown); + var burstCount = max(1, (int) EvaluateNominalPower(_data.Count)); + var perShotEnergy = EvaluateNominalPower(_data.Energy) / burstCount; + var cooldown = EvaluateNominalPower(_data.Cooldown); _capacitor.UpdateStats(perShotEnergy, cooldown, rateOverride: RateOverride); } diff --git a/Assets/Scripts/ServerShared/Behaviors/Radiator.cs b/Assets/Scripts/ServerShared/Behaviors/Radiator.cs index d09b5382a..a43a1a5c7 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Radiator.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Radiator.cs @@ -61,13 +61,19 @@ public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior // 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 = Evaluate(_data.PumpedHeat); - var wasteHeat = Evaluate(_data.WasteHeat); + var pumpedHeat = EvaluateNominalPower(_data.PumpedHeat); + var wasteHeat = EvaluateNominalPower(_data.WasteHeat); var tempRatio = max(RadiatorTemperature / Temperature, 1); if (tempRatio > pumpedHeat / wasteHeat) return 0f; - return Evaluate(_data.EnergyUsage) * tempRatio * dt; + 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 diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 547da7909..228657b5d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -86,10 +86,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer // Cut 4: capacity/rate, resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls // PowerRequest below before this behaviour's own Execute runs this tick). + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PingEnergy and + // PingCooldown are registered request fields (StatValidation.PowerRequestFields) -- read nominally, same + // reasoning as InstantWeapon's own RefreshInputCapacitor. private void RefreshInputCapacitor() { - var energy = Evaluate(_data.PingEnergy); - var cooldown = Evaluate(_data.PingCooldown); + var energy = EvaluateNominalPower(_data.PingEnergy); + var cooldown = EvaluateNominalPower(_data.PingCooldown); _capacitor.UpdateStats(energy, cooldown); } diff --git a/Assets/Scripts/ServerShared/Behaviors/Shield.cs b/Assets/Scripts/ServerShared/Behaviors/Shield.cs index 1dfe421f4..cef8e8941 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Shield.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Shield.cs @@ -93,10 +93,17 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer, IAlwaysUpdate // Resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls PowerRequest below before // this behaviour's own Execute runs this tick), reading Broken as it stood at the end of the previous tick. + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): Capacity, RefillDuration + // and RestoreDuration are registered request fields (StatValidation.PowerRequestFields) -- read nominally. + // This also feeds Update's own real accumulation math below (RefreshReserve is called from both PowerRequest + // and Update), which is fine: the actual charge added is separately scaled by Item.PowerSupply there, so + // nominalizing the reserve's physical size/rate does not change how much of a partial grant it actually + // receives, only removes any dependency the size/rate calculation itself could have had on this tick's grant. private void RefreshReserve() { - var capacity = Evaluate(_data.Capacity); - var duration = Broken ? Evaluate(_data.RestoreDuration) : Evaluate(_data.RefillDuration); + var capacity = EvaluateNominalPower(_data.Capacity); + var duration = Broken ? EvaluateNominalPower(_data.RestoreDuration) : EvaluateNominalPower(_data.RefillDuration); _reserve.UpdateStats(capacity, cooldown: duration, capacityOverride: capacity); } diff --git a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs index 975911321..5e56e4c42 100644 --- a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs +++ b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs @@ -163,16 +163,25 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa "modifier chain reaches back to itself -- a stat cannot (even transitively) modify its own magnitude"); } - // Cut 6 (docs/stats-and-power-cut.md): the dynamic half of "a stat that decides a power request may not - // depend on power supply, directly or through a modifier chain." StatValidation.ValidateNoPowerSupplyOnRequest - // already refuses a request stat's own declared Terms, at catalog load, at Upsert and at equip -- that check - // needs no entity. This one does: whether a given modifier's target is "a power request stat" is static (the - // (BehaviorData type, field) registry in StatValidation), but which items are actually feeding a modifier - // chain into it is a fact about this concrete ship's loadout. Walks the same magnitude->target edges - // ValidateNoCycle builds, in the other direction: is this modifier's own magnitude stat "power-tainted" -- - // does it, or something feeding it through another modifier on this entity, carry a PowerSupply term -- and - // does it write onto a known request stat. Refused at equip, naming both the modifying item and the item - // whose request it would corrupt. + // Cut 6 (docs/stats-and-power-cut.md), narrowed by the nominal-request ruling (operator ruling 2026-09-19, + // ItemData.cs's own PowerRequestFields comment): this is now the ONLY surviving half of "a stat that decides + // a power request may not depend on power supply, directly or through a modifier chain." The direct half -- + // a request stat's own declared Terms naming PowerSupply -- used to be refused statically + // (StatValidation.ValidateNoPowerSupplyOnRequest, at catalog load, Upsert and equip), but every + // PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now reads a PowerRequestFields stat + // through EquippedItem.EvaluateNominalPower (Entity.cs), which pins that stat's own PowerSupplyFactor to 1 + // regardless of its Terms -- so a direct term can no longer make the request depend on its own answer, and + // that static check was deleted. What EvaluateNominalPower does NOT pin is ScaleModifier/ConstantModifier -- + // it forwards those to the item's real, non-nominal resolver entries (same as ConditionRatio's own + // NominalContext), so a modifier chain that reaches a power-tainted magnitude stat still corrupts a nominal + // read too. That is exactly what this check still catches, and it needs an entity to do it: whether a given + // modifier's target is "a power request stat" is static (the (BehaviorData type, field) registry in + // StatValidation), but which items are actually feeding a modifier chain into it is a fact about this + // concrete ship's loadout. Walks the same magnitude->target edges ValidateNoCycle builds, in the other + // direction: is this modifier's own magnitude stat "power-tainted" -- does it, or something feeding it + // through another modifier on this entity, carry a PowerSupply term -- and does it write onto a known + // request stat. Refused at equip, naming both the modifying item and the item whose request it would + // corrupt. private static void ValidateNoPowerSupplyChain(Entity entity, StatModifierData data, Target[] targets, EquippedItem modifyingItem) { if (data.Modifier == null) return; diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index f88315ed3..29cf8f181 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -80,7 +80,12 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer // 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; + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): EnergyUsage is a + // registered request field (StatValidation.PowerRequestFields) -- read nominally, same reasoning as every + // other IPowerConsumer in this cut, though EnergyUsage carries no PowerSupply term in Thruster's own shipped + // catalog today; Thrust (the field Cut 7 curves) is a separate stat Execute reads with the real Evaluate. + public float PowerRequest(float dt) => _input > .01f ? _input * EvaluateNominalPower(_data.EnergyUsage) : 0f; // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Medium -- mobility. Losing thrust for a tick // under brownout is an inconvenience, not the cascading failure a starved radiator or shield causes. diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 53f4deb66..2dcf90601 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1355,19 +1355,58 @@ public class EquippedItem : IStatContext EquippableItem.PowerTier = consumerTiers.Min(); } - // Cut 6 (docs/stats-and-power-cut.md): "the validator runs at catalog load, at Studio save, and at - // equip." AetheriaStores.Open/CultRecordRefs.Upsert already refuse this at catalog write time; re-running - // the same static check here is what closes the gap for the two named bypasses (CultCache Studio's - // generic editor, AetherDb's dangling-ref apply) rather than silently trusting that every catalog on disk - // went through Upsert. The modifier-chain half of the rule is dynamic (it depends on what is actually - // equipped alongside this item) and lives in StatModifier.Initialize instead. - StatValidation.ValidateNoPowerSupplyOnRequest(Data.Name, Data.Behaviors); + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, superseding F6): Cut + // 6's static half of this check -- refusing any PowerRequestFields stat whose own Terms named + // PowerSupply -- assumed PowerRequest read those stats the same way Execute does. It does not any more: + // EvaluateNominalPower (below, and Behavior.EvaluateNominalPower) is what every PowerRequest/RefreshReserve/ + // RefreshInputCapacitor implementation now calls for a request-field stat, and it pins that stat's own + // PowerSupplyFactor to 1 regardless of what Terms the stat declares -- so a direct PowerSupply term on a + // request field can no longer make the request depend on its own answer, and the six shipped records this + // ruling exists to make legal again (RadiatorData.PumpedHeat, AetherDriveData.Torque) are not an authoring + // error to refuse. What is still refused: a request field fed a PowerSupply-tainted value through a + // *modifier chain* (StatModifier.ValidateNoPowerSupplyChain, StatModifier.cs) -- EvaluateNominalPower + // forwards ScaleModifier/ConstantModifier to the item's real, non-nominal resolver entries (same as + // ConditionRatio's NominalContext already did), so a modifier chain that reaches a power-tainted magnitude + // stat still corrupts the nominal read too. That dynamic check is unchanged and still runs from + // Initialize below via StatModifier -- it is the only surviving half of Cut 6's rule. } // Cut 2 (docs/stats-and-power-cut.md): the resolver owns the value; this is the caller's read of it, keyed by // this EquippedItem instance, so two ships equipping the same design never share a resolved value. public float Evaluate(PerformanceStat stat) => Entity.Resolver.Resolve(this, stat, this); + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): what this item wants at + // full power supply, not what it is currently managing -- the read every PowerRequest/RefreshReserve/ + // RefreshInputCapacitor implementation uses for a stat named in StatValidation.PowerRequestFields, so a stat + // that is both what a request asks for and what it also produces (RadiatorData.PumpedHeat, AetherDriveData. + // Torque) stops being circular at the root instead of being forbidden outright. Only PowerSupply is pinned: + // Heat and Durability are NOT, because they are not the term a request would be circular through -- this + // tick's PowerBus.Step (which calls PowerRequest) always runs before this tick's grant exists, so nothing + // here could depend on an answer that has not been computed yet, and a hot or worn item honestly asking for + // less power is real degradation the ruling never objected to, only "the request depends on its own answer." + // Bypasses the resolver's (owner, stat) cache the same way ConditionRatio's NominalContext below does and for + // the same reason: the cache key is (this, stat) alone, so resolving a second, different context under that + // key would clobber whatever Execute's own real Evaluate(stat) call cached for this same tick. + public float EvaluateNominalPower(PerformanceStat stat) => stat.Evaluate(new PowerRequestContext(this)); + + // Real-condition view of this same item for EvaluateNominalPower above: everything but PowerSupply is + // forwarded to the real item, including ScaleModifier/ConstantModifier -- deliberately NOT pinned, so a + // modifier chain that reaches a power-tainted magnitude stat still taints a nominal read through them too. + // That is exactly the residual case StatModifier.ValidateNoPowerSupplyChain still refuses: nominalizing this + // item's own direct Terms cannot undo a value baked in by another item's modifier. + private readonly struct PowerRequestContext : IStatContext + { + private readonly EquippedItem _item; + public PowerRequestContext(EquippedItem item) => _item = item; + public Lot Lot => _item.Lot; + public float HeatFactor(float exponent) => _item.HeatFactor(exponent); + public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent); + public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent); + public float PowerSupplyFactor(float exponent) => 1f; + public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat); + public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat); + } + public float HeatFactor(float exponent) => pow(ThermalPerformance, ThermalExponent * exponent); public float DurabilityFactor(float exponent) => pow(DurabilityPerformance, DurabilityExponent * exponent); public float ConsumableProgressFactor(float exponent) => 1f; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 45597f1b9..f40179acc 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -649,9 +649,9 @@ public class PerformanceStat // made is this part" -- a part that is still there, just less good at its job, so blending toward Min is // right for them. Power is not a degradation of the part; it is whether the part is receiving anything to // work with at all, so it multiplies the fully-interpolated, fully-modified value instead: a PowerSupply term - // still lives in this stat's own Terms list (it is still authored per stat, still censused, still refused on - // a request stat by ValidateNoPowerSupplyOnRequest below), it is just applied as a separate multiplier rather - // than contributing to `factor`. Consequences named in the ruling: PowerSupply == 0 makes the whole + // still lives in this stat's own Terms list (it is still authored per stat, still censused in + // PowerRequestFields below), it is just applied as a separate multiplier rather than contributing to + // `factor`. Consequences named in the ruling: PowerSupply == 0 makes the whole // expression 0 regardless of Min (pow(0, exponent) == 0 for any exponent > 0), and a Min == Max stat still // responds to a PowerSupply term because the multiplier no longer has to move a degenerate interpolation. public float Evaluate(IStatContext context) @@ -812,21 +812,32 @@ public static class StatValidation // something authored per catalog instance, so they are named here once -- the same (type, field) address // ResolveStatField already uses for StatReference -- rather than adding a second reflection idiom or a // per-instance flag. Naming it here (not a new interface member on IPowerConsumer) means the check needs no - // Behavior instance, no EquippedItem and no Entity: it can run over the catalog's own Data objects, at load - // and at Upsert, before anything is ever equipped. + // Behavior instance, no EquippedItem and no Entity: TryGetPowerRequestBehaviorName below can run over the + // catalog's own Data objects with nothing else in hand. // - // F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this used to name only each consumer's top-level - // request field ("Thruster reads EnergyUsage, EnergyDraw reads EnergyDraw, and so on") on the premise that - // PowerRequest reads exactly one PerformanceStat. That premise was false for five of the eight consumers -- - // Radiator.PowerRequest also reads PumpedHeat and WasteHeat (to decide whether the pump can even run this - // tick), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its - // whole rotor spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration and - // RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown and Count, and - // Sensor.PowerRequest (via RefreshInputCapacitor) also reads PingCooldown. Every one of those was free to - // carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest below, corrupting the - // request it feeds exactly the way the rule exists to forbid. Audited directly against each PowerRequest - // method's own body (and RefreshReserve/RefreshInputCapacitor, which PowerRequest calls into) rather than - // guessed from the behaviour's public surface. + // F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) named every field a PowerRequest implementation + // actually reads, not just each consumer's top-level request field: Radiator.PowerRequest also reads + // PumpedHeat and WasteHeat, AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and + // PassiveCoupling, Shield.PowerRequest (via RefreshReserve) also reads RefillDuration and RestoreDuration, + // InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown and Count, and Sensor. + // PowerRequest (via RefreshInputCapacitor) also reads PingCooldown. Audited directly against each + // PowerRequest method's own body (and RefreshReserve/RefreshInputCapacitor, which PowerRequest calls into) + // rather than guessed from the behaviour's public surface; still the complete census a modifier chain must + // be checked against (see TryGetPowerRequestBehaviorName and StatModifier.ValidateNoPowerSupplyChain below). + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, superseding F6): F6 also + // added a static check here -- ValidateNoPowerSupplyOnRequest, refusing any of these fields whose own Terms + // named PowerSupply outright -- on the premise that PowerRequest reads these stats the same way Execute + // does. It does not any more: every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now + // calls EquippedItem.EvaluateNominalPower (Entity.cs) for a field named here, which pins that stat's own + // PowerSupplyFactor to 1 regardless of what Terms it declares -- so a direct PowerSupply term on one of + // these fields can no longer make the request depend on its own answer, and that static check was deleted + // rather than left enforcing an invariant nominal evaluation had already made false (it would otherwise + // still refuse the six shipped records this ruling exists to make legal again). The census below still has + // exactly one live reader: TryGetPowerRequestBehaviorName, for the modifier-chain half of the rule + // (StatModifier.ValidateNoPowerSupplyChain, StatModifier.cs) -- EvaluateNominalPower forwards + // ScaleModifier/ConstantModifier unchanged (real, not nominal), so a modifier chain that reaches a + // power-tainted magnitude stat still corrupts a nominal read too, and that is what remains forbidden. public static readonly (Type Type, string Field)[] PowerRequestFields = { (typeof(EnergyDrawData), nameof(EnergyDrawData.EnergyDraw)), @@ -850,29 +861,6 @@ public static class StatValidation (typeof(AetherDriveData), nameof(AetherDriveData.PassiveCoupling)), }; - // The direct half of the rule: a request stat's own declared Terms must not name PowerSupply. Runs wherever - // ValidateHeatResponse/ValidateStatModifiers run (AetheriaStores.Open, CultRecordRefs.Upsert) plus once more - // at equip (EquippedItem's constructor, Entity.cs) as defense against the two named bypasses that write a - // catalog document without going through Upsert (CultCache Studio's generic editor, AetherDb's dangling-ref - // apply -- see the comment above ValidateHeatResponse). The modifier-chain half of the rule is dynamic and - // lives in StatModifier.Initialize (StatModifier.cs), because a modifier's target only resolves against a - // concrete entity's actual equipment. - public static void ValidateNoPowerSupplyOnRequest(string ownerName, IEnumerable behaviors) - { - foreach (var behavior in behaviors) - foreach (var (type, field) in PowerRequestFields) - { - if (!type.IsInstanceOfType(behavior)) continue; - if (!(type.GetField(field)?.GetValue(behavior) is PerformanceStat stat)) continue; - foreach (var term in stat.Terms) - if (term.Source == StatSource.PowerSupply) - throw new InvalidOperationException( - $"{ownerName}: {behavior.GetType().Name}.{field} is a power request -- its own Terms may not " + - "declare a PowerSupply term, or the request would depend on how much power it receives to " + - "decide how much power it asks for"); - } - } - // Whether `stat` is the request stat of some IPowerConsumer behaviour on `data` -- used by StatModifier's // equip-time chain check to tell "an ordinary modifier target" from "a modifier target that would corrupt a // power request." Returns the owning behaviour's type name for the error message. diff --git a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs index 9f2d906d2..e0fec193d 100644 --- a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs +++ b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs @@ -169,22 +169,25 @@ public sealed class AetheriaStoresTests : IDisposable Assert.Contains("BadDangling", error.Message); } - // The same direct-call path must also catch F6's rule (a power request stat may not carry a PowerSupply - // term), not only R-heat -- a repair tool landing a bad ThrusterData.EnergyUsage is exactly as dangerous as - // one landing a bad heat response. + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19), superseding F6: this + // direct-call path used to also catch F6's static rule (a power request stat may not carry a PowerSupply + // term at all -- StatValidation.ValidateNoPowerSupplyOnRequest, since deleted). PowerRequest now reads a + // request field through EquippedItem.EvaluateNominalPower, which pins that stat's own PowerSupplyFactor to 1 + // regardless of its Terms, so the term can no longer make the request depend on its own answer -- a repair + // tool landing a curved ThrusterData.EnergyUsage is no longer any more dangerous than R-heat's own check, + // which this still runs (see the sibling test above). [Fact] - public void ValidateRefusesAPowerRequestCarryingAPowerSupplyTermTheSameWayUpsertWould() + public void ValidateAcceptsAPowerRequestCarryingAPowerSupplyTermTheSameWayUpsertWould() { - var badEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; - var badDesign = new GearData + var curvedEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + var goodDesign = new GearData { - Name = "BadMigratedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + Name = "CurvedMigratedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, - Behaviors = { new ThrusterData { EnergyUsage = badEnergy } } + Behaviors = { new ThrusterData { EnergyUsage = curvedEnergy } } }; - var error = Assert.Throws(() => CultRecordRefs.Validate(badDesign)); - Assert.Contains("BadMigratedThruster", error.Message); - Assert.Contains(nameof(ThrusterData.EnergyUsage), error.Message); + var record = Record.Exception(() => CultRecordRefs.Validate(goodDesign)); + Assert.Null(record); } // A document type Validate has no opinion about (neither EquippableItemData nor ConsumableItemData) must be diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index fe5b57cb2..e9a2dbf11 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -23,17 +23,25 @@ using static CultMath.math; // consumer's own top-level request field. Radiator.PowerRequest's own early-out reads PumpedHeat AND WasteHeat // (to decide whether the pump can keep up this tick); AetherDrive.PowerRequest's spin-up arithmetic reads // Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling. Cut 7 asks for the brownout curve to live on exactly -// the stats these two behaviours actually curve (PumpedHeat, Torque) -- but Cut 6 forbids a PowerSupply term on -// any stat a request reads, on pain of the exact oscillation Soul measured against the shipped catalog's own "OK -// Disperser" radiator (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates, docs/stats-and-power-cut.md). Every -// field either behaviour's PowerRequest touches is now correctly refused for a PowerSupply term, and neither has -// an untouched field left to curve instead -- there is no valid catalog shape today that gives Radiator or -// AetherDrive a working brownout curve without also feeding it back into their own request. The six tests below -// that authored one are marked Skip rather than deleted or silently reworked to hide the gap: reconciling Cut -// 6 and Cut 7 for these two behaviours (most likely by having PowerRequest read a nominal/undegraded value -// instead of the live curved one) is real, scoped work for a future pass, not a fix a registry correction should -// smuggle in. Thruster and ConstantWeapon are unaffected -- their own PowerRequest reads only EnergyUsage/Energy, -// never the separate Thrust/Damage field their tests curve. +// the stats these two behaviours actually curve (PumpedHeat, Torque), but F6 forbade a PowerSupply term on any +// stat a request reads at all, on pain of the exact oscillation Soul measured against the shipped catalog's own +// "OK Disperser" radiator (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates, docs/stats-and-power-cut.md). +// The six tests below that authored one were marked Skip rather than deleted or silently reworked to hide the +// gap, naming the conflict as real, scoped work for a future pass rather than a fix a registry correction should +// smuggle in. +// +// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19) is that future pass: a power +// request is evaluated nominally -- what the item wants at full supply, not what it is currently managing +// (EquippedItem.EvaluateNominalPower, Entity.cs). Radiator.PowerRequest and AetherDrive.PowerRequest now read +// PumpedHeat/Torque (and every other field in StatValidation.PowerRequestFields) through that nominal read, so +// the same stat can carry a PowerSupply term (curved for Execute's real effect) AND be a request field, without +// the request depending on its own answer -- there is no more oscillation to reproduce, because the request no +// longer reads the live grant at all. StatValidation.ValidateNoPowerSupplyOnRequest (the static check F6 added +// to forbid this) is deleted; only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) +// still refuses anything, and it was never what these six tests tripped. Un-skipped below. Thruster and +// ConstantWeapon were never affected by F6 -- their own PowerRequest reads only EnergyUsage/Energy, never the +// separate Thrust/Damage field their tests curve -- but now also read that field nominally, for the same reason +// every other IPowerConsumer does. public sealed class BrownoutTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-brownout-" + Guid.NewGuid().ToString("N")); @@ -194,9 +202,11 @@ public sealed class BrownoutTests : IDisposable return (drive.Rpm.x, drive); } - // F6: Torque is now a registered AetherDrive request field (PowerRequest's spin-up arithmetic reads it) -- - // see this file's header comment. Curved(...) on Torque is no longer an authorable catalog shape. - [Fact(Skip = "F6 (docs/stats-and-power-cut.md): Torque is a request field now; a PowerSupply term on it is correctly refused. See file header.")] + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, see file header): Torque + // is a registered AetherDrive request field (PowerRequest's spin-up arithmetic reads it), but PowerRequest now + // reads it through EvaluateNominalPower (full-supply, undegraded), so Curved(...) on Torque is a legal + // catalog shape again -- Execute's own real, curved Evaluate is what actually degrades the spin-up below. + [Fact] public void AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp() { var (full, _) = RunAetherDrive(reactorCharge: 1000); // demand ~50, generation far exceeds it -> ratio 1 @@ -205,7 +215,7 @@ public sealed class BrownoutTests : IDisposable Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}"); } - [Fact(Skip = "F6 (docs/stats-and-power-cut.md): Torque is a request field now; a PowerSupply term on it is correctly refused. See file header.")] + [Fact] public void AetherDriveAtFullGrantIsUnchangedFromToday() { var (full, drive) = RunAetherDrive(reactorCharge: 1000); @@ -213,7 +223,7 @@ public sealed class BrownoutTests : IDisposable Assert.True(full > 0f); } - [Fact(Skip = "F6 (docs/stats-and-power-cut.md): Torque is a request field now; a PowerSupply term on it is correctly refused. See file header.")] + [Fact] public void AetherDriveAtZeroGrantProducesNothing() { var (zero, drive) = RunAetherDrive(reactorCharge: 0); @@ -240,10 +250,11 @@ public sealed class BrownoutTests : IDisposable return (radiator.RadiatorTemperature - before, radiator); } - // F6: PumpedHeat and WasteHeat are now registered Radiator request fields (PowerRequest's own early-out - // reads both) -- see this file's header comment. Curved(...) on PumpedHeat is no longer an authorable - // catalog shape. - [Fact(Skip = "F6 (docs/stats-and-power-cut.md): PumpedHeat is a request field now; a PowerSupply term on it is correctly refused. See file header.")] + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, see file header): + // PumpedHeat and WasteHeat are registered Radiator request fields (PowerRequest's own early-out reads both), + // but PowerRequest now reads them through EvaluateNominalPower, so Curved(...) on PumpedHeat is a legal + // catalog shape again -- Execute's own real, curved Evaluate is what actually degrades the pumping below. + [Fact] public void RadiatorAtHalfGrantPumpsReducedNotZeroNotFullHeat() { var (full, _) = RunRadiator(reactorCharge: 1000); // demand 50, generation far exceeds it -> ratio 1 @@ -252,7 +263,7 @@ public sealed class BrownoutTests : IDisposable Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}"); } - [Fact(Skip = "F6 (docs/stats-and-power-cut.md): PumpedHeat is a request field now; a PowerSupply term on it is correctly refused. See file header.")] + [Fact] public void RadiatorAtFullGrantIsUnchangedFromToday() { var (full, radiator) = RunRadiator(reactorCharge: 1000); @@ -260,7 +271,7 @@ public sealed class BrownoutTests : IDisposable Assert.True(full > 0f); } - [Fact(Skip = "F6 (docs/stats-and-power-cut.md): PumpedHeat is a request field now; a PowerSupply term on it is correctly refused. See file header.")] + [Fact] public void RadiatorAtZeroGrantProducesNothing() { var (zero, radiator) = RunRadiator(reactorCharge: 0); @@ -371,4 +382,80 @@ public sealed class BrownoutTests : IDisposable capacitor.AddCharge(5); // now full Assert.True(capacitor.TrySpend(10)); // and only now does it fire, for the whole cost } + + // --- Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): a direct probe of + // --- EquippedItem.EvaluateNominalPower itself, decoupled from any behaviour's own physical state (Radiator's + // --- temperature, AetherDrive's Rpm) so the only thing that can make it diverge from the ordinary Evaluate + // --- is the item's own PowerSupply grant. Thruster's EnergyUsage carries the term here purely as a probe -- + // --- not a shape BrownoutTests' other fixtures author -- because Thruster has no other tick-to-tick state + // --- that could confound the comparison the way Radiator's RadiatorTemperature or AetherDrive's Rpm would. + // --- Backs tests/mutation_tests_stats_power_nominal_request.py's own mutation of this exact method. --- + [Fact] + public void EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant() + { + var energyUsage = Curved(100, exponent: 1); + using var cache = OpenCatalog(new ThrusterData + { + Thrust = Constant(0), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = energyUsage + }); + var ship = BuildShip(cache, reactorCharge: 25); // demand 100 (nominal), generation 25 -> ratio .25 + ship.MovementDirection = float2(0, -1); + ship.Update(1f); + var thruster = ship.GetBehavior(); + + // The fixture must actually land a partial grant, or this probe proves nothing. + Assert.True(thruster.Item.PowerSupply > 0f && thruster.Item.PowerSupply < 1f, + $"expected a partial grant, got {thruster.Item.PowerSupply}"); + + Assert.Equal(100f, thruster.Item.EvaluateNominalPower(energyUsage), 3); // full-supply value, always + Assert.True(thruster.Item.Evaluate(energyUsage) < 100f); // the real, curved value the grant actually shrank + } + + // --- Wiring regression for Radiator.PowerRequest specifically: EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant + // --- above proves the underlying mechanism, but a single ship.Update never actually exercises it here -- + // --- PumpedHeat/WasteHeat's own ratio (100/5, nominally) so outstrips tempRatio (pinned at 1 on a radiator + // --- that started at ambient) that a single tick's PowerRequest returns the same gate-open answer whether or + // --- not PowerRequest reads PumpedHeat nominally. Two ticks exposes it: tick 1 is identical either way (the + // --- item's PowerSupply has not been written yet, so Evaluate and EvaluateNominalPower agree), landing the + // --- same partial grant (ratio1 = 20/50 = .4) and so the same RadiatorTemperature going into tick 2 in both + // --- cases. Only starting at tick 2's own PowerRequest can the two implementations diverge: nominally, + // --- PumpedHeat/WasteHeat is still 10/5 = 2, comfortably above tempRatio, so tick 2 still requests (demand + // --- unchanged from tick 1); read through the ordinary curved Evaluate instead, PumpedHeat is now + // --- pow(.4, 1)*10 = 4, so PumpedHeat/WasteHeat drops to 4/5 = .8, BELOW tempRatio -- PowerRequest's own + // --- early-out fires and demand collapses to 0. A wiring regression (PowerRequest reverted to the ordinary + // --- Evaluate) is exactly what tests/mutation_tests_stats_power_nominal_request.py mutates and this catches. + [Fact] + public void RadiatorSecondTickDemandIsUnaffectedByTheFirstTicksPartialGrant() + { + using var cache = OpenCatalog(new RadiatorData + { + Emissivity = Constant(0), PumpedHeat = Curved(10, exponent: 1), TemperatureFloor = 0, + WasteHeat = Constant(5), EnergyUsage = Constant(50), ThermalMass = Constant(1000) + }); + var ship = BuildShip(cache, reactorCharge: 20); // demand 50 (nominal, both ticks) -> ratio1 = .4 + ship.Update(1f); + var firstDemand = ship.PowerBus.TotalDemand; + Assert.Equal(50f, firstDemand, 3); // tick 1: PowerSupply defaults to 1, so both readings agree + + ship.Update(1f); + var secondDemand = ship.PowerBus.TotalDemand; + // Still the gate-open, EnergyUsage*tempRatio figure (tempRatio has crept up slightly from the radiator's + // own tick-1 heating, which is real and expected -- not the gate-closed 0 a reverted, non-nominal + // PowerRequest would produce once Item.PowerSupply < 1 curves PumpedHeat/WasteHeat below tempRatio). + Assert.True(secondDemand > 0f, + $"expected tick 2's gate to stay open on the nominal PumpedHeat/WasteHeat ratio, got demand {secondDemand}"); + } + + // --- The same wiring check attempted on AetherDrive.PowerRequest's own Torque read does not carry over + // --- cleanly: torqueRatio (actualRpmDelta/potentialRpmDelta) self-cancels Torque's own magnitude whenever + // --- the rotor is not yet rpm-capped (both numerator and denominator scale with Torque, so the ratio -- and + // --- so the final draw -- comes out identical whether Torque is read nominally or through the curved + // --- Evaluate; verified empirically while building this fixture: reverting AetherDrive.PowerRequest to + // --- Evaluate produced byte-identical TotalDemand across two ticks, including once the rotor was pushed + // --- into its rpm cap). A black-box demand comparison cannot discriminate this call site the way it can + // --- Radiator's binary gate above. Its correctness rests instead on the identical pattern to Radiator's + // --- (same EvaluateNominalPower call, same EquippedItem mechanism EvaluateNominalPowerIgnoresTheItemsCurrent + // --- PowerSupplyGrant already pins) and on AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp/ + // --- AtFullGrantIsUnchangedFromToday/AtZeroGrantProducesNothing above, which exercise the real end-to-end + // --- Execute path this ruling exists to keep legal. Not part of the mutation harness for that reason. } diff --git a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs index e5701b623..1a5a1dfd2 100644 --- a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs @@ -9,9 +9,20 @@ using Xunit; // Cut 6 (docs/stats-and-power-cut.md): "the power-supply term and the request-independence rule." Purely // additive -- StatSource.PowerSupply already existed as an identity everywhere (Cut 2), and the bus already wrote // EquippedItem.PowerSupply (Cut 3/5). This file pins the two things Cut 6 actually adds: a stat that declares a -// PowerSupply term now really degrades as the grant falls, and a stat that decides a power request can never read -// that same term back, directly or through a modifier chain. Each rule has a matching mutation in +// PowerSupply term now really degrades as the grant falls, and a stat that decides a power request can never +// have its OWN evaluation depend on that same term. Each rule has a matching mutation in // tests/mutation_tests_stats_power_cut6.py. +// +// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19) narrowed the second half: a +// PowerRequest implementation now reads a request field through EquippedItem.EvaluateNominalPower (Entity.cs), +// which pins that stat's own PowerSupplyFactor to 1 -- so a direct PowerSupply term on a request field can no +// longer make the request depend on its own answer, and StatValidation.ValidateNoPowerSupplyOnRequest (the +// static check that used to refuse it) is deleted; the "OpenAccepts.../UpsertAccepts..." tests below pin that. +// What EvaluateNominalPower does NOT pin is ScaleModifier/ConstantModifier (forwarded to the item's real, +// non-nominal resolver entries, same as ConditionRatio's own NominalContext) -- a modifier CHAIN that reaches a +// power-tainted magnitude stat still corrupts a nominal read too, and that half +// (StatModifier.ValidateNoPowerSupplyChain) is unchanged; ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest +// below still pins it. public sealed class PowerCurveTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-powercurve-" + Guid.NewGuid().ToString("N")); @@ -249,101 +260,101 @@ public sealed class PowerCurveTests : IDisposable Assert.Equal(2, context.PowerSupplyFactorCalls); // exactly one more -- not zero, not three again } - // --- Cut 6 verification bullet 2: "a catalog holding a thruster whose EnergyUsage carries a PowerSupply term - // --- is refused at load, naming the item and the stat." Written through the raw UpsertAsync (bypassing - // --- CultRecordRefs.Upsert's own validation, AetheriaStoresTests.WeaponWrittenThroughGearHandleReloadsAsWeapon's - // --- own pattern for reaching disk with something Upsert would refuse) so this pins AetheriaStores.Open's own - // --- half of the rule, not Upsert's. --- + // --- Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19), superseding Cut 6 + // --- verification bullet 2: a request field's own Terms naming PowerSupply directly used to be refused here + // --- (StatValidation.ValidateNoPowerSupplyOnRequest, now deleted). PowerRequest now reads every + // --- PowerRequestFields stat through EquippedItem.EvaluateNominalPower, which pins that stat's own + // --- PowerSupplyFactor to 1 regardless of its Terms, so the request can no longer depend on its own answer + // --- -- a direct term on a request field is legal again, and Open must accept it (round-tripping through the + // --- raw UpsertAsync, bypassing CultRecordRefs.Upsert's own validation, the same reach-disk pattern the + // --- refusal version of this test used). --- [Fact] - public void OpenRefusesAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm() + public void OpenAcceptsAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm() { - var badEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + var goodEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) { cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); cache.UpsertAsync(new GearData { - Name = "BadThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + Name = "CurvedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, - Behaviors = { new ThrusterData { EnergyUsage = badEnergy } } + Behaviors = { new ThrusterData { EnergyUsage = goodEnergy } } }).Wait(); cache.FlushAsync().Wait(); } - var error = Assert.Throws(() => AetheriaStores.Open(Catalog)); - Assert.Contains("BadThruster", error.Message); - Assert.Contains(nameof(ThrusterData), error.Message); - Assert.Contains(nameof(ThrusterData.EnergyUsage), error.Message); + using var reopened = AetheriaStores.Open(Catalog); + Assert.NotNull(reopened.GetByName("CurvedThruster")); } - // The same rule at Upsert (the write path every tool, test and migration script actually goes through), so a - // bad record is refused before it ever reaches disk -- not just the next time someone reopens the catalog. + // The same acceptance at Upsert (the write path every tool, test and migration script actually goes through). [Fact] - public void UpsertRefusesAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm() + public void UpsertAcceptsAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm() { - var badEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + var goodEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); - var error = Assert.Throws(() => cache.Upsert(new GearData + cache.Upsert(new GearData { - Name = "BadThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + Name = "CurvedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, - Behaviors = { new ThrusterData { EnergyUsage = badEnergy } } - })); - Assert.Contains("BadThruster", error.Message); - Assert.Contains(nameof(ThrusterData.EnergyUsage), error.Message); + Behaviors = { new ThrusterData { EnergyUsage = goodEnergy } } + }); + Assert.NotNull(cache.GetByName("CurvedThruster")); } - // --- F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the registry used to name only each consumer's - // --- top-level request field, so a PowerSupply term on a stat a request reads only indirectly -- here, - // --- Radiator.PowerRequest's own early-out gate on PumpedHeat, exactly the shape Soul reproduced against - // --- the shipped catalog's "OK Disperser" (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates) -- sailed - // --- straight through Upsert. PumpedHeat is now in PowerRequestFields, so this must be refused. --- + // --- F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) named PumpedHeat as a Radiator request field + // --- (PowerRequest's own early-out gate reads it, the exact shape Soul reproduced against the shipped + // --- catalog's "OK Disperser": SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates) and, at the time, + // --- refused a PowerSupply term there for exactly that reason. The nominal-request ruling above is what + // --- actually fixes the oscillation (the request no longer reads the live grant at all), so this shape is + // --- accepted now -- the six shipped records this ruling exists to make legal again include exactly this + // --- one (RadiatorData.PumpedHeat). BrownoutTests' own RunRadiator fixture exercises the resulting behaviour + // --- end to end; this pins acceptance at the catalog-write boundary. --- [Fact] - public void UpsertRefusesARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm() + public void UpsertAcceptsARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm() { - var badPumpedHeat = new PerformanceStat { Min = 1000, Max = 4000, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + var curvedPumpedHeat = new PerformanceStat { Min = 1000, Max = 4000, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); - var error = Assert.Throws(() => cache.Upsert(new GearData + cache.Upsert(new GearData { - Name = "BadRadiator", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + Name = "CurvedRadiator", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, Behaviors = { new RadiatorData { - PumpedHeat = badPumpedHeat, WasteHeat = Constant(100), EnergyUsage = Constant(6), + PumpedHeat = curvedPumpedHeat, WasteHeat = Constant(100), EnergyUsage = Constant(6), Emissivity = Constant(.5f), ThermalMass = Constant(100), TemperatureFloor = 0 } } - })); - Assert.Contains("BadRadiator", error.Message); - Assert.Contains(nameof(RadiatorData.PumpedHeat), error.Message); + }); + Assert.NotNull(cache.GetByName("CurvedRadiator")); } - // --- The same hole on the other consumer the ruling names by field: Shield.PowerRequest calls RefreshReserve, - // --- which reads RefillDuration (or RestoreDuration while broken) to size the reserve's own fill rate -- a - // --- PowerSupply term there makes the reserve's own refill speed depend on how much of it was already - // --- granted, the same self-reference the rule exists to forbid. --- + // --- The same acceptance on the other consumer the original refusal named by field: Shield.PowerRequest + // --- calls RefreshReserve, which reads RefillDuration (or RestoreDuration while broken) to size the + // --- reserve's own fill rate -- now read through EvaluateNominalPower, so a PowerSupply term there no + // --- longer makes the reserve's own refill speed depend on how much of it was already granted. --- [Fact] - public void UpsertRefusesAShieldWhoseRefillDurationCarriesAPowerSupplyTerm() + public void UpsertAcceptsAShieldWhoseRefillDurationCarriesAPowerSupplyTerm() { - var badRefillDuration = new PerformanceStat { Min = 2, Max = 2, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + var curvedRefillDuration = new PerformanceStat { Min = 2, Max = 2, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); - var error = Assert.Throws(() => cache.Upsert(new GearData + cache.Upsert(new GearData { - Name = "BadShield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + Name = "CurvedShield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(100), - RefillDuration = badRefillDuration, RestoreDuration = Constant(5) + RefillDuration = curvedRefillDuration, RestoreDuration = Constant(5) } } - })); - Assert.Contains("BadShield", error.Message); - Assert.Contains(nameof(ShieldData.RefillDuration), error.Message); + }); + Assert.NotNull(cache.GetByName("CurvedShield")); } // --- Cut 6 verification bullet 3: "the same refusal through a modifier chain, at equip time, naming both diff --git a/tests/mutation_tests_stats_power_cut6.py b/tests/mutation_tests_stats_power_cut6.py index 04ecdfabd..74b7e7b3e 100644 --- a/tests/mutation_tests_stats_power_cut6.py +++ b/tests/mutation_tests_stats_power_cut6.py @@ -5,12 +5,20 @@ rule). Cut 6 is purely additive: StatSource.PowerSupply already existed as an identity everywhere (Cut 2), and the bus already wrote EquippedItem.PowerSupply (Cut 3/5). This cut wires the identity into a real curve (EquippedItem.PowerSupplyFactor), wires the bus's own per-tick write to invalidate the resolver -(PowerBus.AllocateTiers), and adds the validation rule that a power request may not depend on power supply, -directly (StatValidation.ValidateNoPowerSupplyOnRequest) or through a modifier chain -(StatModifier.ValidateNoPowerSupplyChain). This script mutates each rule's own line of code and asserts the named +(PowerBus.AllocateTiers), and added the validation rule that a power request may not depend on power supply, +directly or through a modifier chain. This script mutates each rule's own line of code and asserts the named test in PowerCurveTests.cs goes red for exactly that reason. A no-op control mutation proves the read/replace/ restore path is transparent. +Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19) deleted the direct half of the +request-independence rule (StatValidation.ValidateNoPowerSupplyOnRequest) -- PowerRequest now reads a request +field through EquippedItem.EvaluateNominalPower, which pins that stat's own PowerSupplyFactor to 1, so a direct +term can no longer make the request depend on its own answer. The three mutation cases that used to pin that +static check (and the two PowerCurveTests it targeted, since renamed to OpenAccepts.../UpsertAccepts... in +PowerCurveTests.cs) are removed below; see tests/mutation_tests_stats_power_nominal_request.py for that rule's +own replacement check. The modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) is unchanged and its +two mutation cases below still apply. + Usage: python tests/mutation_tests_stats_power_cut6.py --cultlib-root @@ -99,42 +107,6 @@ MUTATIONS: list[Mutation] = [ expect="red", ), - # --- The direct half of the request-independence rule: a request stat's own declared Terms must be checked - # --- for PowerSupply. Neutering the check into a no-op accepts the exact catalog record the rule exists to - # --- refuse. --- - Mutation( - rule="a request stat's own Terms must be checked for a PowerSupply term", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" foreach (var term in stat.Terms)\n if (term.Source == StatSource.PowerSupply)\n throw new InvalidOperationException(\n $\"{ownerName}: {behavior.GetType().Name}.{field} is a power request -- its own Terms may not \" +\n \"declare a PowerSupply term, or the request would depend on how much power it receives to \" +\n \"decide how much power it asks for\");", - mutated=" foreach (var term in stat.Terms)\n if (false)\n throw new InvalidOperationException(\n $\"{ownerName}: {behavior.GetType().Name}.{field} is a power request -- its own Terms may not \" +\n \"declare a PowerSupply term, or the request would depend on how much power it receives to \" +\n \"decide how much power it asks for\");", - test="PowerCurveTests.UpsertRefusesAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm", - expect="red", - ), - - # --- The registry itself must actually name the field the mutant fixture exercises (ThrusterData.EnergyUsage) - # --- -- removing that one entry lets a catalog write straight past the check because nothing on the whole - # --- registry recognises it as a request stat any more. --- - Mutation( - rule="the request-field registry must include ThrusterData.EnergyUsage", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" (typeof(ThrusterData), nameof(ThrusterData.EnergyUsage)),\n", - mutated="", - test="PowerCurveTests.UpsertRefusesAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm", - expect="red", - ), - - # --- AetheriaStores.Open must run the same check at catalog load, not only at Upsert -- otherwise a record - # --- written through the raw CultCache API (bypassing Upsert, exactly like AetherDb's own named dangling-ref - # --- hole) reopens silently invalid. --- - Mutation( - rule="AetheriaStores.Open must validate every EquippableItemData against the power-request rule", - file="Assets/Scripts/ServerShared/AetheriaStores.cs", - anchor=" StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);\n // Cut 6 (docs/stats-and-power-cut.md): a power request may not depend on power supply.\n StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors);", - mutated=" StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);", - test="PowerCurveTests.OpenRefusesAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm", - expect="red", - ), - # --- The modifier-chain half: a modifier's own magnitude must be checked for a PowerSupply dependency before # --- it is allowed to attach onto a known request stat. Skipping the taint check lets a booster with a # --- power-dependent magnitude attach onto Drain's own EnergyDraw without complaint. --- diff --git a/tests/mutation_tests_stats_power_nominal_request.py b/tests/mutation_tests_stats_power_nominal_request.py new file mode 100644 index 000000000..b0ac88c2d --- /dev/null +++ b/tests/mutation_tests_stats_power_nominal_request.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Mutation tests for docs/stats-and-power-cut.md's nominal-request ruling (operator ruling 2026-09-19). + +Two landed rulings collided: a stat deciding a power request may not depend on power supply (Cut 6, widened by +F6 to name every stat a PowerRequest actually reads), and continuous consumers brown out through a power term on +their performance stats (Cut 7). For Radiator and AetherDrive those are the same stat (RadiatorData.PumpedHeat, +AetherDriveData.Torque), which made six shipped catalog records illegal and left six BrownoutTests.cs cases +skipped. + +The resolution: a power request is evaluated NOMINALLY -- what the item wants at full supply, not what it is +currently managing. EquippedItem.EvaluateNominalPower (Entity.cs) is the read every PowerRequest/RefreshReserve/ +RefreshInputCapacitor implementation now uses for a StatValidation.PowerRequestFields stat; it pins that stat's +own PowerSupplyFactor to 1 regardless of what Terms it declares, removing the circularity at the root instead of +forbidding the field (StatValidation.ValidateNoPowerSupplyOnRequest, the static half of Cut 6/F6's rule, is +deleted -- see mutation_tests_stats_power_cut6.py's own updated header). This script pins that EvaluateNominalPower +actually forces the pin (rather than silently falling back to the real, curved value) and that Radiator.PowerRequest +actually calls it instead of the ordinary Evaluate. AetherDrive.PowerRequest's own Torque read has no equivalent +black-box mutation here -- its torqueRatio term self-cancels Torque's own magnitude outside the rpm cap, so +reverting that one call site produces byte-identical demand in every fixture tried (see the comment in +BrownoutTests.cs, right after AetherDriveAtZeroGrantProducesNothing, for the empirical finding); its correctness +rests on the identical pattern to Radiator's own call site instead. + +Usage: + python tests/mutation_tests_stats_power_nominal_request.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/Entity.cs", + anchor=" public PowerRequestContext(EquippedItem item) => _item = item;\n public Lot Lot => _item.Lot;\n public float HeatFactor(float exponent) => _item.HeatFactor(exponent);\n public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);\n public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;", + mutated=" public PowerRequestContext(EquippedItem item) => _item = item;\n public Lot Lot => _item.Lot;\n public float HeatFactor(float exponent) => _item.HeatFactor(exponent);\n public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);\n public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;", + test="BrownoutTests.EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant", + expect="green", + ), + + # --- The pin itself: EvaluateNominalPower's own context must actually return the identity for PowerSupply, + # --- not forward it to the real item -- otherwise EvaluateNominalPower is just Evaluate under another name, + # --- and every PowerRequest/RefreshReserve/RefreshInputCapacitor call site built on it stays exactly as + # --- circular as before this ruling (the oscillation Soul originally measured against the shipped catalog). + Mutation( + rule="PowerRequestContext.PowerSupplyFactor must return the identity (1), not forward to the real item", + file="Assets/Scripts/ServerShared/Entity.cs", + anchor=" public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;\n public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat);\n public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat);\n }\n\n public float HeatFactor(float exponent)", + mutated=" public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => _item.PowerSupplyFactor(exponent);\n public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat);\n public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat);\n }\n\n public float HeatFactor(float exponent)", + test="BrownoutTests.EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant", + expect="red", + ), + + # --- Radiator.PowerRequest must actually call the nominal read for PumpedHeat/WasteHeat/EnergyUsage, not the + # --- ordinary curved Evaluate. A single-tick fixture cannot distinguish the two (Item.PowerSupply defaults to + # --- 1 before the bus has ever run, so tick 1 reads the same value either way) -- this fixture runs two + # --- ticks, so tick 2 sees whatever tick 1 actually granted. Tuned so nominal PumpedHeat/WasteHeat (10/5 = 2) + # --- stays comfortably above tempRatio (pinned near 1) while the curved value at tick 1's partial grant + # --- (pow(.4, 1)*10/5 = .8) falls below it -- reverting to Evaluate collapses tick 2's demand through + # --- PowerRequest's own early-out gate instead of leaving it open. AetherDrive.PowerRequest's own Torque read + # --- has no equivalent black-box mutation: see the comment in BrownoutTests.cs above + # --- AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp for why (torqueRatio self-cancels Torque's own + # --- magnitude outside the rpm cap, verified empirically while building this script). + Mutation( + rule="Radiator.PowerRequest must read PumpedHeat/WasteHeat/EnergyUsage nominally, not via the curved Evaluate", + file="Assets/Scripts/ServerShared/Behaviors/Radiator.cs", + anchor=" var pumpedHeat = EvaluateNominalPower(_data.PumpedHeat);\n var wasteHeat = EvaluateNominalPower(_data.WasteHeat);\n var tempRatio = max(RadiatorTemperature / Temperature, 1);\n if (tempRatio > pumpedHeat / wasteHeat) return 0f;\n return EvaluateNominalPower(_data.EnergyUsage) * tempRatio * dt;", + mutated=" var pumpedHeat = Evaluate(_data.PumpedHeat);\n var wasteHeat = Evaluate(_data.WasteHeat);\n var tempRatio = max(RadiatorTemperature / Temperature, 1);\n if (tempRatio > pumpedHeat / wasteHeat) return 0f;\n return Evaluate(_data.EnergyUsage) * tempRatio * dt;", + test="BrownoutTests.RadiatorSecondTickDemandIsUnaffectedByTheFirstTicksPartialGrant", + 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()) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 46bc7de89..8f69161b2 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -882,19 +882,23 @@ public static class Program // a partial grant read exactly like a full one instead of a reduced one. This authors the term, once, on the // one performance stat each of the four curve-eligible behaviours (EnergyDraw has none -- see EnergyDraw.cs) // actually reads for its continuous effect: ThrusterData.Thrust, AetherDriveData.Torque, - // RadiatorData.PumpedHeat, ConstantWeaponData.Damage. None of the four is a power-request field - // (StatValidation.PowerRequestFields), so authoring the term here does not trip - // ValidateNoPowerSupplyOnRequest. Exponent 1 (linear) is the gentle default the ruling calls for -- half - // supply reads as half performance, not a cliff -- and is left for the operator to steepen per design later. - // Dry run unless passed "apply"; every record must derive cleanly or nothing is written, same contract as - // ShieldMigrate. + // RadiatorData.PumpedHeat, ConstantWeaponData.Damage. Two of the four (AetherDriveData.Torque, + // RadiatorData.PumpedHeat) are ALSO power-request fields (StatValidation.PowerRequestFields) -- at the time + // this tool authored the shipped catalog's curves that briefly made those two records illegal under F6's + // now-deleted static check (StatValidation.ValidateNoPowerSupplyOnRequest); the nominal-request ruling + // (docs/stats-and-power-cut.md, operator ruling 2026-09-19, see ItemData.cs's PowerRequestFields comment) + // resolved that by having PowerRequest read request fields nominally instead of forbidding the term, so + // authoring it here on a request field is legal again. Exponent 1 (linear) is the gentle default the ruling + // calls for -- half supply reads as half performance, not a cliff -- and is left for the operator to steepen + // per design later. Dry run unless passed "apply"; every record must derive cleanly or nothing is written, + // same contract as ShieldMigrate. private static int BrownoutMigrate(bool apply) { var db = AetherDb.Open(catalogWritable: apply); const float gentleExponent = 1f; - // (behaviour type, target performance-stat field, short label) -- deliberately NOT the request field - // (StatValidation.PowerRequestFields already forbids a PowerSupply term there for each of these types). + // (behaviour type, target performance-stat field, short label) -- two of these are also request fields + // (StatValidation.PowerRequestFields); that is legal under the nominal-request ruling above. var targets = new (Type BehaviorType, string Field, string Label)[] { (typeof(ThrusterData), nameof(ThrusterData.Thrust), "thrust"),