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"),