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:
parent
8a717542a4
commit
1c1249e700
5 changed files with 166 additions and 15 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue