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:
parent
3461eec520
commit
dbb8be39d9
9 changed files with 806 additions and 84 deletions
|
|
@ -32,7 +32,14 @@ public static class AetheriaStores
|
|||
// R-heat (docs/stats-and-power-cut.md): every equippable design's heat response must describe a
|
||||
// coherent range before anything reads it. Fails loudly, naming the item.
|
||||
foreach (var data in cache.GetAll<EquippableItemData>())
|
||||
{
|
||||
StatValidation.ValidateHeatResponse(data);
|
||||
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
|
||||
}
|
||||
// Cut 2 (docs/stats-and-power-cut.md): a consumable's own StatModifierData needs the same check;
|
||||
// consumables carry no heat response, so this is not folded into the loop above.
|
||||
foreach (var data in cache.GetAll<ConsumableItemData>())
|
||||
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
|
||||
return cache;
|
||||
}
|
||||
catch
|
||||
|
|
@ -64,7 +71,13 @@ public static class CultRecordRefs
|
|||
// before the write ever reaches disk, not just the next time someone reopens the file.
|
||||
public static CultRecordRef<T> Upsert<T>(this CultCache cache, T document) where T : class
|
||||
{
|
||||
if (document is EquippableItemData data) StatValidation.ValidateHeatResponse(data);
|
||||
if (document is EquippableItemData data)
|
||||
{
|
||||
StatValidation.ValidateHeatResponse(data);
|
||||
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
|
||||
}
|
||||
if (document is ConsumableItemData consumable)
|
||||
StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors);
|
||||
return new CultRecordRef<T>(cache.UpsertAsync(document).Result.Key);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ public abstract class Behavior
|
|||
protected Entity Entity => Item?.Entity ?? Consumable.Entity;
|
||||
public float Temperature => Item?.Temperature ?? Consumable.Entity.MaxTemp;
|
||||
|
||||
// The StatResolver key for whichever of Item/Consumable this behaviour has -- the two owners a resolved stat
|
||||
// value or an attached modifier can belong to (StatResolver.cs, docs/stats-and-power-cut.md Cut 2).
|
||||
protected object StatOwner => (object) Item ?? Consumable;
|
||||
|
||||
public float3 Direction
|
||||
{
|
||||
get
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ public abstract class Entity
|
|||
|
||||
public List<IPopulationAssignment> PopulationAssignments = new List<IPopulationAssignment>();
|
||||
|
||||
// Cut 2 (docs/stats-and-power-cut.md): the sole owner of every resolved (item, stat) value for this entity.
|
||||
// Constructed with the entity and reachable only from it -- nothing on the catalog side ever points back --
|
||||
// so it is collected with the entity instead of surviving it for the life of the process (§0.3, §1.1).
|
||||
public readonly StatResolver Resolver = new StatResolver();
|
||||
|
||||
public EquippedItem[,] GearOccupancy;
|
||||
public HardpointData[,] Hardpoints;
|
||||
public float[,] Armor;
|
||||
|
|
@ -1175,13 +1180,14 @@ public class ConsumableItemEffect : IStatContext
|
|||
}
|
||||
|
||||
RemainingDuration -= delta;
|
||||
// Progress moves every tick; a stat with a ConsumableProgress term must recompute against it, the same
|
||||
// way EquippedItem signals Heat and Durability from UpdatePerformance below.
|
||||
Entity.Resolver.InvalidateSource(this, StatSource.ConsumableProgress);
|
||||
}
|
||||
|
||||
public float Evaluate(PerformanceStat stat)
|
||||
{
|
||||
var result = stat.Evaluate(this);
|
||||
return float.IsNaN(result) ? stat.Min : result;
|
||||
}
|
||||
// Cut 2 (docs/stats-and-power-cut.md): the resolver owns the value; this is the caller's read of it, keyed by
|
||||
// this effect instance (never by Entity, so two active consumables never share an entry).
|
||||
public float Evaluate(PerformanceStat stat) => Entity.Resolver.Resolve(this, stat, this);
|
||||
|
||||
// Cut 1 (docs/stats-and-power-cut.md): progress-through-duration is now its own StatSource
|
||||
// (ConsumableProgress) rather than a hard-coded override of "heat" that applied to every stat regardless of
|
||||
|
|
@ -1192,6 +1198,7 @@ public class ConsumableItemEffect : IStatContext
|
|||
public float ConsumableProgressFactor(float exponent) =>
|
||||
pow(Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration), exponent);
|
||||
public float PowerSupplyFactor(float exponent) => 1f;
|
||||
// No modifiers: there is no entity's worth of equipment to modify against (comment above, unchanged by Cut 2).
|
||||
public float ScaleModifier(PerformanceStat stat) => 1f;
|
||||
public float ConstantModifier(PerformanceStat stat) => 0f;
|
||||
}
|
||||
|
|
@ -1362,30 +1369,19 @@ public class EquippedItem : IStatContext
|
|||
}
|
||||
}
|
||||
|
||||
public float Evaluate(PerformanceStat stat)
|
||||
{
|
||||
var result = stat.Evaluate(this);
|
||||
return float.IsNaN(result) ? stat.Min : result;
|
||||
}
|
||||
// Cut 2 (docs/stats-and-power-cut.md): the resolver owns the value; this is the caller's read of it, keyed by
|
||||
// this EquippedItem instance, so two ships equipping the same design never share a resolved value.
|
||||
public float Evaluate(PerformanceStat stat) => Entity.Resolver.Resolve(this, stat, this);
|
||||
|
||||
public float HeatFactor(float exponent) => pow(ThermalPerformance, ThermalExponent * exponent);
|
||||
public float DurabilityFactor(float exponent) => pow(DurabilityPerformance, DurabilityExponent * exponent);
|
||||
public float ConsumableProgressFactor(float exponent) => 1f;
|
||||
public float PowerSupplyFactor(float exponent) => 1f;
|
||||
|
||||
public float ScaleModifier(PerformanceStat stat)
|
||||
{
|
||||
var scaleModifier = 1.0f;
|
||||
foreach (var value in stat.GetScaleModifiers(Entity).Values) scaleModifier *= value;
|
||||
return scaleModifier;
|
||||
}
|
||||
|
||||
public float ConstantModifier(PerformanceStat stat)
|
||||
{
|
||||
float constantModifier = 0;
|
||||
foreach (var value in stat.GetConstantModifiers(Entity).Values) constantModifier += value;
|
||||
return constantModifier;
|
||||
}
|
||||
// Cut 2: these used to read the catalog stat's own per-entity dictionary (the leak, §0.3). A modifier now
|
||||
// attaches to this entity's resolver, keyed by (this item, stat); reading it here is unchanged.
|
||||
public float ScaleModifier(PerformanceStat stat) => Entity.Resolver.ScaleModifier(this, stat);
|
||||
public float ConstantModifier(PerformanceStat stat) => Entity.Resolver.ConstantModifier(this, stat);
|
||||
|
||||
public void AddHeat(float heat, bool ignoreThermalMass = false)
|
||||
{
|
||||
|
|
@ -1394,16 +1390,18 @@ public class EquippedItem : IStatContext
|
|||
}
|
||||
|
||||
public void UpdatePerformance()
|
||||
{
|
||||
{
|
||||
var temp = Temperature;
|
||||
ThermalPerformance = Data.Performance(temp);
|
||||
Entity.Resolver.InvalidateSource(this, StatSource.Heat);
|
||||
var deltaTemp = math.abs(temp - oldTemperature);
|
||||
DurabilityPerformance = EquippableItem.Durability / Data.Durability;
|
||||
Entity.Resolver.InvalidateSource(this, StatSource.Durability);
|
||||
var performanceThreshold = Entity.Settings.ShutdownPerformance;
|
||||
Wear = (1 - pow(ThermalPerformance,
|
||||
(1 - pow(Lot.Quality, ItemManager.GameplaySettings.QualityWearExponent)) *
|
||||
ItemManager.GameplaySettings.ThermalWearExponent) +
|
||||
deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent
|
||||
deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent
|
||||
) * Data.Durability / Data.ThermalResilience;
|
||||
ThermalOnline.Value = ThermalPerformance > performanceThreshold || Entity.OverrideShutdown && EquippableItem.OverrideShutdown;
|
||||
DurabilityOnline.Value = EquippableItem.Durability > .01f;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
142
Assets/Scripts/ServerShared/StatResolver.cs
Normal file
142
Assets/Scripts/ServerShared/StatResolver.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/* 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);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/StatResolver.cs.meta
Normal file
11
Assets/Scripts/ServerShared/StatResolver.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b9e2b7d6335e48709359a81ae2ea98ad
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
273
tests/Aetheria.Shared.Tests/StatResolverTests.cs
Normal file
273
tests/Aetheria.Shared.Tests/StatResolverTests.cs
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
||||
// Cut 2 (docs/stats-and-power-cut.md): the resolver owns every value. PerformanceStat -- the catalog object --
|
||||
// used to hold two Dictionary<Entity, ...> fields (the modifier sets) that grew one entry per entity that ever
|
||||
// evaluated it, for the life of the process (§0.3). This file pins the two structural claims that fix replaces
|
||||
// them with: one StatResolver per Entity, reachable only from it, and a resolved value that recomputes only when
|
||||
// a source it declared actually moves.
|
||||
public sealed class StatResolverTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-statresolver-" + Guid.NewGuid().ToString("N"));
|
||||
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
||||
private static readonly int2 HardpointCell = new int2(0, 0);
|
||||
private static readonly int2 SecondHardpointCell = new int2(1, 0);
|
||||
|
||||
public StatResolverTests() => Directory.CreateDirectory(_root);
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
private static GameplaySettings Settings() => new GameplaySettings
|
||||
{
|
||||
DefaultEntitySettings = new EntitySettings(),
|
||||
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
||||
QualityPriceModifier = new ExponentialLerp()
|
||||
};
|
||||
|
||||
private CultCache OpenCatalog(CapacitorData capacitor, StatModifierData modifier = null)
|
||||
{
|
||||
var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
var hullShape = new Shape(3, 3);
|
||||
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
||||
cache.Upsert(new HullData
|
||||
{
|
||||
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10,
|
||||
Hardpoints =
|
||||
{
|
||||
new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() },
|
||||
new HardpointData { Type = HardpointType.Sensors, Position = SecondHardpointCell, Shape = new Shape() }
|
||||
}
|
||||
});
|
||||
cache.Upsert(new GearData { Name = "Battery", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { capacitor } });
|
||||
if (modifier != null)
|
||||
cache.Upsert(new GearData { Name = "Booster", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { modifier } });
|
||||
cache.FlushAsync().Wait();
|
||||
return cache;
|
||||
}
|
||||
|
||||
private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem
|
||||
{
|
||||
Data = cache.RefOf<ItemData>(design),
|
||||
Durability = design is EquippableItemData equippable ? equippable.Durability : 0,
|
||||
Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
|
||||
};
|
||||
|
||||
// A ship holding one Battery (and, if present, one Booster), activated in a real (non-null) Zone so
|
||||
// Entity.Activate runs every IInitializableBehavior.Initialize -- StatModifier's included.
|
||||
private Ship BuildActivatedShip(CultCache cache, ItemManager items, bool withBooster)
|
||||
{
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
||||
var hullItem = Mint(cache, items, cache.GetByName<HullData>("Skiff"));
|
||||
var ship = new Ship(items, zone, hullItem, new EntitySettings());
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Battery")), HardpointCell));
|
||||
if (withBooster)
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Booster")), SecondHardpointCell));
|
||||
zone.Entities.Add(ship);
|
||||
ship.Activate();
|
||||
return ship;
|
||||
}
|
||||
|
||||
// §1.1's authority claim, structurally: "StatResolver, one per Entity, constructed with it." Mutation: make
|
||||
// Entity.Resolver a static field -- every entity then shares one resolver, and NotSame goes red.
|
||||
[Fact]
|
||||
public void EachEntityOwnsItsOwnResolver()
|
||||
{
|
||||
var capacity = new PerformanceStat { Min = 1, Max = 1 };
|
||||
using var cache = OpenCatalog(new CapacitorData { Capacity = capacity });
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
|
||||
var shipA = BuildActivatedShip(cache, items, withBooster: false);
|
||||
var shipB = BuildActivatedShip(cache, items, withBooster: false);
|
||||
|
||||
Assert.NotSame(shipA.Resolver, shipB.Resolver);
|
||||
}
|
||||
|
||||
// The consequence of one-resolver-per-entity: a modifier attached on one entity's item never touches another
|
||||
// entity's resolved value, even though both equip the very same design (the very same PerformanceStat
|
||||
// instance, from the same catalog record). The same static-Resolver mutation above kills this test too, since
|
||||
// a shared resolver is the only way two entities could ever collide on an owner-keyed entry in this design.
|
||||
[Fact]
|
||||
public void AModifierOnOneEntityDoesNotReachAnotherEntitysResolvedValue()
|
||||
{
|
||||
var capacity = new PerformanceStat { Min = 1, Max = 1 };
|
||||
var modifier = new StatModifierData
|
||||
{
|
||||
Stat = new StatReference { Target = nameof(CapacitorData), Stat = nameof(CapacitorData.Capacity) },
|
||||
Modifier = new PerformanceStat { Min = 5, Max = 5 },
|
||||
Type = StatModifierType.Constant
|
||||
};
|
||||
using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }, modifier);
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
|
||||
|
||||
var boosted = BuildActivatedShip(cache, items, withBooster: true);
|
||||
var plain = BuildActivatedShip(cache, items, withBooster: false);
|
||||
|
||||
// Drive the Booster's StatModifier directly (Execute then Update) rather than a full tick, so the test
|
||||
// does not also have to bring the item thermally/durability online first.
|
||||
var boosterEquipped = boosted.Equipment.Single(e => e.Data.Name == "Booster");
|
||||
var modifierBehavior = boosterEquipped.GetBehavior<StatModifier>();
|
||||
Assert.NotNull(modifierBehavior);
|
||||
modifierBehavior.Execute(0f);
|
||||
modifierBehavior.Update(0f);
|
||||
|
||||
var boostedBattery = boosted.Equipment.Single(e => e.Data.Name == "Battery");
|
||||
var plainBattery = plain.Equipment.Single(e => e.Data.Name == "Battery");
|
||||
|
||||
Assert.Equal(6f, boostedBattery.Evaluate(capacity), 3); // 1 (Min==Max) + 5 (the Constant modifier)
|
||||
Assert.Equal(1f, plainBattery.Evaluate(capacity), 3); // untouched
|
||||
}
|
||||
|
||||
// §0.3's headline claim: the catalog used to keep every entity that ever evaluated one of its stats alive for
|
||||
// the life of the process, through PerformanceStat's own per-entity modifier dictionaries. This fails at HEAD
|
||||
// before Cut 2. Mutation: make Entity.Resolver static -- its dictionaries then live on a field the catalog's
|
||||
// assembly can reach for the life of the process, and the entity (and its EquippedItem) never dies.
|
||||
[Fact]
|
||||
public void UnequippingAndDroppingAnEntityLeavesItCollectible()
|
||||
{
|
||||
var capacity = new PerformanceStat { Min = 1, Max = 1 };
|
||||
using var cache = OpenCatalog(new CapacitorData { Capacity = capacity });
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
|
||||
|
||||
WeakReference EquipEvaluateAndDrop()
|
||||
{
|
||||
var ship = BuildActivatedShip(cache, items, withBooster: false);
|
||||
var battery = ship.Equipment.Single(e => e.Data is GearData);
|
||||
battery.Evaluate(capacity); // exactly the call that leaked pre-cut (Entity.cs, EquippedItem.ScaleModifier/ConstantModifier)
|
||||
return new WeakReference(ship);
|
||||
}
|
||||
|
||||
var weakShip = EquipEvaluateAndDrop();
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
Assert.False(weakShip.IsAlive);
|
||||
}
|
||||
|
||||
// Cut 2's Adds: "StatValidation for self-dependency ... cycles." A modifier whose own magnitude stat is one
|
||||
// of the stats it writes would resolve one tick behind itself forever -- refused at equip (Entity.Activate),
|
||||
// naming the entity and the (Target, Stat) reference. Mutation: neuter the DFS's self-check in
|
||||
// StatModifier.ValidateNoCycle and this goes from throwing to succeeding silently.
|
||||
[Fact]
|
||||
public void AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip()
|
||||
{
|
||||
var capacity = new PerformanceStat { Min = 1, Max = 1 };
|
||||
var selfModifier = new StatModifierData
|
||||
{
|
||||
Stat = new StatReference { Target = nameof(CapacitorData), Stat = nameof(CapacitorData.Capacity) },
|
||||
Modifier = capacity, // the modifier's own magnitude IS the stat it targets
|
||||
Type = StatModifierType.Constant
|
||||
};
|
||||
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
var hullShape = new Shape(3, 3);
|
||||
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
||||
cache.Upsert(new HullData
|
||||
{
|
||||
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10,
|
||||
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } }
|
||||
});
|
||||
cache.Upsert(new GearData
|
||||
{
|
||||
Name = "Loop", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10,
|
||||
Behaviors = { new CapacitorData { Capacity = capacity }, selfModifier }
|
||||
});
|
||||
cache.FlushAsync().Wait();
|
||||
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
||||
var hullItem = Mint(cache, items, cache.GetByName<HullData>("Skiff"));
|
||||
var ship = new Ship(items, zone, hullItem, new EntitySettings());
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Loop")), HardpointCell));
|
||||
zone.Entities.Add(ship);
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => ship.Activate());
|
||||
Assert.Contains(ship.Name, error.Message); // the entity, by name ("Skiff", HullData.Name)
|
||||
Assert.Contains(nameof(CapacitorData), error.Message);
|
||||
Assert.Contains(nameof(CapacitorData.Capacity), error.Message);
|
||||
}
|
||||
|
||||
// A small, deliberately fake context so the resolver's own caching rule is provable without any catalog,
|
||||
// entity or item plumbing: a stat recomputes when a source it declared moves, and only then.
|
||||
private sealed class FakeContext : IStatContext
|
||||
{
|
||||
public Lot Lot { get; set; }
|
||||
public float Heat = 1f;
|
||||
public float Durability = 1f;
|
||||
// Real contexts (EquippedItem, ConsumableItemEffect) route these through their entity's resolver, keyed
|
||||
// by themselves as owner; a resolver-only test needs the same wiring or it can never observe an attached
|
||||
// modifier, which is the whole point of AttachingAModifierInvalidatesImmediately below.
|
||||
public StatResolver Resolver;
|
||||
public object Owner;
|
||||
public float HeatFactor(float exponent) => Heat;
|
||||
public float DurabilityFactor(float exponent) => Durability;
|
||||
public float ConsumableProgressFactor(float exponent) => 1f;
|
||||
public float PowerSupplyFactor(float exponent) => 1f;
|
||||
public float ScaleModifier(PerformanceStat stat) => Resolver?.ScaleModifier(Owner, stat) ?? 1f;
|
||||
public float ConstantModifier(PerformanceStat stat) => Resolver?.ConstantModifier(Owner, stat) ?? 0f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDoesNotRecomputeWhenNoDeclaredSourceMoved()
|
||||
{
|
||||
var resolver = new StatResolver();
|
||||
var owner = new object();
|
||||
var context = new FakeContext { Heat = 1f };
|
||||
var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 } } };
|
||||
|
||||
Assert.Equal(10f, resolver.Resolve(owner, stat, context), 3);
|
||||
context.Heat = 0f; // the live context changed, but nothing told the resolver a source moved
|
||||
Assert.Equal(10f, resolver.Resolve(owner, stat, context), 3); // still the stale, cached value
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRecomputesOnlyAfterItsDeclaredSourceIsInvalidated()
|
||||
{
|
||||
var resolver = new StatResolver();
|
||||
var owner = new object();
|
||||
var context = new FakeContext { Heat = 1f };
|
||||
var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 } } };
|
||||
|
||||
Assert.Equal(10f, resolver.Resolve(owner, stat, context), 3);
|
||||
context.Heat = 0f;
|
||||
resolver.InvalidateSource(owner, StatSource.Heat);
|
||||
Assert.Equal(0f, resolver.Resolve(owner, stat, context), 3);
|
||||
}
|
||||
|
||||
// Invalidating an undeclared source must not touch a stat that never read it -- otherwise every stat would
|
||||
// recompute on every tick regardless of its own Terms, which is exactly the "recomputes only when its own
|
||||
// source moves" rule this cut adds.
|
||||
[Fact]
|
||||
public void InvalidatingAnUnrelatedSourceDoesNotRecomputeAStatThatDidNotDeclareIt()
|
||||
{
|
||||
var resolver = new StatResolver();
|
||||
var owner = new object();
|
||||
var context = new FakeContext { Durability = 1f };
|
||||
var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.Durability, Exponent = 1 } } };
|
||||
|
||||
Assert.Equal(10f, resolver.Resolve(owner, stat, context), 3);
|
||||
context.Durability = 0f;
|
||||
resolver.InvalidateSource(owner, StatSource.Heat); // a different source moving
|
||||
Assert.Equal(10f, resolver.Resolve(owner, stat, context), 3); // Durability's own generation never bumped
|
||||
}
|
||||
|
||||
// Attaching or detaching a modifier invalidates immediately, independent of the source-generation scheme --
|
||||
// a modifier is not one of the stat's declared Terms.
|
||||
[Fact]
|
||||
public void AttachingAModifierInvalidatesImmediately()
|
||||
{
|
||||
var resolver = new StatResolver();
|
||||
var owner = new object();
|
||||
var context = new FakeContext { Resolver = resolver, Owner = owner };
|
||||
var stat = new PerformanceStat { Min = 1, Max = 1 };
|
||||
|
||||
Assert.Equal(1f, resolver.Resolve(owner, stat, context), 3);
|
||||
resolver.AttachModifier(owner, stat, "modifier-a", StatModifierType.Constant, 4f);
|
||||
Assert.Equal(5f, resolver.Resolve(owner, stat, context), 3);
|
||||
resolver.DetachModifier(owner, stat, "modifier-a");
|
||||
Assert.Equal(1f, resolver.Resolve(owner, stat, context), 3);
|
||||
}
|
||||
}
|
||||
201
tests/mutation_tests_stats_power_cut2.py
Normal file
201
tests/mutation_tests_stats_power_cut2.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md Cut 2 (the resolver owns every value).
|
||||
|
||||
Cut 2 deletes PerformanceStat's two per-entity modifier dictionaries (the leak, §0.3) and moves every resolved
|
||||
(item, stat) value and every attached modifier into a new StatResolver owned by the Entity, one per entity. This
|
||||
script mutates each rule's own line of code and asserts the named test goes red for exactly that reason. A no-op
|
||||
control mutation proves the read/replace/restore path is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_cut2.py --cultlib-root <path-to-CultLib-45c2f40-worktree>
|
||||
|
||||
Only touches files under this repository; every mutation is reversed before the script exits, including on
|
||||
failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Mutation:
|
||||
rule: str
|
||||
file: str
|
||||
anchor: str
|
||||
mutated: str
|
||||
test: str
|
||||
expect: str # "red" (mutant must be killed) or "green" (the no-op control)
|
||||
|
||||
|
||||
MUTATIONS: list[Mutation] = [
|
||||
# --- No-op control: proves the anchor/replace/restore mechanism is transparent ---
|
||||
Mutation(
|
||||
rule="control (no-op round trip)",
|
||||
file="Assets/Scripts/ServerShared/StatResolver.cs",
|
||||
anchor=" var value = stat.Evaluate(context);",
|
||||
mutated=" var value = stat.Evaluate(context);",
|
||||
test="StatResolverTests.ResolveRecomputesOnlyAfterItsDeclaredSourceIsInvalidated",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- The leak (§0.3): a resolver reachable from a static field is reachable from the catalog's assembly for
|
||||
# --- the life of the process, exactly like the two dictionaries it replaced. Both the leak test and the
|
||||
# --- one-resolver-per-entity structural test die under this same mutation. ---
|
||||
Mutation(
|
||||
rule="Entity.Resolver must be an instance field: one resolver per entity, not a shared/static one",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public readonly StatResolver Resolver = new StatResolver();",
|
||||
mutated=" public static readonly StatResolver Resolver = new StatResolver();",
|
||||
test="StatResolverTests.EachEntityOwnsItsOwnResolver",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="a static resolver keeps every entity that ever evaluated a stat reachable for the life of the process",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public readonly StatResolver Resolver = new StatResolver();",
|
||||
mutated=" public static readonly StatResolver Resolver = new StatResolver();",
|
||||
test="StatResolverTests.UnequippingAndDroppingAnEntityLeavesItCollectible",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- A resolved value is per (item, stat): a modifier must attach to the resolver entry it actually targets
|
||||
# --- (the target item's own owner key), not to the modifying behaviour's own item. This is the real defect
|
||||
# --- Hands found and fixed while wiring StatModifier to the resolver: attaching under the wrong owner leaves
|
||||
# --- the target item's resolved value untouched. ---
|
||||
Mutation(
|
||||
rule="a modifier must attach under the target item's own owner, not the modifying behaviour's owner",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs",
|
||||
anchor=" foreach (var target in _targets)\n Entity.Resolver.AttachModifier(target.Owner, target.Stat, this, _data.Type, value);",
|
||||
mutated=" foreach (var target in _targets)\n Entity.Resolver.AttachModifier(StatOwner, target.Stat, this, _data.Type, value);",
|
||||
test="StatResolverTests.AModifierOnOneEntityDoesNotReachAnotherEntitysResolvedValue",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Self-dependency / modifier-chain cycles (Cut 2's Adds): a modifier whose magnitude stat is one of its
|
||||
# --- own targets must be refused at equip, not silently accepted to resolve one tick behind itself. ---
|
||||
Mutation(
|
||||
rule="a modifier chain that reaches its own magnitude stat must be refused at equip",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs",
|
||||
anchor=" if (ReferenceEquals(target, data.Modifier)) return true;",
|
||||
mutated=" if (false) return true;",
|
||||
test="StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Recompute-on-change: a cached value must be reused while every source it declared is unchanged, and
|
||||
# --- must be recomputed once any of them is invalidated. Each half gets its own mutation. ---
|
||||
Mutation(
|
||||
rule="a resolved value must stay cached until its own declared source is invalidated (never on every read)",
|
||||
file="Assets/Scripts/ServerShared/StatResolver.cs",
|
||||
anchor=" if (_cache.TryGetValue(entry, out var cached) && IsCurrent(owner, cached))",
|
||||
mutated=" if (false)",
|
||||
test="StatResolverTests.ResolveDoesNotRecomputeWhenNoDeclaredSourceMoved",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="a resolved value must actually recompute once its declared source is invalidated (never stay cached forever)",
|
||||
file="Assets/Scripts/ServerShared/StatResolver.cs",
|
||||
anchor=" if (_cache.TryGetValue(entry, out var cached) && IsCurrent(owner, cached))",
|
||||
mutated=" if (_cache.TryGetValue(entry, out var cached))",
|
||||
test="StatResolverTests.ResolveRecomputesOnlyAfterItsDeclaredSourceIsInvalidated",
|
||||
expect="red",
|
||||
),
|
||||
# --- Invalidation must be scoped to the source that actually moved -- otherwise every stat recomputes on
|
||||
# --- every tick regardless of the Terms it declared, which defeats the whole point of caching by source. ---
|
||||
Mutation(
|
||||
rule="invalidating one source must not bump every source's generation",
|
||||
file="Assets/Scripts/ServerShared/StatResolver.cs",
|
||||
anchor=" perSource[source] = GenerationOf(owner, source) + 1;",
|
||||
mutated=" foreach (StatSource s in System.Enum.GetValues(typeof(StatSource))) perSource[s] = GenerationOf(owner, s) + 1;",
|
||||
test="StatResolverTests.InvalidatingAnUnrelatedSourceDoesNotRecomputeAStatThatDidNotDeclareIt",
|
||||
expect="red",
|
||||
),
|
||||
# --- Attaching/detaching a modifier must invalidate immediately: a modifier is not one of the stat's
|
||||
# --- declared Terms, so the source-generation scheme never sees it move on its own. ---
|
||||
Mutation(
|
||||
rule="attaching a modifier must invalidate its (owner, stat) entry immediately",
|
||||
file="Assets/Scripts/ServerShared/StatResolver.cs",
|
||||
anchor=(
|
||||
" (type == StatModifierType.Constant ? set.Constant : set.Scale)[modifierKey] = value;\n"
|
||||
" _cache.Remove(entry);"
|
||||
),
|
||||
mutated=" (type == StatModifierType.Constant ? set.Constant : set.Scale)[modifierKey] = value;",
|
||||
test="StatResolverTests.AttachingAModifierInvalidatesImmediately",
|
||||
expect="red",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def replace_unique(path: Path, anchor: str, mutated: str) -> None:
|
||||
original = path.read_text(encoding="utf-8")
|
||||
count = original.count(anchor)
|
||||
if count != 1:
|
||||
raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}")
|
||||
path.write_text(original.replace(anchor, mutated), encoding="utf-8")
|
||||
|
||||
|
||||
def run_test(cultlib_root: str, test: str) -> tuple[int, str]:
|
||||
proc = subprocess.run(
|
||||
["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}",
|
||||
"--filter", f"FullyQualifiedName~{test}"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
return proc.returncode, proc.stdout + proc.stderr
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--cultlib-root", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
failures: list[str] = []
|
||||
originals: dict[str, str] = {}
|
||||
for m in MUTATIONS:
|
||||
if m.file not in originals:
|
||||
originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8")
|
||||
|
||||
def restore_all() -> None:
|
||||
for file, text in originals.items():
|
||||
(REPO_ROOT / file).write_text(text, encoding="utf-8")
|
||||
|
||||
try:
|
||||
for m in MUTATIONS:
|
||||
print(f"\n=== {m.rule} (expect {m.expect}) ===")
|
||||
path = REPO_ROOT / m.file
|
||||
replace_unique(path, m.anchor, m.mutated)
|
||||
code, output = run_test(args.cultlib_root, m.test)
|
||||
passed = "Passed!" in output and code == 0
|
||||
if m.expect == "green":
|
||||
if not passed:
|
||||
failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}")
|
||||
else:
|
||||
print(f"OK: {m.test} passed under a no-op mutation, as expected")
|
||||
else:
|
||||
if passed:
|
||||
failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed")
|
||||
else:
|
||||
print(f"OK: {m.test} went red under the mutation, as expected")
|
||||
# Restore this file immediately so the next mutation starts from a clean tree.
|
||||
path.write_text(originals[m.file], encoding="utf-8")
|
||||
finally:
|
||||
restore_all()
|
||||
|
||||
if failures:
|
||||
print("\n=== FAILURES ===")
|
||||
for f in failures:
|
||||
print(f)
|
||||
return 1
|
||||
|
||||
print("\nAll mutation cases behaved as Cut 2's verification section demands.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue