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:
Meta Vens 2026-10-14 02:16:04 +02:00
commit 5e0ec92509
33 changed files with 282 additions and 127 deletions

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
},

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
{

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
} }

View file

@ -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)
} }

View file

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

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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
{

View 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);
}

View file

@ -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) =>

View file

@ -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,

View file

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

View file

@ -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),