Cut 2: give every entity its own StatResolver, delete the catalog leak

PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers,
grown by one key per entity that ever evaluated the stat and never trimmed
(docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of
every resolved (item, stat) value and every attached modifier, one instance
per Entity and reachable only from it. EquippedItem and ConsumableItemEffect
now read and write through it instead of through the catalog object; the two
dictionaries and their four accessors are deleted outright, not hidden.

StatModifier no longer resolves its (type name, field name) reference by
runtime reflection search on every equip: StatValidation.ResolveStatField
does it once, memoized, and StatValidation.ValidateStatModifiers checks it at
catalog load and at Upsert, naming the record instead of failing with an
opaque NullReferenceException the first time something equips it. A modifier
whose own magnitude stat is (directly or transitively, through another
modifier on the same entity) one of the stats it writes is refused at equip:
resolving it would depend on a value computed from itself one tick late.

A resolved value now caches by the generation of the sources its declared
Terms actually read, invalidated per source by EquippedItem.UpdatePerformance
(Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a
stat recomputes only when a term it declared moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-18 19:53:59 +02:00
commit dbb8be39d9
9 changed files with 806 additions and 84 deletions

View file

@ -32,7 +32,14 @@ public static class AetheriaStores
// R-heat (docs/stats-and-power-cut.md): every equippable design's heat response must describe a
// coherent range before anything reads it. Fails loudly, naming the item.
foreach (var data in cache.GetAll<EquippableItemData>())
{
StatValidation.ValidateHeatResponse(data);
StatValidation.ValidateStatModifiers(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);
return cache;
}
catch
@ -64,7 +71,13 @@ public static class CultRecordRefs
// before the write ever reaches disk, not just the next time someone reopens the file.
public static CultRecordRef<T> Upsert<T>(this CultCache cache, T document) where T : class
{
if (document is EquippableItemData data) StatValidation.ValidateHeatResponse(data);
if (document is EquippableItemData data)
{
StatValidation.ValidateHeatResponse(data);
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
}
if (document is ConsumableItemData consumable)
StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors);
return new CultRecordRef<T>(cache.UpsertAsync(document).Result.Key);
}
}

View file

@ -21,6 +21,10 @@ public abstract class Behavior
protected Entity Entity => Item?.Entity ?? Consumable.Entity;
public float Temperature => Item?.Temperature ?? Consumable.Entity.MaxTemp;
// The StatResolver key for whichever of Item/Consumable this behaviour has -- the two owners a resolved stat
// value or an attached modifier can belong to (StatResolver.cs, docs/stats-and-power-cut.md Cut 2).
protected object StatOwner => (object) Item ?? Consumable;
public float3 Direction
{
get

View file

@ -3,6 +3,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.Linq;
using MessagePack;
using Newtonsoft.Json;
@ -36,18 +37,24 @@ public class StatModifierData : BehaviorData
public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwaysUpdatedBehavior
{
// A resolved value is per (item, stat) (docs/stats-and-power-cut.md §1.1), so a modifier that reaches a stat
// must attach under the owner that specific stat resolves through -- the EquippedItem the field came from --
// not under the modifying behaviour's own owner. Two items of the same design targeted by one modifier get
// two separate resolver entries, exactly as two separate Evaluate calls on them already would.
private readonly struct Target
{
public readonly object Owner;
public readonly PerformanceStat Stat;
public Target(object owner, PerformanceStat stat) { Owner = owner; Stat = stat; }
}
private StatModifierData _data;
private PerformanceStat[] _stats;
private static Type[] _statObjects;
private Target[] _targets;
private bool _applied;
private bool _executed;
private static Type[] StatObjects => _statObjects = _statObjects ?? typeof(BehaviorData).GetAllChildClasses()
.Concat(typeof(EquippableItemData).GetAllChildClasses()).ToArray();
public StatModifier(StatModifierData data, EquippedItem item) : base(data, item)
{
_data = data;
@ -58,48 +65,98 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa
_data = data;
}
// Cut 2 (docs/stats-and-power-cut.md): the (type name, field name) resolution that used to live here, done
// fresh and silently at every equip, now goes through StatValidation.ResolveStatField -- resolved once,
// memoized, and refused loudly (at catalog load, or here if a caller built one without going through the
// catalog) instead of leaving `_stats` null for ApplyModifier to throw an opaque NullReferenceException on.
public void Initialize()
{
var targetType = StatObjects.FirstOrDefault(so => so.Name == _data.Stat.Target);
if (targetType != null)
{
var statField = targetType.GetFields()
.Where(f => f.FieldType == typeof(PerformanceStat)).FirstOrDefault(f => f.Name == _data.Stat.Stat);
if (statField != null)
{
if (typeof(EquippableItemData).IsAssignableFrom(targetType))
_stats = Entity.Equipment
.Select(hp => hp.EquippableItem)
.Where(gear => string.IsNullOrEmpty(_data.RequireBehavior) || ItemManager.GetData(gear).Behaviors.Any(behavior => behavior.GetType().Name == _data.RequireBehavior))
.Where(gear => ItemManager.GetData(gear).GetType() == targetType)
.Select(gear => statField.GetValue(ItemManager.GetData(gear)) as PerformanceStat)
.ToArray();
else
_stats = Entity.Equipment
.Select(hp => hp.EquippableItem)
.Where(gear => string.IsNullOrEmpty(_data.RequireBehavior) || ItemManager.GetData(gear).Behaviors.Any(behavior => behavior.GetType().Name == _data.RequireBehavior))
.SelectMany(gear => ItemManager.GetData(gear).Behaviors)
.Where(behaviorData => behaviorData.GetType() == targetType)
.Select(behaviorData => statField.GetValue(behaviorData) as PerformanceStat)
.ToArray();
}
}
_targets = TargetsOf(Entity, _data);
ValidateNoCycle(Entity, _data, _targets);
}
private static Target[] TargetsOf(Entity entity, StatModifierData data)
{
var (targetType, statField) = StatValidation.ResolveStatField(data.Stat);
var gear = entity.Equipment
.Where(g => string.IsNullOrEmpty(data.RequireBehavior) ||
entity.ItemManager.GetData(g.EquippableItem).Behaviors.Any(b => b.GetType().Name == data.RequireBehavior));
return typeof(EquippableItemData).IsAssignableFrom(targetType)
? gear.Where(g => entity.ItemManager.GetData(g.EquippableItem).GetType() == targetType)
.Select(g => new Target(g, statField.GetValue(entity.ItemManager.GetData(g.EquippableItem)) as PerformanceStat))
.Where(t => t.Stat != null)
.ToArray()
: gear.Where(g => entity.ItemManager.GetData(g.EquippableItem).Behaviors.Any(bd => bd.GetType() == targetType))
.SelectMany(g => entity.ItemManager.GetData(g.EquippableItem).Behaviors
.Where(bd => bd.GetType() == targetType)
.Select(bd => new Target(g, statField.GetValue(bd) as PerformanceStat)))
.Where(t => t.Stat != null)
.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)
{
if (data.Modifier == null) return;
var edges = new Dictionary<PerformanceStat, List<PerformanceStat>>();
foreach (var behavior in entity.Equipment.SelectMany(e => e.Behaviors).OfType<StatModifier>())
{
if (behavior._data.Modifier == null) continue;
Target[] behaviorTargets;
try
{
behaviorTargets = ReferenceEquals(behavior._data, data) ? targets : TargetsOf(entity, behavior._data);
}
catch (InvalidOperationException)
{
continue; // another modifier's own unresolved reference is that modifier's Initialize to refuse
}
if (!edges.TryGetValue(behavior._data.Modifier, out var list))
edges[behavior._data.Modifier] = list = new List<PerformanceStat>();
foreach (var target in behaviorTargets)
if (target.Stat != null) list.Add(target.Stat);
}
var visited = new HashSet<PerformanceStat>();
bool ReachesSelf(PerformanceStat node)
{
if (!edges.TryGetValue(node, out var next)) return false;
foreach (var target in next)
{
if (ReferenceEquals(target, data.Modifier)) return true;
if (visited.Add(target) && ReachesSelf(target)) return true;
}
return false;
}
if (ReachesSelf(data.Modifier))
throw new InvalidOperationException(
$"{entity.Name}: stat modifier \"{data.Stat.Target}.{data.Stat.Stat}\" reads a magnitude stat whose " +
"modifier chain reaches back to itself -- a stat cannot (even transitively) modify its own magnitude");
}
// 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.
private void ApplyModifier()
{
_applied = true;
foreach (var stat in _stats)
(_data.Type == StatModifierType.Constant
? stat.GetConstantModifiers(Entity)
: stat.GetScaleModifiers(Entity))[this] = Evaluate(_data.Modifier);
var value = Evaluate(_data.Modifier);
foreach (var target in _targets)
Entity.Resolver.AttachModifier(target.Owner, target.Stat, this, _data.Type, value);
}
private void RemoveModifier()
{
_applied = false;
foreach (var stat in _stats)
(_data.Type == StatModifierType.Constant ? stat.GetConstantModifiers(Entity) : stat.GetScaleModifiers(Entity)).Remove(this);
foreach (var target in _targets)
Entity.Resolver.DetachModifier(target.Owner, target.Stat, this);
}
public override bool Execute(float dt)

View file

@ -62,6 +62,11 @@ public abstract class Entity
public List<IPopulationAssignment> PopulationAssignments = new List<IPopulationAssignment>();
// Cut 2 (docs/stats-and-power-cut.md): the sole owner of every resolved (item, stat) value for this entity.
// Constructed with the entity and reachable only from it -- nothing on the catalog side ever points back --
// so it is collected with the entity instead of surviving it for the life of the process (§0.3, §1.1).
public readonly StatResolver Resolver = new StatResolver();
public EquippedItem[,] GearOccupancy;
public HardpointData[,] Hardpoints;
public float[,] Armor;
@ -1175,13 +1180,14 @@ public class ConsumableItemEffect : IStatContext
}
RemainingDuration -= delta;
// Progress moves every tick; a stat with a ConsumableProgress term must recompute against it, the same
// way EquippedItem signals Heat and Durability from UpdatePerformance below.
Entity.Resolver.InvalidateSource(this, StatSource.ConsumableProgress);
}
public float Evaluate(PerformanceStat stat)
{
var result = stat.Evaluate(this);
return float.IsNaN(result) ? stat.Min : result;
}
// Cut 2 (docs/stats-and-power-cut.md): the resolver owns the value; this is the caller's read of it, keyed by
// this effect instance (never by Entity, so two active consumables never share an entry).
public float Evaluate(PerformanceStat stat) => Entity.Resolver.Resolve(this, stat, this);
// Cut 1 (docs/stats-and-power-cut.md): progress-through-duration is now its own StatSource
// (ConsumableProgress) rather than a hard-coded override of "heat" that applied to every stat regardless of
@ -1192,6 +1198,7 @@ public class ConsumableItemEffect : IStatContext
public float ConsumableProgressFactor(float exponent) =>
pow(Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration), exponent);
public float PowerSupplyFactor(float exponent) => 1f;
// No modifiers: there is no entity's worth of equipment to modify against (comment above, unchanged by Cut 2).
public float ScaleModifier(PerformanceStat stat) => 1f;
public float ConstantModifier(PerformanceStat stat) => 0f;
}
@ -1362,30 +1369,19 @@ public class EquippedItem : IStatContext
}
}
public float Evaluate(PerformanceStat stat)
{
var result = stat.Evaluate(this);
return float.IsNaN(result) ? stat.Min : result;
}
// 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);
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;
public float ScaleModifier(PerformanceStat stat)
{
var scaleModifier = 1.0f;
foreach (var value in stat.GetScaleModifiers(Entity).Values) scaleModifier *= value;
return scaleModifier;
}
public float ConstantModifier(PerformanceStat stat)
{
float constantModifier = 0;
foreach (var value in stat.GetConstantModifiers(Entity).Values) constantModifier += value;
return constantModifier;
}
// Cut 2: these used to read the catalog stat's own per-entity dictionary (the leak, §0.3). A modifier now
// attaches to this entity's resolver, keyed by (this item, stat); reading it here is unchanged.
public float ScaleModifier(PerformanceStat stat) => Entity.Resolver.ScaleModifier(this, stat);
public float ConstantModifier(PerformanceStat stat) => Entity.Resolver.ConstantModifier(this, stat);
public void AddHeat(float heat, bool ignoreThermalMass = false)
{
@ -1394,16 +1390,18 @@ public class EquippedItem : IStatContext
}
public void UpdatePerformance()
{
{
var temp = Temperature;
ThermalPerformance = Data.Performance(temp);
Entity.Resolver.InvalidateSource(this, StatSource.Heat);
var deltaTemp = math.abs(temp - oldTemperature);
DurabilityPerformance = EquippableItem.Durability / Data.Durability;
Entity.Resolver.InvalidateSource(this, StatSource.Durability);
var performanceThreshold = Entity.Settings.ShutdownPerformance;
Wear = (1 - pow(ThermalPerformance,
(1 - pow(Lot.Quality, ItemManager.GameplaySettings.QualityWearExponent)) *
ItemManager.GameplaySettings.ThermalWearExponent) +
deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent
deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent
) * Data.Durability / Data.ThermalResilience;
ThermalOnline.Value = ThermalPerformance > performanceThreshold || Entity.OverrideShutdown && EquippableItem.OverrideShutdown;
DurabilityOnline.Value = EquippableItem.Durability > .01f;

View file

@ -6,6 +6,7 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using GameCult.Caching;
using MessagePack;
using Newtonsoft.Json;
@ -631,32 +632,11 @@ public class PerformanceStat
[Inspectable, JsonProperty("terms"), Key(6)]
public List<StatTerm> Terms = new List<StatTerm>();
[IgnoreMember] private Dictionary<Entity,Dictionary<Behavior,float>> _scaleModifiers;
[IgnoreMember] private Dictionary<Entity,Dictionary<Behavior,float>> _constantModifiers;
[IgnoreMember]
private Dictionary<Entity, Dictionary<Behavior, float>> ScaleModifiers =>
_scaleModifiers = _scaleModifiers ?? new Dictionary<Entity, Dictionary<Behavior, float>>();
[IgnoreMember]
private Dictionary<Entity, Dictionary<Behavior, float>> ConstantModifiers =>
_constantModifiers = _constantModifiers ?? new Dictionary<Entity, Dictionary<Behavior, float>>();
public Dictionary<Behavior, float> GetScaleModifiers(Entity entity)
{
if(!ScaleModifiers.ContainsKey(entity))
ScaleModifiers[entity] = new Dictionary<Behavior, float>();
return ScaleModifiers[entity];
}
public Dictionary<Behavior, float> GetConstantModifiers(Entity entity)
{
if(!ConstantModifiers.ContainsKey(entity))
ConstantModifiers[entity] = new Dictionary<Behavior, float>();
return ConstantModifiers[entity];
}
// Cut 2 (docs/stats-and-power-cut.md): this catalog object used to hold two Dictionary<Entity, ...> fields
// here, keyed by every entity that ever evaluated it, with no removal -- the leak (§0.3). A stat is shared;
// a resolved value, and the modifiers that shape it, are not. Both now live in the entity's own StatResolver
// (StatResolver.cs), reachable only from the entity, so they die when the entity does instead of when the
// process does. Do not reintroduce per-entity state here under any name.
// The one evaluation path. A stat with no terms resolves to Max (the identity factor, 1, times Min/Max
// interpolation lands on the top) -- that is the same number a stat with all-zero exponents produced before
@ -717,6 +697,49 @@ public static class StatValidation
$"[{data.MinimumTemperature}, {data.MaximumTemperature}] -- the plateau must clamp to the bounds, " +
"not exceed them");
}
// Cut 2 (docs/stats-and-power-cut.md): a StatReference names its target by (type name, field name) so a
// modifier can point at any design or behaviour without a hard type reference -- but before this cut an
// unresolvable reference failed silently: StatModifier.Initialize left `_stats` null and the first
// ApplyModifier threw an unhelpful NullReferenceException at random equip time. Resolving it here, once,
// memoized, and failing loudly at catalog load or Upsert (through ValidateStatModifiers below) names the
// record instead of leaving the bug for whoever equips the item first.
private static readonly Dictionary<(string Target, string Stat), (Type Type, FieldInfo Field)> _resolvedStatFields =
new Dictionary<(string, string), (Type, FieldInfo)>();
private static Type[] _statReferenceTypes;
private static Type[] StatReferenceTypes => _statReferenceTypes ??= typeof(BehaviorData).GetAllChildClasses()
.Concat(typeof(EquippableItemData).GetAllChildClasses()).ToArray();
public static (Type Type, FieldInfo Field) ResolveStatField(StatReference reference)
{
var key = (reference.Target, reference.Stat);
if (_resolvedStatFields.TryGetValue(key, out var cached)) return cached;
var targetType = StatReferenceTypes.FirstOrDefault(t => t.Name == reference.Target);
var field = targetType?.GetFields().FirstOrDefault(f => f.FieldType == typeof(PerformanceStat) && f.Name == reference.Stat);
if (targetType == null || field == null)
throw new InvalidOperationException(
$"stat modifier targets \"{reference.Target}.{reference.Stat}\", which does not resolve to a PerformanceStat field");
var resolved = (targetType, field);
_resolvedStatFields[key] = resolved;
return resolved;
}
// Runs wherever ValidateHeatResponse runs (AetheriaStores.Open, CultRecordRefs.Upsert): every StatModifierData
// authored on this design must resolve, named here rather than at whichever equip happens to hit it first.
public static void ValidateStatModifiers(string ownerName, IEnumerable<BehaviorData> behaviors)
{
foreach (var behavior in behaviors)
if (behavior is StatModifierData modifier)
try
{
ResolveStatField(modifier.Stat);
}
catch (InvalidOperationException ex)
{
throw new InvalidOperationException($"{ownerName}: {ex.Message}");
}
}
}
[CultDocument("aetheria.personalityattribute", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]

View file

@ -0,0 +1,142 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
// Cut 2 (docs/stats-and-power-cut.md): one resolver per Entity, owning every resolved (item, stat) value for
// that entity. PerformanceStat -- the catalog object -- used to hold two Dictionary<Entity, ...> fields and grew
// one entry per entity that ever evaluated it, for the life of the process (§0.3). A stat is shared; a resolved
// value is not (§0b) -- that sentence is this type's whole reason to exist. It is reachable only from the Entity
// that owns it, so it dies when the entity does, not when the catalog does.
//
// "Owner" below is whatever called Resolve/AttachModifier for a stat -- an EquippedItem or a ConsumableItemEffect,
// compared by reference, never by value. Two ships equipping the same design share the PerformanceStat instance
// but never share an owner, so they never share a cached value or a modifier.
public class StatResolver
{
private readonly struct Entry : IEquatable<Entry>
{
public readonly object Owner;
public readonly PerformanceStat Stat;
public Entry(object owner, PerformanceStat stat)
{
Owner = owner;
Stat = stat;
}
public bool Equals(Entry other) => ReferenceEquals(Owner, other.Owner) && ReferenceEquals(Stat, other.Stat);
public override bool Equals(object obj) => obj is Entry other && Equals(other);
public override int GetHashCode() => unchecked(
RuntimeHelpers.GetHashCode(Owner) * 397 ^ RuntimeHelpers.GetHashCode(Stat));
}
// A resolved value is valid only as long as every source it read is still at the generation it read.
// Generations are per (owner, source): a term's *source kind*, not the specific stat, so invalidating "this
// owner's heat changed" invalidates every stat of that owner's that declared a Heat term, and no others.
private class CachedValue
{
public float Value;
public Dictionary<StatSource, int> Generations;
}
private class ModifierSet
{
public readonly Dictionary<object, float> Scale = new Dictionary<object, float>();
public readonly Dictionary<object, float> Constant = new Dictionary<object, float>();
}
private readonly Dictionary<Entry, CachedValue> _cache = new Dictionary<Entry, CachedValue>();
private readonly Dictionary<Entry, ModifierSet> _modifiers = new Dictionary<Entry, ModifierSet>();
private readonly Dictionary<object, Dictionary<StatSource, int>> _generations =
new Dictionary<object, Dictionary<StatSource, int>>();
private int GenerationOf(object owner, StatSource source)
{
if (_generations.TryGetValue(owner, out var perSource) && perSource.TryGetValue(source, out var generation))
return generation;
return 0;
}
// Called by an owner when one of its own sources moves: EquippedItem.UpdatePerformance every tick for Heat
// and Durability, ConsumableItemEffect.Update every tick for ConsumableProgress. Invalidates every cached
// value of this owner's that declared a term reading this source -- and only those.
public void InvalidateSource(object owner, StatSource source)
{
if (!_generations.TryGetValue(owner, out var perSource))
_generations[owner] = perSource = new Dictionary<StatSource, int>();
perSource[source] = GenerationOf(owner, source) + 1;
}
// The only place a resolved value is computed. Returns the cached value when every source the stat's Terms
// declared is still at the generation it was computed against; otherwise evaluates once, against the live
// context, and caches the new value against the current generations.
public float Resolve(object owner, PerformanceStat stat, IStatContext context)
{
var entry = new Entry(owner, stat);
if (_cache.TryGetValue(entry, out var cached) && IsCurrent(owner, cached))
return cached.Value;
var value = stat.Evaluate(context);
if (float.IsNaN(value)) value = stat.Min;
var generations = new Dictionary<StatSource, int>();
foreach (var term in stat.Terms)
if (!generations.ContainsKey(term.Source))
generations[term.Source] = GenerationOf(owner, term.Source);
_cache[entry] = new CachedValue { Value = value, Generations = generations };
return value;
}
private bool IsCurrent(object owner, CachedValue cached)
{
foreach (var pair in cached.Generations)
if (GenerationOf(owner, pair.Key) != pair.Value)
return false;
return true;
}
public float ScaleModifier(object owner, PerformanceStat stat)
{
if (!_modifiers.TryGetValue(new Entry(owner, stat), out var set)) return 1f;
var result = 1f;
foreach (var value in set.Scale.Values) result *= value;
return result;
}
public float ConstantModifier(object owner, PerformanceStat stat)
{
if (!_modifiers.TryGetValue(new Entry(owner, stat), out var set)) return 0f;
var result = 0f;
foreach (var value in set.Constant.Values) result += value;
return result;
}
// Attached by StatModifier when its behaviour executes and detached when it stops (StatModifier.cs); the
// modifier's own instance is the key both ways, exactly as it was on the catalog object before this cut --
// only the dictionary this now lives in has changed owner. Invalidates the entry directly: a modifier is not
// one of the stat's declared Terms, so the source-generation scheme above does not see it move.
public void AttachModifier(object owner, PerformanceStat stat, object modifierKey, StatModifierType type, float value)
{
var entry = new Entry(owner, stat);
if (!_modifiers.TryGetValue(entry, out var set))
_modifiers[entry] = set = new ModifierSet();
(type == StatModifierType.Constant ? set.Constant : set.Scale)[modifierKey] = value;
_cache.Remove(entry);
}
public void DetachModifier(object owner, PerformanceStat stat, object modifierKey)
{
var entry = new Entry(owner, stat);
if (_modifiers.TryGetValue(entry, out var set))
{
set.Scale.Remove(modifierKey);
set.Constant.Remove(modifierKey);
}
_cache.Remove(entry);
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b9e2b7d6335e48709359a81ae2ea98ad
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: