Aetheria/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs
Meta Vens 5e0ec92509 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>
2026-10-14 02:16:04 +02:00

110 lines
5.5 KiB
C#

/* 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);
}