Aetheria/Assets/Scripts/ServerShared/StatResolver.cs

142 lines
6.5 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 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);
}
}