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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-19 06:58:57 +02:00
commit 8bbf280756
19 changed files with 528 additions and 209 deletions

View file

@ -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<ConsumableItemData>())
{
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);
}
}

View file

@ -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;
}

View file

@ -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)

View file

@ -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;

View file

@ -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.

View file

@ -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);
}

View file

@ -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

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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.

View file

@ -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;

View file

@ -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<BehaviorData> 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.