diff --git a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs index 5e56e4c42..430536591 100644 --- a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs +++ b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs @@ -95,13 +95,13 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa .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) + ? gear.Where(g => targetType.IsAssignableFrom(entity.ItemManager.GetData(g.EquippableItem).GetType())) .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)) + : gear.Where(g => entity.ItemManager.GetData(g.EquippableItem).Behaviors.Any(bd => targetType.IsAssignableFrom(bd.GetType()))) .SelectMany(g => entity.ItemManager.GetData(g.EquippableItem).Behaviors - .Where(bd => bd.GetType() == targetType) + .Where(bd => targetType.IsAssignableFrom(bd.GetType())) .Select(bd => new Target(g, statField.GetValue(bd) as PerformanceStat))) .Where(t => t.Stat != null) .ToArray(); diff --git a/tests/Aetheria.Shared.Tests/StatResolverTests.cs b/tests/Aetheria.Shared.Tests/StatResolverTests.cs index 6c0677a2a..5b731215d 100644 --- a/tests/Aetheria.Shared.Tests/StatResolverTests.cs +++ b/tests/Aetheria.Shared.Tests/StatResolverTests.cs @@ -17,6 +17,8 @@ public sealed class StatResolverTests : IDisposable 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); @@ -40,7 +42,9 @@ public sealed class StatResolverTests : IDisposable 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 = 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 } }); @@ -72,6 +76,61 @@ public sealed class StatResolverTests : IDisposable 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); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("AutoGun")), FirstGunCell)); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("ChargedGun")), SecondGunCell)); + // Re-activate so the booster's Initialize sees the guns it did not see at the first activation. + ship.Deactivate(); + ship.Activate(); + var booster = ship.Equipment.Single(e => e.Data.Name == "Booster"); + var behavior = booster.GetBehavior(); + 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]