diff --git a/tests/Aetheria.Shared.Tests/StatResolverTests.cs b/tests/Aetheria.Shared.Tests/StatResolverTests.cs index 76ed48b90..6c0677a2a 100644 --- a/tests/Aetheria.Shared.Tests/StatResolverTests.cs +++ b/tests/Aetheria.Shared.Tests/StatResolverTests.cs @@ -121,6 +121,52 @@ public sealed class StatResolverTests : IDisposable Assert.Equal(1f, plainBattery.Evaluate(capacity), 3); // untouched } + // Soul's Gate 2 finding on Cut 2 (docs/stats-and-power-cut.md): StatModifier.Initialize recomputes _targets + // by matching the *behaviour type* the modifier targets (here, any equipped item with a CapacitorData + // behaviour) fresh every time it runs -- but _applied was never reset alongside it. So after the modifier + // has already applied once, deactivating, unequipping its target, equipping a replacement with the same + // targeted behaviour, and reactivating used to leave the modifier permanently unattached: Update saw + // `_executed && _applied` (both still true from the stale attachment) and neither branch of its latch fired, + // so ApplyModifier never ran against the new target. Mutation: delete the `if (_applied) RemoveModifier();` + // line this cut adds to Initialize, and the final assertion goes red (the replacement reads 1f, unboosted). + [Fact] + public void ARefitTargetReattachesTheModifierAfterReactivation() + { + 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 ship = BuildActivatedShip(cache, items, withBooster: true); + var boosterEquipped = ship.Equipment.Single(e => e.Data.Name == "Booster"); + var modifierBehavior = boosterEquipped.GetBehavior(); + Assert.NotNull(modifierBehavior); + + // First activation: applies against the original Battery. + modifierBehavior.Execute(0f); + modifierBehavior.Update(0f); + var originalBattery = ship.Equipment.Single(e => e.Data.Name == "Battery"); + Assert.Equal(6f, originalBattery.Evaluate(capacity), 3); + + // Deactivate, unequip the target, equip a replacement with the same targeted behaviour, reactivate. + ship.Deactivate(); + Assert.NotNull(ship.TryUnequip(originalBattery)); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Battery")), HardpointCell)); + ship.Activate(); + + // The modifier behaviour re-executes on the new activation, exactly as it did on the first. + modifierBehavior.Execute(0f); + modifierBehavior.Update(0f); + + var replacementBattery = ship.Equipment.Single(e => e.Data.Name == "Battery"); + Assert.Equal(6f, replacementBattery.Evaluate(capacity), 3); // reattached; not stuck unboosted at 1f + } + // §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 @@ -270,4 +316,81 @@ public sealed class StatResolverTests : IDisposable resolver.DetachModifier(owner, stat, "modifier-a"); Assert.Equal(1f, resolver.Resolve(owner, stat, context), 3); } + + // Soul's Gate 1 finding on Cut 2 (docs/stats-and-power-cut.md §0b: a resolver entry is "destroyed at + // unequip / entity teardown"): nothing ever removed an owner from _cache/_modifiers/_generations, so an + // EquippedItem that was unequipped, evaluated, and replaced kept growing the resolver's retained set forever. + // This pins the fix at the retained-set level, not only via a WeakReference on one entity (which the existing + // UnequippingAndDroppingAnEntityLeavesItCollectible already covers): repeatedly equip, evaluate (populating + // _cache and, through UpdatePerformance, _generations), and unequip, then assert the resolver's own entry + // counts return to the same baseline they started at, cycle after cycle. Mutation: delete the + // Resolver.Forget(item) call in Entity.TryUnequip and this goes red because the counts climb instead. + [Fact] + public void UnequipReturnsTheResolversRetainedSetToBaseline() + { + 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 ship = BuildActivatedShip(cache, items, withBooster: false); + var resolver = ship.Resolver; + + var baselineGenerations = resolver.GenerationOwnerCount; + var baselineCache = resolver.CacheEntryCount; + var baselineModifiers = resolver.ModifierEntryCount; + + for (var i = 0; i < 20; i++) + { + var battery = ship.Equipment.Single(e => e.Data.Name == "Battery"); + battery.UpdatePerformance(); // Cut 2: bumps this owner's Heat and Durability generations + battery.Evaluate(capacity); // populates a _cache entry keyed by (battery, capacity) + + ship.Deactivate(); + Assert.NotNull(ship.TryUnequip(battery)); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Battery")), HardpointCell)); + ship.Activate(); + } + + Assert.Equal(baselineGenerations, resolver.GenerationOwnerCount); + Assert.Equal(baselineCache, resolver.CacheEntryCount); + Assert.Equal(baselineModifiers, resolver.ModifierEntryCount); + } + + // The other half of Gate 1: a ConsumableItemEffect that expires drops out of Entity._activeConsumables + // (Entity.Update) without ever telling the resolver, so 20 activate/evaluate/expire cycles left 22 generation + // owners at HEAD (this ship's own EquippedItem owners plus one per expired consumable instance). Mutation: + // delete the Resolver.Forget(_activeConsumables[i]) call at expiry and this goes red. + [Fact] + public void ConsumableExpiryReturnsTheResolversRetainedSetToBaseline() + { + using var cache = OpenCatalog(new CapacitorData { Capacity = new PerformanceStat { Min = 1, Max = 1 } }); + var boosterRef = cache.Upsert(new ConsumableItemData { Name = "Stim", Duration = 0.01f }); + cache.FlushAsync().Wait(); + var boosterData = cache.Get(boosterRef); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var ship = BuildActivatedShip(cache, items, withBooster: false); + var resolver = ship.Resolver; + + // A warm-up tick with no consumable active: the still-equipped Battery owns a permanent Heat/Durability + // generation entry from here on (correctly -- it is never unequipped in this test), so the baseline this + // test pins against must be taken after that entry exists, not before it. + ship.Update(0f); + var baselineGenerations = resolver.GenerationOwnerCount; + var baselineCache = resolver.CacheEntryCount; + var baselineModifiers = resolver.ModifierEntryCount; + + for (var i = 0; i < 20; i++) + { + var lot = items.Lots.Add(new Lot { Design = cache.RefOf(boosterData), Origin = new Attributed(), Quality = .5f, Roles = new List() }); + var consumableItem = new ConsumableItem { Data = cache.RefOf(boosterData), Lot = lot }; + ship.ActivateConsumable(consumableItem); + + // Duration is 0.01s; one 1-second tick expires it and drives Entity.Update's expiry branch. + ship.Update(1f); + } + + Assert.Equal(baselineGenerations, resolver.GenerationOwnerCount); + Assert.Equal(baselineCache, resolver.CacheEntryCount); + Assert.Equal(baselineModifiers, resolver.ModifierEntryCount); + } } diff --git a/tests/mutation_tests_stats_power_cut2.py b/tests/mutation_tests_stats_power_cut2.py index 1fe651697..867e6a97f 100644 --- a/tests/mutation_tests_stats_power_cut2.py +++ b/tests/mutation_tests_stats_power_cut2.py @@ -73,7 +73,11 @@ MUTATIONS: list[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);", + # F8 (Soul pass over Cut 2): this mutant used to reach for a dedicated `StatOwner` property on Behavior + # that had no production caller -- added only to make this one mutation expressible. Deleted; `Entity` + # is already a real accessible symbol on Behavior and is just as wrong an owner key for this mutant + # (neither the modifying item's own owner nor the target's), so it needs no dead code to stand in for it. + mutated=" foreach (var target in _targets)\n Entity.Resolver.AttachModifier(Entity, target.Stat, this, _data.Type, value);", test="StatResolverTests.AModifierOnOneEntityDoesNotReachAnotherEntitysResolvedValue", expect="red", ), @@ -130,6 +134,71 @@ MUTATIONS: list[Mutation] = [ test="StatResolverTests.AttachingAModifierInvalidatesImmediately", expect="red", ), + + # --- Gate 1 (Soul pass over Cut 2), F1: nothing ever removed an owner from _cache/_modifiers/_generations, + # --- so the resolver's retained set grew without bound across unequip and consumable-expiry cycles even + # --- though §0b promises a resolver entry is "destroyed at unequip / entity teardown." Forget(owner) closes + # --- it; these two mutations delete each of its two call sites in turn. --- + Mutation( + rule="unequipping an item must forget it in the resolver (Entity.TryUnequip)", + file="Assets/Scripts/ServerShared/Entity.cs", + anchor=( + " // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): the resolver's \xa70b lifecycle promise -- a resolver\n" + " // entry is \"destroyed at unequip\" -- was never wired up. Without this, every unequipped item stayed\n" + " // reachable from the resolver's dictionaries for the entity's whole remaining life.\n" + " Resolver.Forget(item);" + ), + mutated=( + " // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): the resolver's \xa70b lifecycle promise -- a resolver\n" + " // entry is \"destroyed at unequip\" -- was never wired up. Without this, every unequipped item stayed\n" + " // reachable from the resolver's dictionaries for the entity's whole remaining life." + ), + test="StatResolverTests.UnequipReturnsTheResolversRetainedSetToBaseline", + expect="red", + ), + Mutation( + rule="an expired consumable must forget itself in the resolver (Entity.Update)", + file="Assets/Scripts/ServerShared/Entity.cs", + anchor=( + " // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this\n" + " // list without ever telling the resolver, leaving its generation/cache/modifier entries\n" + " // reachable (keyed by this ConsumableItemEffect instance) for the rest of the process.\n" + " Resolver.Forget(_activeConsumables[i]);\n" + " _activeConsumables.RemoveAt(i--);" + ), + mutated=( + " // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this\n" + " // list without ever telling the resolver, leaving its generation/cache/modifier entries\n" + " // reachable (keyed by this ConsumableItemEffect instance) for the rest of the process.\n" + " _activeConsumables.RemoveAt(i--);" + ), + test="StatResolverTests.ConsumableExpiryReturnsTheResolversRetainedSetToBaseline", + expect="red", + ), + + # --- Gate 2 (Soul pass over Cut 2), F2: _applied was never reset alongside the _targets recomputed on every + # --- Initialize, so a modifier that had already applied once stayed permanently latched after its target was + # --- unequipped, replaced and the entity reactivated -- Update saw `_executed && _applied` and neither branch + # --- of its own latch ever fired again. Deleting the detach-on-reinitialize line restores that split + # --- authority between Initialize and Update. --- + Mutation( + rule="Initialize must detach a stale attachment before new targets are computed, so Update's latch can reattach", + file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs", + anchor=( + " public void Initialize()\n" + " {\n" + " if (_applied)\n" + " RemoveModifier();\n" + " _targets = TargetsOf(Entity, _data);" + ), + mutated=( + " public void Initialize()\n" + " {\n" + " _targets = TargetsOf(Entity, _data);" + ), + test="StatResolverTests.ARefitTargetReattachesTheModifierAfterReactivation", + expect="red", + ), ]