Aetheria/tests/Aetheria.Shared.Tests/StatResolverTests.cs
Meta Vens 29c5f7aba1 Consumable host: one initialiser for gear and running effects; tests pin overlap, lifetime, per-design cooldown, refit
Entity.Activate re-initialises the running consumable effects through the same
InitializeBehaviors that initialises gear and ActivateConsumable, so a consumable
active across deactivate/refit/activate retargets to the new items.
ConsumableTests gains the stackable overlap/multiply, shared-entry detach,
expiry-boundary, per-design cooldown and refit facts. StatResolverTests' expiry
test builds its consumable through ItemManager.CreateInstance.
Not yet built or run; verification follows in a later commit message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:24:54 +02:00

455 lines
25 KiB
C#

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);
private static readonly int2 FirstGunCell = new int2(2, 0);
private static readonly int2 SecondGunCell = new int2(0, 1);
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() },
new HardpointData { Type = HardpointType.Sensors, Position = FirstGunCell, Shape = new Shape() },
new HardpointData { Type = HardpointType.Sensors, Position = SecondGunCell, 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;
}
// A modifier's StatReference names a behaviour type, and WeaponData.Damage is declared once on the abstract
// base. A ship carrying an AutoWeapon gun and a ChargedWeapon gun and a Booster whose modifier aims at
// `target` is the real case; the two tests below read each gun's resolved Damage through EquippedItem.Evaluate.
private (CultCache Cache, EquippedItem Auto, EquippedItem Charged, PerformanceStat AutoDamage, PerformanceStat ChargedDamage)
BuildGunShipWithBooster(string target)
{
var autoDamage = new PerformanceStat { Min = 10, Max = 10 };
var chargedDamage = new PerformanceStat { Min = 10, Max = 10 };
var modifier = new StatModifierData
{
Stat = new StatReference { Target = target, Stat = nameof(WeaponData.Damage) },
Modifier = new PerformanceStat { Min = 3, Max = 3 },
Type = StatModifierType.Multiplier
};
var cache = OpenCatalog(new CapacitorData { Capacity = new PerformanceStat { Min = 1, Max = 1 } }, modifier);
cache.Upsert(new GearData { Name = "AutoGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { new AutoWeaponData { Damage = autoDamage } } });
cache.Upsert(new GearData { Name = "ChargedGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { new ChargedWeaponData { Damage = chargedDamage } } });
cache.FlushAsync().Wait();
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
var ship = BuildActivatedShip(cache, items, withBooster: true);
ship.Deactivate(); // TryEquip refuses while deployed
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("AutoGun")), FirstGunCell));
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("ChargedGun")), SecondGunCell));
// Re-activate so the booster's Initialize sees the guns it did not see at the first activation.
ship.Activate();
var booster = ship.Equipment.Single(e => e.Data.Name == "Booster");
var behavior = booster.GetBehavior<StatModifier>();
behavior.Execute(0f);
behavior.Update(0f);
return (cache, ship.Equipment.Single(e => e.Data.Name == "AutoGun"), ship.Equipment.Single(e => e.Data.Name == "ChargedGun"), autoDamage, chargedDamage);
}
// A modifier aimed at the abstract base reaches every subtype carried. Mutation: restore exact-type matching
// (`bd.GetType() == targetType`) in either behaviour filter of StatModifier.TargetsOf and both guns read 10.
[Fact]
public void AModifierOnABaseTypeReachesEverySubtype()
{
var (cache, auto, charged, autoDamage, chargedDamage) = BuildGunShipWithBooster(nameof(WeaponData));
using var _ = cache;
Assert.Equal(30f, auto.Evaluate(autoDamage), 3);
Assert.Equal(30f, charged.Evaluate(chargedDamage), 3);
}
// A modifier aimed at one concrete subtype leaves its sibling alone: assignability is downward only.
[Fact]
public void AModifierOnAConcreteTypeReachesOnlyIt()
{
var (cache, auto, charged, autoDamage, chargedDamage) = BuildGunShipWithBooster(nameof(AutoWeaponData));
using var _ = cache;
Assert.Equal(30f, auto.Evaluate(autoDamage), 3);
Assert.Equal(10f, charged.Evaluate(chargedDamage), 3);
}
// §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
}
// 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
// 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);
}
// 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 = Assert.IsType<ConsumableItem>(items.CreateInstance(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);
}
}