Pin Gate 1, Gate 2 and F8's mutant fix with tests and mutations
StatResolverTests: UnequipReturnsTheResolversRetainedSetToBaseline and ConsumableExpiryReturnsTheResolversRetainedSetToBaseline drive 20 equip/ evaluate/unequip and activate/evaluate/expire cycles and assert the resolver's own entry counts return to baseline, pinning Gate 1 at the retained-set level rather than only via one WeakReference. ARefitTargetReattachesTheModifierAfterReactivation drives the exact refit scenario Gate 2 fixes and asserts the replacement target ends up boosted. tests/mutation_tests_stats_power_cut2.py: one mutation per Forget() call site (each expected red under deletion of that call), one for Gate 2's detach line, and the StatOwner mutant swapped for Entity to match its removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5581911fb3
commit
0a971ec4d8
2 changed files with 193 additions and 1 deletions
|
|
@ -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<StatModifier>();
|
||||
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<GearData>("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<GearData>("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<ItemData>(boosterData), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() });
|
||||
var consumableItem = new ConsumableItem { Data = cache.RefOf<ItemData>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue