diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index f823b0514..47e935999 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -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()) + { 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(cache.UpsertAsync(document).Result.Key); } } diff --git a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs index 71164bfcb..975911321 100644 --- a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs +++ b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs @@ -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> BuildModifierEdges(Entity entity, StatModifierData data, Target[] targets) { - if (data.Modifier == null) return; - var edges = new Dictionary>(); foreach (var behavior in entity.Equipment.SelectMany(e => e.Behaviors).OfType()) { @@ -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(); 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())) + 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> edges, HashSet 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. diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 40513f9c6..8a051bc67 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -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 diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 3df548feb..8f318d9d0 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -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 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)] diff --git a/Assets/Scripts/ServerShared/PowerBus.cs b/Assets/Scripts/ServerShared/PowerBus.cs index 53c1b9d01..6942688ae 100644 --- a/Assets/Scripts/ServerShared/PowerBus.cs +++ b/Assets/Scripts/ServerShared/PowerBus.cs @@ -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