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

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