diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 8db7e6548..f823b0514 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -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()) + { 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()) + 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 Upsert(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(cache.UpsertAsync(document).Result.Key); } } diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 4013d5c93..934a9fe6f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -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 diff --git a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs index 6f1f506c5..40ef13eb5 100644 --- a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs +++ b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs @@ -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>(); + foreach (var behavior in entity.Equipment.SelectMany(e => e.Behaviors).OfType()) + { + 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(); + foreach (var target in behaviorTargets) + if (target.Stat != null) list.Add(target.Stat); + } + + var visited = new HashSet(); + 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) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index f83268f82..f841223cc 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -62,6 +62,11 @@ public abstract class Entity public List PopulationAssignments = new List(); + // 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; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 454ceca96..63caa3d7f 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -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 Terms = new List(); - [IgnoreMember] private Dictionary> _scaleModifiers; - [IgnoreMember] private Dictionary> _constantModifiers; - - [IgnoreMember] - private Dictionary> ScaleModifiers => - _scaleModifiers = _scaleModifiers ?? new Dictionary>(); - - [IgnoreMember] - private Dictionary> ConstantModifiers => - _constantModifiers = _constantModifiers ?? new Dictionary>(); - - public Dictionary GetScaleModifiers(Entity entity) - { - if(!ScaleModifiers.ContainsKey(entity)) - ScaleModifiers[entity] = new Dictionary(); - - return ScaleModifiers[entity]; - } - - public Dictionary GetConstantModifiers(Entity entity) - { - if(!ConstantModifiers.ContainsKey(entity)) - ConstantModifiers[entity] = new Dictionary(); - - return ConstantModifiers[entity]; - } + // Cut 2 (docs/stats-and-power-cut.md): this catalog object used to hold two Dictionary 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 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)] diff --git a/Assets/Scripts/ServerShared/StatResolver.cs b/Assets/Scripts/ServerShared/StatResolver.cs new file mode 100644 index 000000000..879d40598 --- /dev/null +++ b/Assets/Scripts/ServerShared/StatResolver.cs @@ -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 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 + { + 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 Generations; + } + + private class ModifierSet + { + public readonly Dictionary Scale = new Dictionary(); + public readonly Dictionary Constant = new Dictionary(); + } + + private readonly Dictionary _cache = new Dictionary(); + private readonly Dictionary _modifiers = new Dictionary(); + private readonly Dictionary> _generations = + new Dictionary>(); + + 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(); + 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(); + 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); + } +} diff --git a/Assets/Scripts/ServerShared/StatResolver.cs.meta b/Assets/Scripts/ServerShared/StatResolver.cs.meta new file mode 100644 index 000000000..96a739ca7 --- /dev/null +++ b/Assets/Scripts/ServerShared/StatResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b9e2b7d6335e48709359a81ae2ea98ad +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/Aetheria.Shared.Tests/StatResolverTests.cs b/tests/Aetheria.Shared.Tests/StatResolverTests.cs new file mode 100644 index 000000000..76ed48b90 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StatResolverTests.cs @@ -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 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(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + // 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("Skiff")); + var ship = new Ship(items, zone, hullItem, new EntitySettings()); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Battery")), HardpointCell)); + if (withBooster) + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("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(); + 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("Skiff")); + var ship = new Ship(items, zone, hullItem, new EntitySettings()); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Loop")), HardpointCell)); + zone.Entities.Add(ship); + + var error = Assert.Throws(() => 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); + } +} diff --git a/tests/mutation_tests_stats_power_cut2.py b/tests/mutation_tests_stats_power_cut2.py new file mode 100644 index 000000000..1fe651697 --- /dev/null +++ b/tests/mutation_tests_stats_power_cut2.py @@ -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 + +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())