Aetheria/Assets/Scripts/ServerShared/StatResolver.cs

177 lines
8.6 KiB
C#
Raw Normal View History

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>
2026-09-18 19:53:59 +02:00
/* 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 unconditionally, every tick, by EquippedItem.UpdatePerformance for Heat and Durability and by
// ConsumableItemEffect.Update for ConsumableProgress -- not only when the source's value actually moved. So
// the true invariant is not "recomputes only when a term's source changes": a Heat- or Durability-termed stat
// recomputes at most once per tick per (item, stat), every tick, regardless of whether the value moved.
// Quality is the one source this scheme still defends against a real no-op tick (F6: nothing invalidates it
// today, since EquippedItem.Lot is set once in the constructor -- harmless until an upgrade needs a lot swap
// to invalidate it, which §0b designs for but this cut does not yet wire up). Invalidates every cached value
// of this owner's that declared a term reading this source -- and only those.
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>
2026-09-18 19:53:59 +02:00
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);
}
// Cut 2 gap (docs/stats-and-power-cut.md §0b: a resolver entry is "destroyed at unequip / entity teardown"):
// nothing ever dropped an owner from _cache, _modifiers or _generations, so every EquippedItem that was ever
// unequipped and every ConsumableItemEffect that ever expired stayed reachable from these three dictionaries
// for the life of the process -- the same shape of leak §0.3 named, one level down. Called from the unequip
// path (Entity.cs TryUnequip) and from consumable expiry (Entity.cs Update); an owner with nothing recorded is
// a harmless no-op.
public void Forget(object owner)
{
_generations.Remove(owner);
RemoveEntriesForOwner(_cache, owner);
RemoveEntriesForOwner(_modifiers, owner);
}
private static void RemoveEntriesForOwner<TValue>(Dictionary<Entry, TValue> dict, object owner)
{
List<Entry> stale = null;
foreach (var entry in dict.Keys)
if (ReferenceEquals(entry.Owner, owner))
(stale ??= new List<Entry>()).Add(entry);
if (stale == null) return;
foreach (var entry in stale)
dict.Remove(entry);
}
// Test/diagnostic surface only, never a hot path: the retained-set sizes a mutation test pins against a
// baseline after equip/evaluate/unequip and activate/evaluate/expire cycles (Cut 2 Gate 1).
public int GenerationOwnerCount => _generations.Count;
public int CacheEntryCount => _cache.Count;
public int ModifierEntryCount => _modifiers.Count;
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>
2026-09-18 19:53:59 +02:00
}