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

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