diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index c5197a909..5169969ec 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -32,7 +32,7 @@ public class CombatState : BaseState _availableLockingWeapons.Clear(); var toTarget = target.Position - _agent.Ship.Position; - var targetDistance = length(toTarget); + var targetDistance = length(toTarget.xz); // Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at // this range, or clear the aim point when nothing qualifies -- the same predicate @@ -139,8 +139,8 @@ public class CombatState : BaseState var maxRange = Single.MinValue; foreach (var weapon in _agent.Ship.Weapons) { - if (weapon.Range < minRange) - minRange = weapon.Range; + if (weapon.MinRange < minRange) + minRange = weapon.MinRange; if (weapon.Range > maxRange) maxRange = weapon.Range; } @@ -153,7 +153,7 @@ public class CombatState : BaseState float dps = 0; foreach (var weapon in _agent.Ship.Weapons) { - if (weapon.Item.Online.Value && weapon.MinRange < range && range < weapon.Range) + if (weapon.Item.Online.Value) dps += weapon.RangeDamagePerSecond(range); } diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index adca5a845..3f753e7f4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -56,13 +56,13 @@ public class ChargedWeaponData : InstantWeaponData } // The one formula of a charged weapon's damage per second at a range (see InstantWeaponData.RangeDamagePerSecond). - internal static float RangeDamagePerSecond(BezierCurve damageCurve, float damage, float chargeFiringDamageMultiplier, - float minRange, float range, float cooldown, float chargeTime, float atRange) => - damage * chargeFiringDamageMultiplier * damageCurve.Evaluate(saturate(unlerp(minRange, range, atRange))) / (cooldown + chargeTime); + internal static float RangeDamagePerSecond(float damage, float chargeFiringDamageMultiplier, float cooldown, float chargeTime, + float rangeFactor) => + damage * chargeFiringDamageMultiplier * rangeFactor / (cooldown + chargeTime); public override float RatedRangeDamagePerSecond(float range) => - RangeDamagePerSecond(DamageCurve, PerRound(Damage.Max, Count.Max), ChargeFiringDamageMultiplier, MinRange.Max, Range.Max, - Cooldown.Max, ChargeTime.Max, range); + RangeDamagePerSecond(PerRound(Damage.Max, Count.Max), ChargeFiringDamageMultiplier, Cooldown.Max, ChargeTime.Max, + RatedRangeFactor(range)); } public class ChargedWeapon : InstantWeapon @@ -90,8 +90,8 @@ public class ChargedWeapon : InstantWeapon public override float DamagePerSecond => _unchargedDamage * _data.ChargeFiringDamageMultiplier / (Cooldown + ChargeTime); public override float RangeDamagePerSecond(float range) { - return ChargedWeaponData.RangeDamagePerSecond(_data.DamageCurve, _unchargedDamage, _data.ChargeFiringDamageMultiplier, - MinRange, Range, Cooldown, ChargeTime, range); + return ChargedWeaponData.RangeDamagePerSecond(_unchargedDamage, _data.ChargeFiringDamageMultiplier, Cooldown, ChargeTime, + RangeFactor(range)); } public event Action OnStartCharging; diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index afa4394e8..cc0015dbe 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -25,11 +25,10 @@ public class ConstantWeaponData : WeaponData } // The one formula of a beam's damage per second at a range (see InstantWeaponData.RangeDamagePerSecond). - internal static float RangeDamagePerSecond(BezierCurve damageCurve, float damage, float minRange, float range, float atRange) => - damage * damageCurve.Evaluate(saturate(unlerp(minRange, range, atRange))); + internal static float RangeDamagePerSecond(float damage, float rangeFactor) => damage * rangeFactor; public override float RatedRangeDamagePerSecond(float range) => - RangeDamagePerSecond(DamageCurve, Damage.Max, MinRange.Max, Range.Max, range); + RangeDamagePerSecond(Damage.Max, RatedRangeFactor(range)); } public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerConsumer @@ -56,7 +55,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override float DamagePerSecond => Damage; public override float RangeDamagePerSecond(float range) { - return ConstantWeaponData.RangeDamagePerSecond(_data.DamageCurve, Damage, MinRange, Range, range); + return ConstantWeaponData.RangeDamagePerSecond(Damage, RangeFactor(range)); } public event Action OnReloadBegin; diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 862eb9c0d..0382c3b17 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -36,11 +36,10 @@ public class InstantWeaponData : WeaponData // UpdateStats and the rated read both use. internal static float PerRound(float total, float burstCount) => total / (int) burstCount; - internal static float RangeDamagePerSecond(BezierCurve damageCurve, float damage, float minRange, float range, float cooldown, float atRange) => - damage * damageCurve.Evaluate(saturate(unlerp(minRange, range, atRange))) / cooldown; + internal static float RangeDamagePerSecond(float damage, float cooldown, float rangeFactor) => damage * rangeFactor / cooldown; public override float RatedRangeDamagePerSecond(float range) => - RangeDamagePerSecond(DamageCurve, PerRound(Damage.Max, Count.Max), MinRange.Max, Range.Max, Cooldown.Max, range); + RangeDamagePerSecond(PerRound(Damage.Max, Count.Max), Cooldown.Max, RatedRangeFactor(range)); } public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerConsumer @@ -72,7 +71,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public override float DamagePerSecond => Damage / Cooldown; public override float RangeDamagePerSecond(float range) { - return InstantWeaponData.RangeDamagePerSecond(_data.DamageCurve, Damage, MinRange, Range, Cooldown, range); + return InstantWeaponData.RangeDamagePerSecond(Damage, Cooldown, RangeFactor(range)); } public override int Ammo diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 87098b78b..6a01f81ca 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -37,8 +37,16 @@ public abstract class WeaponData : BehaviorData [Inspectable, JsonProperty("range"), Key(6)] public PerformanceStat Range = new PerformanceStat(); - [InspectableAnimationCurve, JsonProperty("damageCurve"), Key(7)] - public BezierCurve DamageCurve; + // Key 7 was the damage curve, retired and never reused. + + // The range up to which a round does its full damage; from here to Range damage lerps to DamageAtMaxRange. Unauthored + // is no falloff (Tracking's precedent): +infinity clamps to Range in RangeFactor. + [Inspectable, JsonProperty("effectiveRange"), Key(35)] + public PerformanceStat EffectiveRange = new PerformanceStat { Min = float.PositiveInfinity, Max = float.PositiveInfinity }; + + // The multiplier on damage at Range, the end of the lerp (0 is nothing arriving at maximum range). + [Inspectable, JsonProperty("damageAtMaxRange"), Key(36)] + public float DamageAtMaxRange; [Inspectable, CultInspectorAssetGuid, JsonProperty("effect"), Key(8)] public string EffectPrefab; @@ -76,6 +84,18 @@ public abstract class WeaponData : BehaviorData // design's rated figures, each stat's Max (what a stat with no terms resolves to), through the same formula the // live weapon's RangeDamagePerSecond runs over its evaluated stats. Never the carrier's quality, heat, wear or power. public abstract float RatedRangeDamagePerSecond(float range); + public float RatedRangeFactor(float at) => RangeFactor(MinRange.Max, EffectiveRange.Max, Range.Max, DamageAtMaxRange, at); + + // The one function of how much of a weapon's damage arrives at a planar range: 0 outside [min, max] (inclusive, as + // FireControl.Designated), 1 up to the effective range, then one lerp to damageAtMax at max. An effective range outside + // [min, max] clamps into it. + public static float RangeFactor(float min, float effective, float max, float damageAtMax, float at) + { + if (at < min || at > max) return 0f; + var e = clamp(effective, min, max); + return e >= max ? 1f : lerp(1f, damageAtMax, saturate(unlerp(e, max, at))); + } + [IgnoreMember] public float RatedRange => Range.Max; } @@ -88,11 +108,13 @@ public abstract class Weapon : Behavior, IActivatedBehavior public abstract float RangeDamagePerSecond(float range); public abstract int Ammo { get; } public WeaponData WeaponData => _data; + public float RangeFactor(float at) => WeaponData.RangeFactor(MinRange, EffectiveRange, Range, _data.DamageAtMaxRange, at); public float Damage { get; protected set; } public float Penetration { get; protected set; } public float DamageSpread { get; protected set; } public float MinRange { get; protected set; } + public float EffectiveRange { get; protected set; } public float Range { get; protected set; } public float Energy { get; protected set; } public float Heat { get; protected set; } @@ -141,6 +163,7 @@ public abstract class Weapon : Behavior, IActivatedBehavior Penetration = Evaluate(_data.Penetration); DamageSpread = Evaluate(_data.DamageSpread); MinRange = Evaluate(_data.MinRange); + EffectiveRange = Evaluate(_data.EffectiveRange); Range = Evaluate(_data.Range); Energy = Evaluate(_data.Energy); Heat = Evaluate(_data.Heat); diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 6a1f4e3ba..4e1afeb84 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -584,7 +584,8 @@ public static class FireControl // it whether a round is refused before they spend anything -- so a refusal is decided once, by the same // code that would have flown the round, and the HUD cannot forecast a burst the trigger will not fire. // Direct: a round that resolves against its target (a weapon without a fuse). Burst: a fused round, with - // BurstReach the planar distance from the shooter at which it bursts. Refused: a fused round whose blast + // BurstReach the planar distance from the shooter at which the payload is delivered (a direct round's target, a fused + // round's burst point, 0 for an untargeted direct round), which Fire's range factor reads for every outcome. Refused: a fused round whose blast // radius (its arming distance) exceeds the weapon's Range; it cannot be fired. public static FireSolution Solve(Weapon weapon, EquippedItem item, Entity source, Entity target) { @@ -722,7 +723,8 @@ public static class FireControl Target = engaged, Weapon = item, Aimed = source.ResolvedTargetItem, - Damage = damageOverride ?? weapon.Damage, + // The one place a shot's damage meets range, frozen here: BurstReach is where the payload is delivered. + Damage = (damageOverride ?? weapon.Damage) * weapon.RangeFactor(solution.BurstReach), Penetration = weapon.Penetration, DamageSpread = weapon.DamageSpread, DamageType = weapon.WeaponData.DamageType, @@ -2038,8 +2040,8 @@ public struct GunSolution public float2 Direction; } -// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point, read for Burst -// only) are what the HUD shows; the rest is what Fire freezes into the PendingShot. +// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point; the HUD reads it for +// Burst, Fire's range factor for both outcomes) are what the HUD shows; the rest is what Fire freezes into the PendingShot. public struct FireSolution { public FireOutcome Outcome; diff --git a/Assets/Scripts/UI/Properties Panel/CurveField.cs b/Assets/Scripts/UI/Properties Panel/CurveField.cs index 48c992890..d2e8b43e0 100644 --- a/Assets/Scripts/UI/Properties Panel/CurveField.cs +++ b/Assets/Scripts/UI/Properties Panel/CurveField.cs @@ -20,8 +20,7 @@ public class CurveField : MonoBehaviour public Image CurrentX; public Image CurrentY; - private BezierCurve _curve; - //private Func _xFunc; + private Func _y; public void SetCurrent(float x) { @@ -29,46 +28,33 @@ public class CurveField : MonoBehaviour CurrentY.gameObject.SetActive(true); var xrect = CurrentX.GetComponent(); var yrect = CurrentY.GetComponent(); - var y = _curve.Evaluate(x); + var y = _y(x); xrect.anchorMin = new Vector2(x, 0); xrect.anchorMax = new Vector2(x, y); yrect.anchorMin = new Vector2(x, y); yrect.anchorMax = new Vector2(x, y); } - public void Show(string label, BezierCurve curve, Func xFunc, bool showMax = false) + // Segments + 1 uniform samples of y over [0, 1], labelled by xFunc; the middle label sits at midX. + public void Show(string label, Func y, Func xFunc, float midX = .5f) { - _curve = curve; - //_xFunc = xFunc; + _y = y; CurrentX.gameObject.SetActive(false); CurrentY.gameObject.SetActive(false); Title.text = label; var points = new List(); - var keys = curve.Keys.Select(v => new Vector2(v.x, v.y)); - if (curve.Keys[0].x > .01f) keys = keys.Prepend(new Vector2(0, curve.Keys[0].y)); - if (curve.Keys[curve.Keys.Length-1].x < .99f) keys = keys.Append(new Vector2(1, curve.Keys[curve.Keys.Length-1].y)); - var keysArray = keys.ToArray(); - for (int i = 0; i < keysArray.Length - 1; i++) + for (int j = 0; j <= Segments; j++) { - for (int j = 0; j < Segments; j++) - { - var x = lerp(keysArray[i].x, keysArray[i + 1].x, (float) j / Segments); - points.Add(new Vector2(x, curve.Evaluate(x))); - } + var x = (float) j / Segments; + points.Add(new Vector2(x, y(x))); } - points.Add(new Vector2(1, curve.Evaluate(1))); Line.Points = points.ToArray(); MinLabel.text = xFunc(0); MaxLabel.text = xFunc(1); - if (showMax) - { - var midX = curve.Maximum; - MidLabel.text = xFunc(midX); - var rect = MidLabel.GetComponent(); - rect.anchorMin = new Vector2(midX, rect.anchorMin.y); - rect.anchorMax = new Vector2(midX, rect.anchorMax.y); - } - else MidLabel.text = xFunc(.5f); + MidLabel.text = xFunc(midX); + var rect = MidLabel.GetComponent(); + rect.anchorMin = new Vector2(midX, rect.anchorMin.y); + rect.anchorMax = new Vector2(midX, rect.anchorMax.y); } } diff --git a/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs b/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs index 33d3050c5..053e148b8 100644 --- a/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs +++ b/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs @@ -423,7 +423,13 @@ public class PropertiesPanel : MonoBehaviour if (gearData.Behaviors.FirstOrDefault(b => b is WeaponData) is WeaponData weapon) { var range = AddCurveField(); - range.Show("Damage Range", weapon.DamageCurve, t => ActionGameManager.PlayerSettings.Format(lerp(statValueFunction(weapon.MinRange), statValueFunction(weapon.Range), t))); + var min = statValueFunction(weapon.MinRange); + var effective = statValueFunction(weapon.EffectiveRange); + var max = statValueFunction(weapon.Range); + range.Show("Damage Range", + t => WeaponData.RangeFactor(min, effective, max, weapon.DamageAtMaxRange, lerp(min, max, t)), + t => ActionGameManager.PlayerSettings.Format(lerp(min, max, t)), + effective < max ? saturate(unlerp(min, max, effective)) : .5f); } } diff --git a/tests/Aetheria.Shared.Tests/ClaimTests.cs b/tests/Aetheria.Shared.Tests/ClaimTests.cs index 274650018..c07d5f413 100644 --- a/tests/Aetheria.Shared.Tests/ClaimTests.cs +++ b/tests/Aetheria.Shared.Tests/ClaimTests.cs @@ -18,11 +18,6 @@ public sealed partial class ContactRevealTests private static PerformanceStat Span(float min, float max) => new PerformanceStat { Min = min, Max = max }; - // A damage curve that falls from 1 to .2 over the range span, so every range argument lands on a different factor. - private static BezierCurve Falloff() => new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, .2f, 0, 0) } }; - - private static BezierCurve Flat() => new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; - // ---- ClaimsDefaultToTheTruth ---- [Fact] @@ -216,30 +211,26 @@ public sealed partial class ContactRevealTests // A design's rated figure is each stat's Max through the live weapon's own formula. Every stat is authored with // Min below Max, a falling curve and a MinRange above 0, so reading a Min, scaling the range argument, the wrong - // stat or the wrong operator changes the answer. At 70 the range factor is (70 - 20) / (120 - 20) = .5. + // stat or the wrong operator changes the answer. The spanned fixture falls from full damage at its effective range + // (60) to .2 at its range (120): at 90 the range factor is lerp(1, .2, (90 - 60) / (120 - 60)) = .6. // Mutation: any rated override reads Range.Min or MinRange.Min or a Min, drops the burst divisor, the charge // multiplier or the charge time; the shared formula scales its range argument. [Fact] public void ARatedFigureIsTheDesignsMaxThroughTheSharedFormula() { - var curve = Falloff(); - var half = curve.Evaluate(.5f); - Assert.NotEqual(half, curve.Evaluate(1f)); // the Range.Min and halved-range mutants land elsewhere on the curve - Assert.NotEqual(half, curve.Evaluate(.15f)); - Assert.NotEqual(half, curve.Evaluate(70f / 120f)); - + const float factor = .6f; var instant = Spanned(new InstantWeaponData()); - Assert.Equal(100f / 2 * half / 2, instant.RatedRangeDamagePerSecond(70f), 4); // 100 / 2 rounds, curve, / 2 s + Assert.Equal(100f / 2 * factor / 2, instant.RatedRangeDamagePerSecond(90f), 4); // 100 / 2 rounds, factor, / 2 s Assert.Equal(120f, instant.RatedRange); var charged = (ChargedWeaponData) Spanned(new ChargedWeaponData { ChargeTime = Span(1, 3), ChargeFiringDamageMultiplier = 2f }); - Assert.Equal(100f / 2 * 2 * half / (2 + 3), charged.RatedRangeDamagePerSecond(70f), 4); + Assert.Equal(100f / 2 * 2 * factor / (2 + 3), charged.RatedRangeDamagePerSecond(90f), 4); var beam = Spanned(new ConstantWeaponData()); - Assert.Equal(100f * half, beam.RatedRangeDamagePerSecond(70f), 4); + Assert.Equal(100f * factor, beam.RatedRangeDamagePerSecond(90f), 4); Assert.Equal(120f, beam.RatedRange); } @@ -252,18 +243,19 @@ public sealed partial class ContactRevealTests { var e = Add(float3(0, 0, 0), new[] { "SpanGun", "SpanCharged", "SpanBeam" }); Tick(.1f); - var curve = Falloff(); + // The hand arithmetic of the fixture: 0 outside [20, 120], 1 up to 60, then a straight fall to .2 at 120. + Func factor = at => at < 20f || at > 120f ? 0f : at <= 60f ? 1f : 1f - .8f * (at - 60f) / 60f; var expected = new Dictionary> { - ["SpanGun"] = at => 100f / 2 * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f)) / 2, - ["SpanCharged"] = at => 100f / 2 * 2 * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f)) / (2 + 3), - ["SpanBeam"] = at => 100f * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f)) + ["SpanGun"] = at => 100f / 2 * factor(at) / 2, + ["SpanCharged"] = at => 100f / 2 * 2 * factor(at) / (2 + 3), + ["SpanBeam"] = at => 100f * factor(at) }; foreach (var item in e.Equipment.Where(i => i.Behaviors.Any(b => b is Weapon))) { var live = item.Behaviors.OfType().Single(); var rated = item.Data.Behaviors.OfType().Single(); - foreach (var at in new[] { 10f, 70f, 110f, 500f }) + foreach (var at in new[] { 10f, 40f, 90f, 500f }) { Assert.Equal(expected[item.Data.Name](at), live.RangeDamagePerSecond(at), 3); Assert.Equal(live.RangeDamagePerSecond(at), rated.RatedRangeDamagePerSecond(at), 3); @@ -272,7 +264,7 @@ public sealed partial class ContactRevealTests } // A charged weapon at full charge reads the same rated figure: the charge multiplier is applied once, in the formula // (UpdateStats lerps the shot's damage by charge, so the live reads start from the uncharged damage). At range 10 the - // curve factor is 1: 100 / 2 rounds x 2 / (2 + 3) = 20. Mutation: the live reads pass the charge-lerped Damage. + // range factor is 1: 100 / 2 rounds x 2 / (2 + 3) = 20. Mutation: the live reads pass the charge-lerped Damage. [Fact] public void AFullyChargedWeaponReadsItsRatedFigure() { @@ -284,10 +276,10 @@ public sealed partial class ContactRevealTests typeof(ChargedWeapon).GetField("_charge", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(live, 1f); typeof(Weapon).GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(live, null); - foreach (var at in new[] { 10f, 70f, 110f }) + foreach (var at in new[] { 25f, 70f, 110f }) Assert.Equal(rated.RatedRangeDamagePerSecond(at), live.RangeDamagePerSecond(at), 3); Assert.Equal(20f, live.DamagePerSecond, 3); - Assert.Equal(20f, live.RangeDamagePerSecond(10f), 3); + Assert.Equal(20f, live.RangeDamagePerSecond(25f), 3); } // ---- The schematic's rows (ruling ew-claim-weapon-measure-ruled; finding schematic-unreached) ---- diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 49f371fc0..16e341e61 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -157,7 +157,6 @@ public sealed partial class ContactRevealTests : IDisposable weapon.Heat = Const(0); weapon.Visibility = Const(5); weapon.MagazineSize = magazine; - weapon.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; return weapon; } @@ -167,7 +166,8 @@ public sealed partial class ContactRevealTests : IDisposable weapon.Damage = Span(10, 100); weapon.MinRange = Span(0, 20); weapon.Range = Span(50, 120); - weapon.DamageCurve = Falloff(); + weapon.EffectiveRange = Span(30, 60); + weapon.DamageAtMaxRange = .2f; if (weapon is InstantWeaponData instant) { instant.Count = Span(1, 2); @@ -208,7 +208,6 @@ public sealed partial class ContactRevealTests : IDisposable Damage = Const(0), Range = Const(1000), MinRange = Const(0), Velocity = Const(0), Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1), BurstTime = Const(0), Cooldown = Const(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), DirectionImpact = Const(1), Decay = Const(.1f), InterceptFactor = interceptFactor.HasValue ? Const(interceptFactor.Value) : null }, diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index efe79b116..752d046d1 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -105,7 +105,6 @@ public sealed class FireAuthorityTests : IDisposable Damage = Constant(damage), Range = Constant(range), MinRange = Constant(minRange), Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(damageSpread), Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index 04a3188ce..5dc373390 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -102,7 +102,6 @@ public sealed class FireControlCut11Tests : IDisposable Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 9f1fbdac5..bfb313a2b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -115,7 +115,6 @@ public sealed class FireControlCut123Tests : IDisposable Damage = Constant(5), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(damageSpread), Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 15fca5f6b..4bcd5b034 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -86,7 +86,7 @@ public sealed partial class FireControlCut124Tests : IDisposable // (LockAngle 180 admits any bearing) or never grows (LockAngle 0 admits none, so it only decays). A stat // term on Range lets a test scale it below its authored value through the power supply. private static InstantWeaponData WeaponBehavior(float damage, float range, StatSource? rangeTerm, float velocity, float penetration, - bool lockWeapon, bool lockAcquires) + bool lockWeapon, bool lockAcquires, float effectiveRange = float.PositiveInfinity, float damageAtMaxRange = 0f) { var data = lockWeapon ? new LockWeaponData { @@ -97,6 +97,8 @@ public sealed partial class FireControlCut124Tests : IDisposable data.Range = rangeTerm == null ? Constant(range) : new PerformanceStat { Min = range, Max = range, Terms = { new StatTerm { Source = rangeTerm.Value, Exponent = 1f } } }; data.MinRange = Constant(0); + data.EffectiveRange = Constant(effectiveRange); + data.DamageAtMaxRange = damageAtMaxRange; data.Velocity = Constant(velocity); data.Spread = Constant(0); data.DamageSpread = Constant(0); @@ -104,7 +106,6 @@ public sealed partial class FireControlCut124Tests : IDisposable data.Count = Constant(1); data.BurstTime = Constant(0); data.Cooldown = Constant(1000); - data.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; return data; } @@ -117,7 +118,8 @@ public sealed partial class FireControlCut124Tests : IDisposable int2? cockpitCell = null, float cockpitDurability = 1000f, (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null, float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f, - float hardpointArc = 0f, StatSource? rangeTerm = null, bool lockWeapon = false, bool lockAcquires = true) + float hardpointArc = 0f, StatSource? rangeTerm = null, bool lockWeapon = false, bool lockAcquires = true, + float effectiveRange = float.PositiveInfinity, float damageAtMaxRange = 0f) { var hullData = new HullData { @@ -140,7 +142,7 @@ public sealed partial class FireControlCut124Tests : IDisposable Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Fuse = fuse, BlastRadius = blastRadius, WeaponModifiers = modifiers, - Behaviors = { WeaponBehavior(damage, weaponRange, rangeTerm, velocity, penetration, lockWeapon, lockAcquires) } + Behaviors = { WeaponBehavior(damage, weaponRange, rangeTerm, velocity, penetration, lockWeapon, lockAcquires, effectiveRange, damageAtMaxRange) } }); cache.Upsert(new GearData { diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index b93df8281..1c343c00b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -141,7 +141,6 @@ public sealed class FireControlCut12Tests : IDisposable Damage = Constant(5), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index 8ff7eef2f..469ebc75b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -92,7 +92,6 @@ public sealed class FireControlCut4Tests : IDisposable { Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData @@ -214,7 +213,6 @@ public sealed class FireControlCut4Tests : IDisposable Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.FlushAsync().Wait(); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index 406a5d66b..f9d12e330 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -107,7 +107,6 @@ public sealed class FireControlCut5Tests : IDisposable Damage = Constant(damage), Range = Constant(range), MinRange = Constant(minRange), Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(damageSpread), Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData @@ -295,7 +294,6 @@ public sealed class FireControlCut5Tests : IDisposable { Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 832038882..1755cd7e3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -104,7 +104,6 @@ public sealed class FireControlCut6Tests : IDisposable Damage = Constant(damage), Range = Constant(range), MinRange = Constant(0), Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs index b0b0ed373..e04ee078b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs @@ -93,7 +93,6 @@ public sealed class FireControlCut6cTests : IDisposable Damage = Constant(10), Range = Constant(range), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs index c11438266..828965836 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs @@ -95,7 +95,6 @@ public sealed class FireControlCut6dTests : IDisposable Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs index a35a836b6..1984e7b74 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -89,7 +89,6 @@ public sealed class FireControlCut7Tests : IDisposable Damage = Constant(50), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index f313f7909..8da880f4c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -93,7 +93,6 @@ public sealed class FireControlCut8Tests : IDisposable Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData @@ -185,7 +184,6 @@ public sealed class FireControlCut8Tests : IDisposable Damage = Constant(0), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170), DirectionImpact = Constant(1), Decay = Constant(1) } } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 09e76e35b..cc63a9bc4 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -294,7 +294,6 @@ public sealed class FireControlCut9Tests : IDisposable Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData @@ -533,7 +532,6 @@ public sealed class FireControlCut9Tests : IDisposable Damage = Constant(0), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170), DirectionImpact = Constant(1), Decay = Constant(1) } } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index bb065db55..ed8716cff 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -140,7 +140,6 @@ public sealed class FireControlPerWeaponTests : IDisposable behavior.Heat = Constant(heat); behavior.Visibility = Constant(visibility); behavior.MagazineSize = magazine; - behavior.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); _openCaches.Add(cache); @@ -165,7 +164,6 @@ public sealed class FireControlPerWeaponTests : IDisposable Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), Damage = Constant(50), Range = Constant(100), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Energy = Constant(0), Heat = Constant(0), Visibility = Constant(0), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 839f64034..d05073c85 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -192,7 +192,6 @@ public sealed class FireControlTests : IDisposable weaponData.Range = new PerformanceStat { Min = 1000, Max = 1000 }; weaponData.MinRange = new PerformanceStat { Min = 0, Max = 0 }; weaponData.Velocity = new PerformanceStat { Min = projectileSpeed, Max = projectileSpeed }; - weaponData.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; var zone = shooter.Zone; var targetHullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); @@ -213,7 +212,6 @@ public sealed class FireControlTests : IDisposable weakData.Cooldown = new PerformanceStat { Min = 1, Max = 1 }; weakData.Range = new PerformanceStat { Min = 1000, Max = 1000 }; weakData.MinRange = new PerformanceStat { Min = 0, Max = 0 }; - weakData.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; Assert.True(target.TryEquip(new EquippableItem { Data = weakRef, Durability = 1, Lot = 2 }, new int2(1, 0))); } zone.Admit(target, piloted: false); @@ -286,7 +284,7 @@ public sealed class FireControlTests : IDisposable // A design as an observer might be told it is: an instant weapon with rated figures, held in memory (a claim is data, // not a catalog item). Rated damage per second at any range is damage / cooldown (one round, flat curve). - private static GearData ClaimedDesign(string name, float damage, float range = 1000f, BezierCurve curve = null) => new GearData + private static GearData ClaimedDesign(string name, float damage, float range = 1000f, float? effective = null, float end = 0f) => new GearData { Name = name, Behaviors = @@ -296,7 +294,8 @@ public sealed class FireControlTests : IDisposable Damage = new PerformanceStat { Min = 0, Max = damage }, Count = new PerformanceStat { Min = 1, Max = 1 }, Cooldown = new PerformanceStat { Min = 1, Max = 1 }, Range = new PerformanceStat { Min = 0, Max = range }, MinRange = new PerformanceStat { Min = 0, Max = 0 }, - DamageCurve = curve ?? new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + EffectiveRange = effective.HasValue ? new PerformanceStat { Min = effective.Value, Max = effective.Value } : new PerformanceStat { Min = float.PositiveInfinity, Max = float.PositiveInfinity }, + DamageAtMaxRange = end } } }; @@ -352,16 +351,15 @@ public sealed class FireControlTests : IDisposable // The ranking reads the claimed design at the distance to the target: a short-range design that out-damages a flat one // up close is worthless at 10 when its Range is 12, and the weaker flat design is chosen. Mutation: Combat halves - // targetDistance (the short design then rates 6.2 at 5 and wins), or the rated read ignores the range. + // targetDistance (the short design then rates 5.8 at 5 and wins), or the rated read ignores the range. [Fact] public void TheAIRanksClaimsAtTheTargetsDistance() { var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true); var strong = target.Equipment.Single(x => x.Data.Name == "Gun"); var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun"); - var falling = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 0, 0, 0) } }; - target.Claim(strong, ClaimedDesign("Dart", 10f, range: 12f, curve: falling)); // ~.7 at 10, ~6.2 at 5 + target.Claim(strong, ClaimedDesign("Dart", 10f, range: 12f, effective: 0f)); // ~1.7 at 10, ~5.8 at 5 target.Claim(weak, ClaimedDesign("Slug", 5f)); // 5 at any range shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong)); shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak)); @@ -371,6 +369,25 @@ public sealed class FireControlTests : IDisposable Assert.Same(weak, shooter.TargetItem.Value); } + // The AI settles where its guns do the most damage per second, weighted towards range: with full damage to 500 and none + // at 1000 that is the effective range (DF6). Mutation: SampleDps spans from the shortest Range (every sample then reads + // 1000, factor 0, and the optimum stays 0), or the factor reads an offset range. + [Fact] + public void TheAIHoldsAtItsEffectiveRange() + { + var (shooter, _, zone) = BuildCombatEncounter(float3(0, 0, 10)); + var data = (InstantWeaponData) shooter.Weapons.Single().WeaponData; + data.EffectiveRange = new PerformanceStat { Min = 500, Max = 500 }; + data.DamageAtMaxRange = 0f; + zone.Update(1f); // enters CombatState, which samples DPS over the evaluated stats + + var agent = zone.Agents.Single(); + var state = typeof(Agent).GetField("_currentState", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(agent); + Assert.IsType(state); + var optimum = (float) typeof(CombatState).GetField("_optimumRange", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(state); + Assert.InRange(optimum, 500f - 1000f / 32f, 500f + 1000f / 32f); + } + // Ruling ew-claim-weapon-measure-ruled: no observer reads a target weapon's live state. Two unclaimed guns whose // rated order (10 against 1) is the reverse of their live order (the strong one worn down to a hundredth) are // ranked by the design, so the aim stays on the rated stronger gun. Mutation: rank an unclaimed reveal by the diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 1c23d15aa..a9e1c23cd 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -78,7 +78,6 @@ public sealed class LockWarningTests : IDisposable Damage = Const(0), Range = Const(1000), MinRange = Const(0), Velocity = Const(0), Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1), BurstTime = Const(0), Cooldown = Const(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, LockSpeed = Const(lockSpeed), SensorImpact = Const(sensorImpact), LockAngle = Const(lockAngle), DirectionImpact = Const(directionImpact), Decay = Const(.1f) } diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index e6f79fe93..6b736fa23 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -87,7 +87,6 @@ public sealed class LootTests : IDisposable { Damage = Constant(1), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } } } }); cache.Upsert(new GearData diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index da225ce86..afdd0ef4f 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -94,7 +94,6 @@ public sealed class MineTests : IDisposable Damage = Constant(damage), Range = Constant(range), MinRange = Constant(0), Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(cooldown), MagazineSize = magazine, - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } }; cache.Upsert(new WeaponItemData { diff --git a/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs b/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs new file mode 100644 index 000000000..df203d659 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; + +// Damage falloff (aetheria-release, damage-curve-fate-ruled): a weapon's damage is flat to its effective range, then one +// lerp to DamageAtMaxRange at its Range, and 0 outside [MinRange, Range]. The function, the catalog authored through it, +// and the fire-time freeze that reads it. +public sealed class RangeFalloffTests +{ + private static PerformanceStat Span(float min, float max) => new PerformanceStat { Min = min, Max = max }; + + // [20, 60, 120], .25 at the end: flat to 60, then 1 -> .25 over 60 units. + [Fact] + public void TheFactorIsFlatToEffectiveThenOneLerp() + { + float F(float at) => WeaponData.RangeFactor(20f, 60f, 120f, .25f, at); + Assert.Equal(0f, F(19.9f)); + Assert.Equal(0f, F(120.1f)); + Assert.Equal(1f, F(20f)); + Assert.Equal(1f, F(40f)); + Assert.Equal(1f, F(60f)); + Assert.Equal(.625f, F(90f), 5); + Assert.Equal(.25f, F(120f), 5); + } + + [Fact] + public void AnUnauthoredOrDegenerateEffectiveRangeIsFlat() + { + var fresh = new InstantWeaponData { MinRange = Span(10, 10), Range = Span(100, 100) }; + Assert.True(float.IsPositiveInfinity(fresh.EffectiveRange.Min) && float.IsPositiveInfinity(fresh.EffectiveRange.Max)); + foreach (var at in new[] { 10f, 55f, 100f }) + Assert.Equal(1f, fresh.RatedRangeFactor(at)); + + // Below the minimum range the effective range reads as the minimum: full damage only at it, then the lerp. + Assert.Equal(1f, WeaponData.RangeFactor(20f, 5f, 120f, 0f, 20f)); + Assert.Equal(.5f, WeaponData.RangeFactor(20f, 5f, 120f, 0f, 70f), 5); + // At or past the maximum range it is flat everywhere inside the gate. + foreach (var at in new[] { 20f, 70f, 120f }) + Assert.Equal(1f, WeaponData.RangeFactor(20f, 120f, 120f, 0f, at)); + Assert.Equal(1f, WeaponData.RangeFactor(20f, 500f, 120f, 0f, 70f)); + } + + [Fact] + public void ShippedWeaponsFallOffAsAuthored() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var weapons = cache.GetAll().SelectMany(i => i.Behaviors.OfType().Select(w => (i.Name, w))).ToArray(); + Assert.Equal(27, weapons.Length); + var falling = weapons.Where(x => !(x.w is LockWeaponData || x.w is GuidedWeaponData || x.w is MineLayerData)).ToArray(); + Assert.Equal(21, falling.Length); + foreach (var (name, weapon) in falling) + { + Assert.True(weapon.EffectiveRange.Min == weapon.Range.Min * .5f && weapon.EffectiveRange.Max == weapon.Range.Max * .5f, $"{name}: effective range is half the range"); + Assert.Equal(weapon.Range.Terms.Select(t => (t.Source, t.Exponent, t.Role)), weapon.EffectiveRange.Terms.Select(t => (t.Source, t.Exponent, t.Role))); + Assert.Equal(0f, weapon.DamageAtMaxRange); + } + foreach (var (name, weapon) in weapons.Except(falling)) + Assert.True(float.IsPositiveInfinity(weapon.EffectiveRange.Max) && float.IsPositiveInfinity(weapon.EffectiveRange.Min), $"{name}: no falloff"); + foreach (var (name, weapon) in weapons) + for (var i = 0; i <= 64; i++) + Assert.True(weapon.RatedRangeFactor(lerp(weapon.MinRange.Min, weapon.Range.Max, i / 64f)) <= 1f, $"{name}: factor above 1"); + } +} + +public sealed partial class FireControlCut124Tests +{ + private ShotRecord FreezeAt(float targetRange, float effective = 50f, float weaponRange = 150f, float? damageOverride = null) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, damage: 100f, weaponRange: weaponRange, targetRange: targetRange, + effectiveRange: effective, damageAtMaxRange: 0f); + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride); + Assert.NotEqual(0, shotId); + return new ShotRecord { Damage = e.Zone.PendingShots.Single(s => s.ShotId == shotId).Damage }; + } + + private struct ShotRecord { public float Damage; } + + // The observation is where damage is decided at fire time: the shot right after Fire, before anything resolves. + [Fact] + public void FireFreezesDamageTimesTheFactorAtDeliveryRange() + { + Assert.Equal(50f, FreezeAt(100f).Damage, 3); // halfway from 50 to 150 + Assert.Equal(100f, FreezeAt(40f).Damage, 3); // inside the effective range + Assert.Equal(0f, FreezeAt(150f).Damage, 3); // at the maximum range + } + + // A fused round with no target bursts at Range ("bomb fishing"), so it lands at the end multiplier. + [Fact] + public void AFusedRoundFishedToRangeLandsAtTheEndMultiplier() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 150f, effectiveRange: 50f, damageAtMaxRange: .25f); + e.Shooter.SetTarget(TargetRef.None); + Aim(e, float2(0, 1)); + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + Assert.NotEqual(0, shotId); + Assert.Equal(25f, e.Zone.PendingShots.Single(s => s.ShotId == shotId).Damage, 3); + } + + // A beam roll fires through damageOverride: the override is what the factor multiplies. + [Fact] + public void ABeamRollCarriesTheFactor() => Assert.Equal(5f, FreezeAt(100f, damageOverride: 10f).Damage, 3); +} diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 5edaa035b..661747831 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -459,18 +459,6 @@ public sealed partial class RunStartTests : IDisposable Assert.Equal(new[] { "CShot RainbowLite Lazer", "ChargeBlast SG", "ChargeBlast+-", "OK Disperser" }, homeless); } - // A sold weapon can be generated onto an NPC, whose combat state samples its damage at range, so every weapon a - // product sells carries a damage curve. - [Fact] - public void EverySoldWeaponHasADamageCurve() - { - var sold = _cache.GetAll().Select(product => product.Design.Key).ToHashSet(); - var missing = _cache.GetAll() - .Where(design => sold.Contains(_cache.RefOf(design).Key)) - .Where(design => design.Behaviors.OfType().Any(weapon => weapon.DamageCurve?.Keys == null || weapon.DamageCurve.Keys.Length == 0)) - .Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToList(); - Assert.True(missing.Count == 0, $"sold weapons with no damage curve: {string.Join(", ", missing)}"); - } // An empty hull of this kind to equip into by hand. private Entity BareHull(HullData hull) => diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 198e82cc2..f1289f58a 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -118,7 +118,6 @@ public sealed class SignatureTests : IDisposable behavior.Heat = Constant(0); behavior.Visibility = Constant(_weaponVisibility); behavior.MagazineSize = armament == Armament.Gun ? 5 : 0; - behavior.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; cache.Upsert(new WeaponItemData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs index 120f7764b..4b55ee97e 100644 --- a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -97,7 +97,6 @@ public sealed class VapourCloudTests : IDisposable Damage = Constant(0), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170), DirectionImpact = Constant(1), Decay = Constant(1) })); diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 80d4b61c8..c0412e9b2 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -35,6 +35,7 @@ public static class Program case "brownout-migrate": return BrownoutMigrate(args.Contains("apply")); case "roles-migrate": return RolesMigrate(args.Contains("apply")); case "firing-arc-migrate": return FiringArcMigrate(args.Contains("apply")); + case "range-falloff": return RangeFalloff(args.Skip(1).ToArray()); case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); @@ -52,7 +53,7 @@ public static class Program return PiratesFaction(args.Contains("apply")); case "verse-grammar": return VerseGrammarCommand.Run(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply]"); return 1; } } @@ -1060,6 +1061,61 @@ public static class Program return 0; } + // Damage falloff (aetheria-release, damage-curve-fate-ruled): every gun and beam authors an effective range at a + // fraction of its Range (Min and Max scaled, Terms copied so the effective range wears and heats as the range does) + // and a damage multiplier at maximum range. Guided weapons, lock weapons and mine layers keep the unauthored + // defaults (no falloff). The command upserts every weapon design, which is also what drops the retired damage curve + // (key 7) from the stored records. Idempotent: the values are functions of Range alone. Dry run unless "apply". + private static int RangeFalloff(string[] args) + { + var apply = args.Contains("apply"); + float Option(string name, float fallback) + { + var at = Array.IndexOf(args, name); + return at >= 0 && at + 1 < args.Length ? float.Parse(args[at + 1], System.Globalization.CultureInfo.InvariantCulture) : fallback; + } + var fraction = Option("--effective", .5f); + var end = Option("--end", 0f); + var db = AetherDb.Open(catalogWritable: apply); + + Console.WriteLine($"{"weapon",-28} {"behaviour",-20} {"range",9} {"effective",9} {"end",5}"); + var changed = new List<(object Document, CultRecordKey Key)>(); + foreach (var item in db.Cache.GetAll().OrderBy(i => i.Name, StringComparer.Ordinal)) + { + var key = db.Cache.RefOf(item).Key; + foreach (var weapon in item.Behaviors.OfType()) + { + var falls = !(weapon is LockWeaponData || weapon is GuidedWeaponData || weapon is MineLayerData); + if (falls) + { + weapon.EffectiveRange = new PerformanceStat + { + Min = weapon.Range.Min * fraction, Max = weapon.Range.Max * fraction, + Terms = weapon.Range.Terms.Select(t => new StatTerm { Source = t.Source, Exponent = t.Exponent, Role = t.Role }).ToList() + }; + weapon.DamageAtMaxRange = end; + } + Console.WriteLine($"{item.Name,-28} {weapon.GetType().Name,-20} {weapon.Range.Max,9:0.##} {(falls ? weapon.EffectiveRange.Max : float.PositiveInfinity),9:0.##} {weapon.DamageAtMaxRange,5:0.##}"); + } + changed.Add((item, key)); + } + + Console.WriteLine($" +{changed.Count} weapon records"); + if (!apply) + { + Console.WriteLine($"Dry run. Pass \"apply\" to land {changed.Count} records."); + return 0; + } + foreach (var (document, _) in changed) CultRecordRefs.Validate(document); + db.Cache.Commit(batch => + { + foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); + }); + Console.WriteLine($"Landed {changed.Count} records in Aetheria.cc"); + return 0; + } + // Fire control Cut 6a (docs/fire-control-cut.md, Cut 2's "Catalog:" bullet). Cut 2 shipped the behaviour and // never shipped the data: no targeting-system design exists, so tools/AetherDb loadout throws // InvalidLoadoutException ("No compatible targeting system found for entity!") for every armed hull. This @@ -1738,7 +1794,7 @@ public static class Program var constant = new ConstantWeaponData { DamageType = instant.DamageType, Damage = instant.Damage, Penetration = instant.Penetration, DamageSpread = instant.DamageSpread, - MinRange = instant.MinRange, Range = Fixed(20f), DamageCurve = instant.DamageCurve, EffectPrefab = instant.EffectPrefab, + MinRange = instant.MinRange, Range = Fixed(20f), EffectiveRange = instant.EffectiveRange, DamageAtMaxRange = instant.DamageAtMaxRange, EffectPrefab = instant.EffectPrefab, Energy = instant.Energy, Heat = instant.Heat, Visibility = instant.Visibility, AmmoType = instant.AmmoType, MagazineSize = instant.MagazineSize, ReloadTime = instant.ReloadTime, Spread = instant.Spread, Velocity = instant.Velocity, Tracking = instant.Tracking @@ -1828,7 +1884,6 @@ public static class Program new MineLayerData { DamageType = DamageType.Kinetic, - DamageCurve = new BezierCurve { Keys = new[] { new float4(0f, 1f, 0f, 0f), new float4(1f, 1f, 0f, 0f) } }, Damage = Constant(50f), Range = Constant(2000f), MinRange = Constant(0f),