diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 558e04285..4697fe272 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -81,12 +81,14 @@ public static class CultRecordRefs StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); StatValidation.ValidateMunitionParts(data.Name, data.Behaviors); + StatValidation.ValidateMineLayerFlat(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); } if (document is ConsumableItemData consumable) { StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors); StatValidation.ValidateMunitionParts(consumable.Name, consumable.Behaviors); + StatValidation.ValidateMineLayerFlat(consumable.Name, consumable.Behaviors); StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors); } } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 09de87b43..9ce4e55ce 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -797,9 +797,9 @@ public static class FireControl FuseDelay = data.FuseDelay, Lifetime = data.Lifetime, BlastRadius = blastRadius, - // A mine is laid at the layer, distance 0: it goes through the same falloff as every other payload, so a layer's - // authored ranges decide its mines, the AI's reading of it and its panel alike. - Damage = weapon.Damage * weapon.RangeFactor(0f), + // Mines are flat (ruling effective-range-authoring-ruled): a mine layer carries no falloff authoring + // (StatValidation.ValidateMineLayerFlat), so its figure is the AI's and the panel's at every distance it can engage. + Damage = weapon.Damage, DamageType = weapon.WeaponData.DamageType, Layer = source, LayerFaction = source.Faction diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 963d4d30e..9b9c057e7 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -840,6 +840,21 @@ public static class StatValidation } } + // Ruling effective-range-authoring-ruled: mines are flat. A mine layer's Range and MinRange gate where it may lay; its + // damage meets no range, so it carries no falloff authoring, and WeaponData.RangeFactor reads 1 inside the gate for the + // mine, the AI and the panel alike. Refused here, with the other catalog rules, wherever those run. + public static void ValidateMineLayerFlat(string ownerName, IEnumerable behaviors) + { + foreach (var layer in (behaviors ?? Enumerable.Empty()).OfType()) + { + var effective = layer.EffectiveRange; + if (effective == null || !float.IsPositiveInfinity(effective.Min) || !float.IsPositiveInfinity(effective.Max) || (effective.Terms != null && effective.Terms.Count > 0)) + throw new InvalidOperationException($"{ownerName}: {nameof(MineLayerData)}.{nameof(WeaponData.EffectiveRange)} is authored, but a mine layer carries no damage falloff"); + if (layer.DamageAtMaxRange != 0f) + throw new InvalidOperationException($"{ownerName}: {nameof(MineLayerData)}.{nameof(WeaponData.DamageAtMaxRange)} is authored, but a mine layer carries no damage falloff"); + } + } + // Cut 7 (docs/stats-and-power-cut.md): "a stat naming a role its design lacks" must be refused, not silently // fall back to the lot's own workmanship (Lot.QualityForRole already falls back for a role a lot never rolled // -- that is the intended forward-compat path for an old lot meeting a newly-authored role. This is the other diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 18b548dca..c997041a4 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -67,7 +67,7 @@ public sealed class MineTests : IDisposable private Lab Build(float blast = 4f, float arming = 2f, float fuse = 2f, float life = 30f, float spread = 0f, float velocity = 0f, float damage = 100f, float range = 1000f, float cooldown = 1000f, Faction layerFaction = null, WeaponFuse? fuseKind = WeaponFuse.Proximity, bool well = false, float3? layerAt = null, int magazine = 100, - float minRange = 0f, float effectiveRange = float.PositiveInfinity, float damageAtMaxRange = 0f) + float minRange = 0f) { var settings = new GameplaySettings { @@ -93,7 +93,7 @@ public sealed class MineTests : IDisposable { ArmingDelay = arming, FuseDelay = fuse, Lifetime = life, Damage = Constant(damage), Range = Constant(range), MinRange = Constant(minRange), - EffectiveRange = Constant(effectiveRange), DamageAtMaxRange = damageAtMaxRange, Velocity = Constant(velocity), + Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(cooldown), MagazineSize = magazine, }; @@ -547,22 +547,39 @@ public sealed class MineTests : IDisposable Assert.True(angles.Max() > 10f && angles.Min() < -10f, "the angle spans +-Spread/2, not a narrower cone"); } - // A mine goes through the same falloff as every payload, read at its lay distance (0, at the layer): the layer's authored - // ranges decide the mine's damage, the AI's reading of the layer and the panel's chart alike. Authored flat to 10 with - // a .3 end, a mine layer with no minimum range lays a full-damage mine; with a minimum of 5 the layer has no reach at the - // lay point and the mine is worth nothing, in the AI's figure too. Kills: Lay without the factor, Lay reading it at any - // other distance (20 reads under 1 for both fixtures). + // Mines are flat (ruling effective-range-authoring-ruled): a layer's MinRange and Range gate where it may lay, and the one + // RangeFactor reads 1 inside them, so the mine's damage, the AI's figure and the panel's function agree at every distance. [Theory] - [InlineData(0f, 100f)] - [InlineData(5f, 0f)] - public void ALayersAuthoredRangesDecideItsMinesAndTheAILikewise(float minRange, float damage) + [InlineData(0f)] + [InlineData(5f)] + public void AMineLayerIsFlatForTheMineTheAIAndThePanel(float minRange) { - var lab = Build(damage: 100f, range: 1000f, minRange: minRange, effectiveRange: 10f, damageAtMaxRange: .3f, layerFaction: A); - Assert.NotEqual(damage, 100f * lab.Weapon.RangeFactor(20f)); + var lab = Build(damage: 100f, range: 1000f, minRange: minRange, layerFaction: A); var mine = LayOne(lab); + Assert.Equal(100f, mine.Damage, 3); - Assert.Equal(damage, mine.Damage, 3); - Assert.Equal(damage, lab.Weapon.RangeDamagePerSecond(0f) * lab.Weapon.Cooldown, 3); + var data = lab.Weapon.WeaponData; + foreach (var at in new[] { 5f, 10f, 100f, 500f, 999f }) + { + Assert.Equal(mine.Damage, lab.Weapon.RangeDamagePerSecond(at) * lab.Weapon.Cooldown, 3); + Assert.Equal(mine.Damage, 100f * WeaponData.RangeFactor(data.MinRange.Max, data.EffectiveRange.Max, data.Range.Max, data.DamageAtMaxRange, at), 3); + } + } + + // A falloff on a mine layer would act on nothing the mine does, so the catalog refuses it where it is authored. + [Theory] + [InlineData(10f, 0f)] + [InlineData(float.PositiveInfinity, .3f)] + public void AMineLayerAuthoringFalloffIsRefused(float effective, float end) + { + var item = new WeaponItemData + { + Name = "Mine Layer", Hardpoint = HardpointType.Sensors, Shape = new Shape(), BlastRadius = 4f, + Behaviors = { new MineLayerData { Range = Constant(1000f), EffectiveRange = Constant(effective), DamageAtMaxRange = end } } + }; + var refusal = Assert.Throws(() => StatValidation.ValidateMineLayerFlat(item.Name, item.Behaviors)); + Assert.Contains("falloff", refusal.Message); + Assert.Throws(() => CultRecordRefs.Validate(item)); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs b/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs index 4068a1cc2..d6b56b040 100644 --- a/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs +++ b/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs @@ -116,13 +116,14 @@ public sealed partial class FireControlCut124Tests // A no-lock fused round reads the falloff where its burst point is decided, at its commit: the shot right after the tick // that committed it, still in the air (velocity 20). With a clear line it travels to Range ("bomb fishing") and lands at // the end multiplier. - private float NoLockDamageAfterCommit(float effective, float blockerAt, out float travelled) + private float NoLockDamageAfterCommit(float effective, float blockerAt, out float travelled, float2? aim = null) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, penetration: 1f, damage: 100f, weaponRange: NoLockRange, effectiveRange: effective, damageAtMaxRange: .25f); e.Shooter.SetTarget(TargetRef.None); - Aim(e, float2(0, 1)); - if (blockerAt > 0f) AddShip(e, PointAlong(e, float2(0, 1), blockerAt), 100000f, 300); + var direction = aim ?? float2(0, 1); + Aim(e, direction); + if (blockerAt > 0f) AddShip(e, PointAlong(e, direction, blockerAt), 100000f, 300); ShotOutcome outcome = null; using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -140,6 +141,15 @@ public sealed partial class FireControlCut124Tests Assert.Equal(NoLockRange, travelled, 3); } + // A clear line's burst point is planned at Range, but its planar distance can come out a rounding step past it (3.8e-6 at + // aim (1, 0) and Range 60); Deliver clamps that to Range, so the round still lands at the end multiplier (a non-zero end + // here: unclamped, the read past Range is 0). + [Fact] + public void ARoundFishedToRangeIsNeverReadPastRangeByRounding() + { + Assert.Equal(25f, NoLockDamageAfterCommit(effective: 20f, blockerAt: 0f, out _, aim: float2(1, 0)), 3); + } + // The round stops on the first hull ahead, 21 out (well inside a 30 effective range of a 60 range weapon): it delivers // full damage there, not the end multiplier of the point it was planned to. With a shorter effective range (10) the // factor is the lerp at the distance it actually travelled.