Modifiers reach a behaviour type and its subtypes: TargetsOf matches assignable types

A StatReference naming WeaponData.Damage validated and attached to nothing,
because TargetsOf compared exact types. All three filters now use
targetType.IsAssignableFrom; no exact-match path remains.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-08 22:01:47 +02:00
commit 7ee40255ca
2 changed files with 63 additions and 4 deletions

View file

@ -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();

View file

@ -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<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.Deactivate();
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]