Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it

docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the
resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5)
as an identity everywhere. This lands the two things Cut 6 actually adds.

The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead
of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this
tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n).
PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the
missing half of the wiring named in the cut map -- so the resolver actually recomputes when
the grant moves, using the same per-source generation scheme Heat/Durability already use
(unconditional, at most once per tick, never touching a stat that did not declare the
source).

The request-independence rule: a stat that decides a power request may not depend on power
supply, directly or through a modifier chain. Direct dependency is checked statically
(StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of
(BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own
PowerRequest evaluates as its request, a compile-time fact rather than something authored
per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at
equip (EquippedItem's constructor) as defense against the two named bypasses that write a
catalog document without going through Upsert. The modifier-chain half is dynamic
(StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked
through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle
check, and refused at Initialize (equip time) if it is power-tainted and targets a known
request stat, naming both items.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-19 03:47:07 +02:00
commit 1c1249e700
5 changed files with 166 additions and 15 deletions

View file

@ -35,11 +35,16 @@ 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 2 (docs/stats-and-power-cut.md): a consumable's own StatModifierData needs the same check;
// consumables carry no heat response, so this is not folded into the loop above.
foreach (var data in cache.GetAll<ConsumableItemData>())
{
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors);
}
return cache;
}
catch
@ -75,9 +80,13 @@ public static class CultRecordRefs
{
StatValidation.ValidateHeatResponse(data);
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors);
}
if (document is ConsumableItemData consumable)
{
StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors);
StatValidation.ValidateNoPowerSupplyOnRequest(consumable.Name, consumable.Behaviors);
}
return new CultRecordRef<T>(cache.UpsertAsync(document).Result.Key);
}
}

View file

@ -85,6 +85,7 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa
RemoveModifier();
_targets = TargetsOf(Entity, _data);
ValidateNoCycle(Entity, _data, _targets);
ValidateNoPowerSupplyChain(Entity, _data, _targets, Item);
}
private static Target[] TargetsOf(Entity entity, StatModifierData data)
@ -106,17 +107,14 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa
.ToArray();
}
// A modifier's magnitude (_data.Modifier) is itself a PerformanceStat, evaluated through the same resolver as
// everything else it modifies. If that magnitude stat is, directly or through another modifier on this
// entity, one of the stats this modifier writes, resolving it would depend on a value computed from itself
// one tick late -- an authoring error, not a feature. Refused at equip, naming the entity and the reference.
// Built fresh from Entity.Equipment every time (never a static or catalog-side map), so it costs nothing to
// discard and cannot leak: by the time Activate() calls any StatModifier's Initialize, every EquippedItem
// (and so every StatModifier) on the entity already exists (Entity.cs Activate).
private static void ValidateNoCycle(Entity entity, StatModifierData data, Target[] targets)
// Shared by ValidateNoCycle and ValidateNoPowerSupplyChain below: every StatModifier currently on this
// entity, as an edge from the magnitude stat it reads to the stat(s) it writes. Built fresh from
// Entity.Equipment every time (never a static or catalog-side map), so it costs nothing to discard and cannot
// leak: by the time Activate() calls any StatModifier's Initialize, every EquippedItem (and so every
// StatModifier) on the entity already exists (Entity.cs Activate). Both checks see exactly what is actually
// equipped, not what the catalog merely allows.
private static Dictionary<PerformanceStat, List<PerformanceStat>> BuildModifierEdges(Entity entity, StatModifierData data, Target[] targets)
{
if (data.Modifier == null) return;
var edges = new Dictionary<PerformanceStat, List<PerformanceStat>>();
foreach (var behavior in entity.Equipment.SelectMany(e => e.Behaviors).OfType<StatModifier>())
{
@ -135,6 +133,18 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa
foreach (var target in behaviorTargets)
if (target.Stat != null) list.Add(target.Stat);
}
return edges;
}
// A modifier's magnitude (_data.Modifier) is itself a PerformanceStat, evaluated through the same resolver as
// everything else it modifies. If that magnitude stat is, directly or through another modifier on this
// entity, one of the stats this modifier writes, resolving it would depend on a value computed from itself
// one tick late -- an authoring error, not a feature. Refused at equip, naming the entity and the reference.
private static void ValidateNoCycle(Entity entity, StatModifierData data, Target[] targets)
{
if (data.Modifier == null) return;
var edges = BuildModifierEdges(entity, data, targets);
var visited = new HashSet<PerformanceStat>();
bool ReachesSelf(PerformanceStat node)
@ -153,6 +163,52 @@ 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.
private static void ValidateNoPowerSupplyChain(Entity entity, StatModifierData data, Target[] targets, EquippedItem modifyingItem)
{
if (data.Modifier == null) return;
var edges = BuildModifierEdges(entity, data, targets);
if (!IsPowerTainted(data.Modifier, edges, new HashSet<PerformanceStat>()))
return;
foreach (var target in targets)
{
if (!(target.Owner is EquippedItem targetItem)) continue;
if (!StatValidation.TryGetPowerRequestBehaviorName(targetItem.Data, target.Stat, out var behaviorName))
continue;
throw new InvalidOperationException(
$"{entity.Name}: stat modifier on \"{modifyingItem?.Data.Name ?? "?"}\" would let " +
$"\"{targetItem.Data.Name}\".{behaviorName}'s power request depend on power supply through a " +
"modifier chain -- a stat that decides how much power a behaviour asks for may not depend, even " +
"transitively, on how much it receives");
}
}
// A magnitude stat is power-tainted if its own Terms name PowerSupply directly, or if it is itself the
// target of some other modifier on this entity whose magnitude is (transitively) power-tainted. `visited`
// guards the search against a real cycle -- ValidateNoCycle already refuses those outright, but this walk
// must not stack-overflow while that refusal is still in flight for a different modifier on the same entity.
private static bool IsPowerTainted(PerformanceStat stat, Dictionary<PerformanceStat, List<PerformanceStat>> edges, HashSet<PerformanceStat> visited)
{
if (stat == null || !visited.Add(stat)) return false;
foreach (var term in stat.Terms)
if (term.Source == StatSource.PowerSupply)
return true;
foreach (var pair in edges)
if (pair.Value.Contains(stat) && IsPowerTainted(pair.Key, edges, visited))
return true;
return false;
}
// Cut 2: the modifier value used to be written straight into the catalog stat's own per-entity dictionary
// (the leak, §0.3). It now attaches to the resolver entry it targets, keyed by (that item, that stat); it is
// discarded with the entity, never with the catalog.

View file

@ -1354,6 +1354,14 @@ public class EquippedItem : IStatContext
if (consumerTiers.Length > 0)
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);
}
// Cut 2 (docs/stats-and-power-cut.md): the resolver owns the value; this is the caller's read of it, keyed by
@ -1363,13 +1371,18 @@ public class EquippedItem : IStatContext
public float HeatFactor(float exponent) => pow(ThermalPerformance, ThermalExponent * exponent);
public float DurabilityFactor(float exponent) => pow(DurabilityPerformance, DurabilityExponent * exponent);
public float ConsumableProgressFactor(float exponent) => 1f;
public float PowerSupplyFactor(float exponent) => 1f;
// Cut 6 (docs/stats-and-power-cut.md): the brownout curve. A stat with a PowerSupply term degrades as this
// tick's grant falls below 1 and is exactly unchanged at full supply (pow(1, exponent) == 1 regardless of
// exponent) -- the operator's "a continuous consumer that degrades instead of stopping." PowerBus writes
// PowerSupply below; the resolver invalidates this source once per tick from the same write (PowerBus.cs
// AllocateTiers), so this read is always this tick's own grant, never a stale one.
public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);
// Cut 3 (docs/stats-and-power-cut.md §1.2): PowerBus's grant ratio for this item, in [0,1], from whichever
// tick last ran its Step. Display-only and (once Cut 6 wires StatSource.PowerSupply into the resolver)
// stat-source-only: the only other reader is the very IPowerConsumer behaviour that declared the request this
// reflects. Forbidden writers: nothing but PowerBus sets this. Defaults to 1 (fully supplied) for an item
// that draws no power, or before the bus has run once.
// tick last ran its Step. Display-only and, as of Cut 6, stat-source-only: the only other reader is the
// resolver, through PowerSupplyFactor above, on behalf of whichever IPowerConsumer behaviour declared a
// PowerSupply term. Forbidden writers: nothing but PowerBus sets this. Defaults to 1 (fully supplied) for an
// item that draws no power, or before the bus has run once.
public float PowerSupply { get; internal set; } = 1f;
// Cut 2: these used to read the catalog stat's own per-entity dictionary (the leak, §0.3). A modifier now

View file

@ -754,6 +754,70 @@ public static class StatValidation
throw new InvalidOperationException($"{ownerName}: {ex.Message}");
}
}
// Cut 6 (docs/stats-and-power-cut.md): "a stat that decides a power request may not depend on power supply,
// directly or through a modifier chain, and no stat may depend on itself." IPowerConsumer.PowerRequest(dt)
// never hands the bus a number it invented on the spot -- each implementation evaluates exactly one fixed
// PerformanceStat field on its own BehaviorData (Thruster reads EnergyUsage, EnergyDraw reads EnergyDraw, and
// so on). That field is a compile-time fact about the C# type, not something authored per catalog instance,
// so it is 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.
public static readonly (Type Type, string Field)[] PowerRequestFields =
{
(typeof(EnergyDrawData), nameof(EnergyDrawData.EnergyDraw)),
(typeof(ThrusterData), nameof(ThrusterData.EnergyUsage)),
(typeof(ConstantWeaponData), nameof(WeaponData.Energy)),
(typeof(InstantWeaponData), nameof(WeaponData.Energy)),
(typeof(SensorData), nameof(SensorData.PingEnergy)),
(typeof(ShieldData), nameof(ShieldData.Capacity)),
(typeof(RadiatorData), nameof(RadiatorData.EnergyUsage)),
(typeof(AetherDriveData), nameof(AetherDriveData.EnergyDraw)),
};
// 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.
public static bool TryGetPowerRequestBehaviorName(EquippableItemData data, PerformanceStat stat, out string behaviorName)
{
foreach (var behavior in data.Behaviors)
foreach (var (type, field) in PowerRequestFields)
{
if (!type.IsInstanceOfType(behavior)) continue;
if (ReferenceEquals(type.GetField(field)?.GetValue(behavior), stat))
{
behaviorName = type.Name;
return true;
}
}
behaviorName = null;
return false;
}
}
[CultDocument("aetheria.personalityattribute", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]

View file

@ -161,7 +161,16 @@ public class PowerBus
}
foreach (var draw in draws)
{
draw.Item.PowerSupply = ratios[draw.Tier];
// Cut 6 (docs/stats-and-power-cut.md): the missing half of the wiring -- a stat with a PowerSupply
// term must recompute when the grant actually moves. Called unconditionally, once per draw per tick,
// the same shape as EquippedItem.UpdatePerformance's own Heat/Durability invalidation: "recomputes at
// most once per tick per (item, stat)," not "only when the value moved." An item with no PowerSupply
// term pays nothing extra -- the resolver's per-source generation bookkeeping (StatResolver.Resolve)
// only ever looks at sources a stat's own Terms declared.
_entity.Resolver.InvalidateSource(draw.Item, StatSource.PowerSupply);
}
}
// Mirrors Entity.TryConsumeEnergy's old do/while exactly (draw evenly across every capacitor that still has