Aetheria/Assets/Scripts/ServerShared/StatResolver.cs
Meta Vens aaab561f98 Consumable host: initialise at activation, dispose at expiry, instantiate consumables
ActivateConsumable runs Initialize on the effect's behaviours; expiry disposes
them before Resolver.Forget, so a consumable's modifiers exist exactly while
its effect is active. ItemManager.CreateInstance returns a ConsumableItem for a
consumable design. StatResolver.DetachModifier drops an entry left with
nothing attached, so ModifierEntryCount returns to baseline. LoadoutTests'
hand-built ConsumableItem goes through CreateInstance. Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:19:22 +02:00

180 lines
8.8 KiB
C#

/* 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.
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);
// An entry with nothing attached is no entry: a modifier that is gone leaves no trace in the resolver.
if (set.Scale.Count == 0 && set.Constant.Count == 0)
_modifiers.Remove(entry);
}
_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;
}