Damage falloff: effective range and damage at max range replace DamageCurve
WeaponData.RangeFactor is the one function of damage over range: 0 outside [MinRange, Range], 1 up to EffectiveRange, one lerp to DamageAtMaxRange. DamageCurve (key 7, retired) and its formula parameters, CurveField's BezierCurve path, and every fixture curve line are deleted. Fire freezes damage times the factor at BurstReach; the AI's DPS reads, Combat's optimum sampling and the properties panel chart read the same function. AetherDb gains range-falloff (command only; the catalog is re-saved in a later commit). Cut and wiring land in one commit rather than two; this commit has not been built yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b836af4850
commit
5e0ec92509
33 changed files with 282 additions and 127 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -20,8 +20,7 @@ public class CurveField : MonoBehaviour
|
|||
public Image CurrentX;
|
||||
public Image CurrentY;
|
||||
|
||||
private BezierCurve _curve;
|
||||
//private Func<float, string> _xFunc;
|
||||
private Func<float, float> _y;
|
||||
|
||||
public void SetCurrent(float x)
|
||||
{
|
||||
|
|
@ -29,46 +28,33 @@ public class CurveField : MonoBehaviour
|
|||
CurrentY.gameObject.SetActive(true);
|
||||
var xrect = CurrentX.GetComponent<RectTransform>();
|
||||
var yrect = CurrentY.GetComponent<RectTransform>();
|
||||
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<float, string> 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<float, float> y, Func<float, string> 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<Vector2>();
|
||||
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<RectTransform>();
|
||||
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<RectTransform>();
|
||||
rect.anchorMin = new Vector2(midX, rect.anchorMin.y);
|
||||
rect.anchorMax = new Vector2(midX, rect.anchorMax.y);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<float, float> factor = at => at < 20f || at > 120f ? 0f : at <= 60f ? 1f : 1f - .8f * (at - 60f) / 60f;
|
||||
var expected = new Dictionary<string, Func<float, float>>
|
||||
{
|
||||
["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<Weapon>().Single();
|
||||
var rated = item.Data.Behaviors.OfType<WeaponData>().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) ----
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
} }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
} }
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ItemData>(items.ItemData.GetByName<HullData>("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<CombatState>(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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
110
tests/Aetheria.Shared.Tests/RangeFalloffTests.cs
Normal file
110
tests/Aetheria.Shared.Tests/RangeFalloffTests.cs
Normal file
|
|
@ -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<WeaponItemData>().SelectMany(i => i.Behaviors.OfType<WeaponData>().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);
|
||||
}
|
||||
|
|
@ -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<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
|
||||
var missing = _cache.GetAll<EquippableItemData>()
|
||||
.Where(design => sold.Contains(_cache.RefOf(design).Key))
|
||||
.Where(design => design.Behaviors.OfType<WeaponData>().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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 <Type.Member>]... [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 <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], 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<WeaponItemData>().OrderBy(i => i.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var key = db.Cache.RefOf(item).Key;
|
||||
foreach (var weapon in item.Behaviors.OfType<WeaponData>())
|
||||
{
|
||||
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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue