Aetheria/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs
Meta Vens db19ecfeb7 EvasionTrackingTests: the entity overload's offset is checked with the shooter away from the origin
Stryker survivor at FireControl.cs:211 (target.Position + source.Position) was invisible while the shooter sat at 0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 17:43:21 +02:00

253 lines
14 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.Collections.Generic;
using System.Linq;
using CultMath;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// The mount term (docs/aetheria-release-map.md, "Tracking per gun"): how fast a gun's mount can follow a target
// sweeping across the shooter's sky, one factor of every hit price. Rulings tracking-per-gun ("gun times ship"),
// weapon-tracking-authored (a rate is authored per gun, never derived from its footprint), jink-per-ship and
// evasion-angular-velocity. Reuses EvasionTermTests' fixtures: Longinus gun platforms and a generated Djinni.
public sealed partial class RunStartTests
{
private static PerformanceStat EvRate(float rate) => new PerformanceStat { Min = rate, Max = rate };
// The gun's rate, set on its design the way an author sets it, flown two updates so the weapon reads it.
private static void EvSetRate(Weapon weapon, Ship shooter, float rate)
{
weapon.WeaponData.Tracking = EvRate(rate);
shooter.Update(EvDt);
shooter.Update(EvDt);
Assert.Equal(rate, weapon.Tracking);
}
// The range fixture with the shooter's projectile gun at one rate and its laser at another, the target seen. The
// shooter is put back at the origin at rest, so the geometry is exactly what the test says.
private EvRange EvMountRange(float gunRate, float laserRate)
{
var range = EvRangeLaunch();
EvSetRate(range.Gun, range.Gunner, gunRate);
EvSetRate(range.GunnerLaser, range.Gunner, laserRate);
range.Gunner.Position = float3(0, range.Gunner.Position.y, 0);
range.Gunner.Velocity = float2(0, 0);
Assert.True(range.Gunner.VisibleEntities.Contains(range.Target), "fixture: the shooter still sees the target");
return range;
}
// The two guns of the one shooter: same gear, same ship, so only the gun's own rate differs.
private static (Weapon weapon, Ship shooter)[] EvGuns(EvRange range) => new[] { (range.Gun, range.Gunner), (range.GunnerLaser, range.Gunner) };
[Fact]
public void AngularVelocityIsTransverseOverRange()
{
// 150 m/s across a thousand metres is 0.15 rad/s, whichever way across, whichever way the line of sight points.
var expected = degrees(.15f);
Assert.Equal(expected, FireControl.AngularVelocity(float2(1000, 0), float2(0, 150)), 3);
Assert.Equal(expected, FireControl.AngularVelocity(float2(1000, 0), float2(0, -150)), 3);
Assert.Equal(expected, FireControl.AngularVelocity(float2(0, 1000), float2(150, 0)), 3);
Assert.Equal(expected, FireControl.AngularVelocity(float2(-1000, 0), float2(0, 150)), 3);
var angle = radians(37f);
float2 Turn(float2 v) => float2(v.x * cos(angle) - v.y * sin(angle), v.x * sin(angle) + v.y * cos(angle));
Assert.Equal(expected, FireControl.AngularVelocity(Turn(float2(1000, 0)), Turn(float2(0, 150))), 3);
// Only the transverse part counts: radial velocity is no sweep at all, and a general case reads both terms.
Assert.Equal(0f, FireControl.AngularVelocity(float2(1000, 0), float2(150, 0)), 4);
Assert.Equal(degrees(17000f / 250000f), FireControl.AngularVelocity(float2(300, 400), float2(-20, 30)), 3);
// A target nearer than a metre is read at a metre: 1 m/s across half a metre is 0.5 rad/s, not 2.
Assert.Equal(degrees(.5f), FireControl.AngularVelocity(float2(.5f, 0), float2(0, 1)), 3);
// The entity overload reads the relative velocity, planar: a common velocity is no sweep, and the shooter's own
// motion is subtracted from the target's.
var (_, source, target) = EvFleet();
source.Position = float3(0, 0, 0);
target.Position = float3(1000, 0, 0);
source.Velocity = float2(30, 40);
target.Velocity = float2(30, 40);
Assert.Equal(0f, FireControl.AngularVelocity(source, target), 4);
// Wherever the pair stands: the offset is target minus source.
source.Position = float3(500, 0, 300);
target.Position = float3(1500, 0, 300);
source.Velocity = float2(0, 100);
target.Velocity = float2(0, 250);
Assert.Equal(expected, FireControl.AngularVelocity(source, target), 3);
}
[Fact]
public void TheMountHitsHalfAtItsRate()
{
foreach (var rate in new[] { .5f, 3f, 40f })
{
Assert.Equal(.5f, FireControl.PMount(rate, rate), 6);
Assert.Equal(1f, FireControl.PMount(0f, rate));
Assert.Equal(1f / 16f, FireControl.PMount(2 * rate, rate), 6);
Assert.Equal(1f, FireControl.PMount(rate, float.PositiveInfinity));
}
Assert.Equal(1f, FireControl.PMount(1e6f, float.PositiveInfinity));
// A mount that follows nothing hits only what stands still.
Assert.Equal(0f, FireControl.PMount(1f, 0f));
Assert.Equal(1f, FireControl.PMount(0f, 0f));
Assert.Equal(0f, FireControl.PMount(1f, -2f));
var previous = float.PositiveInfinity;
for (var omega = 0f; omega <= 5f; omega += .25f)
{
var p = FireControl.PMount(omega, 1.5f);
Assert.True(p < previous, $"PMount falls as the sweep rises: {p} after {previous} at {omega}");
previous = p;
}
}
// Ruling tracking-per-gun, "gun times ship": the rate is the gun's authored rate times the shooter's gear over the
// unaided baseline, and nothing else multiplies the two.
[Fact]
public void TrackingRateIsGunTimesShip()
{
var range = EvMountRange(40f, 3f);
var unaided = _items.GameplaySettings.UnaidedTracking;
foreach (var (weapon, shooter) in EvGuns(range))
{
var gear = FireControl.Tracking(shooter);
Assert.True(gear > unaided * 1.2f, $"fixture: the shooter's targeting gear tracks better than unaided ({gear} against {unaided})");
var expected = weapon.Tracking * gear / unaided;
Assert.InRange(FireControl.TrackingRate(weapon, shooter), expected * .999f, expected * 1.001f);
}
var with = EvGuns(range).Select(g => FireControl.TrackingRate(g.weapon, g.shooter)).ToArray();
// The gear off: both guns' rates fall by the same ratio, and the ratio between the guns does not move.
foreach (var (_, shooter) in EvGuns(range))
{
shooter.GetBehavior<TargetingSystem>().Item.EquippableItem.Durability = 0f;
shooter.Update(EvDt);
shooter.Update(EvDt);
Assert.Equal(unaided, FireControl.Tracking(shooter));
}
var without = EvGuns(range).Select(g => FireControl.TrackingRate(g.weapon, g.shooter)).ToArray();
Assert.Equal(40f, without[0], 3);
Assert.Equal(3f, without[1], 3);
Assert.Equal(with[0] / without[0], with[1] / without[1], 3);
Assert.True(with[0] / without[0] > 1.2f);
Assert.Equal(with[0] / with[1], without[0] / without[1], 3);
}
// Ruling weapon-tracking-authored, in play: a fast crosser close in is beyond a heavy gun and within a light one.
[Fact]
public void LightAndHeavyGunsSplitOnAFastCloseCrosser()
{
var range = EvMountRange(40f, 3f);
EvCross(range, jinking: false, float2(100, 0));
var unaided = _items.GameplaySettings.UnaidedTracking;
var omega = degrees(100f / 200f);
var mounts = new List<float>();
foreach (var (weapon, shooter) in EvGuns(range))
{
var d = FireControl.Inspect(weapon, shooter, range.Target);
var rate = weapon.Tracking * FireControl.Tracking(shooter) / unaided;
Assert.InRange(d.AngularVelocity, omega * .999f, omega * 1.001f);
Assert.InRange(d.TrackingRate, rate * .999f, rate * 1.001f);
Assert.Equal((float) Math.Pow(.5, Math.Pow(omega / rate, 2)), d.PMount, 4);
var price = FireControl.HitProbability(weapon, shooter, range.Target);
var withoutMount = d.PFire * d.PSpread * d.POnHull * d.PEvasion;
Assert.True(withoutMount > 0f, "fixture: the shot is priced without the mount");
Assert.InRange(price, withoutMount * d.PMount * .999f, withoutMount * d.PMount * 1.001f);
Assert.Equal(price, d.PBase, 6);
mounts.Add(d.PMount);
}
Assert.True(mounts[0] >= .85f, $"the light gun follows a fast close crosser: {mounts[0]}");
Assert.True(mounts[1] <= .01f, $"the heavy gun does not: {mounts[1]}");
}
// Ruling evasion-angular-velocity: size is not what dodges, the sweep is. A big ship at range sweeps slowly, and a
// big gun still follows it.
[Fact]
public void ABigGunHitsABigShipAtRange()
{
var range = EvMountRange(2f, 2f);
EvCross(range, jinking: false, float2(50, 0));
range.Target.Position = float3(0, range.Target.Position.y, 1000);
var d = FireControl.Inspect(range.Gun, range.Gunner, range.Target);
Assert.Equal(degrees(.05f), d.AngularVelocity, 2);
Assert.True(d.PMount >= .6f, $"a 2 degree per second gun against 50 m/s at 1000 m: {d.PMount} (omega {d.AngularVelocity}, rate {d.TrackingRate})");
}
// Ruling jink-per-ship: the changes-of-vector term is forgiven by the ship's gear alone, in metres, whatever the gun.
// The target flies straight along the line of sight after jinking (no sweep, PMount 1), so the evasion is all there is.
[Fact]
public void TheJinkIsStillForgivenPerShip()
{
var range = EvMountRange(40f, 3f);
EvCross(range, jinking: true, float2(0, -50));
foreach (var (weapon, shooter) in EvGuns(range))
{
var d = FireControl.Inspect(weapon, shooter, range.Target);
Assert.Equal(1f, d.PMount, 4);
Assert.True(d.Evasion > 0f, "fixture: the target jinks");
Assert.Equal(FireControl.PDeviation(d.Evasion, FireControl.Tracking(shooter)), d.PEvasion, 5);
var id = FireControl.Fire(weapon, weapon.Item, shooter);
Assert.True(id > 0, "fixture: the weapon fires");
var shot = range.Arena.PendingShots.Single(s => s.ShotId == id);
Assert.Equal(FireControl.Tracking(shooter), shot.Tracking);
}
}
// The mount factor is frozen at fire inside PFire, and the commit price is the live price.
[Fact]
public void AShotFreezesItsMount()
{
var range = EvMountRange(40f, 3f);
EvCross(range, jinking: false, float2(100, 0));
foreach (var (weapon, shooter) in EvGuns(range))
{
var d = FireControl.Inspect(weapon, shooter, range.Target);
var live = FireControl.HitProbability(weapon, shooter, range.Target);
Assert.True(d.PMount < 1f && d.PMount > 0f && d.PFire > 0f, "fixture: the mount prices the shot");
var id = FireControl.Fire(weapon, weapon.Item, shooter);
Assert.True(id > 0, "fixture: the weapon fires");
var shot = range.Arena.PendingShots.Single(s => s.ShotId == id);
Assert.InRange(shot.PFire, d.PFire * d.PMount * .999f, d.PFire * d.PMount * 1.001f);
var commit = FireControl.CommitProbability(shot, range.Arena.Time, out _);
Assert.InRange(commit, live * .999f, live * 1.001f);
// The target turns to fly straight along the line of sight: the live mount factor is now 1, the frozen one is not.
range.Target.Velocity = float2(0, 100);
Assert.Equal(1f, FireControl.Inspect(weapon, shooter, range.Target).PMount, 4);
shot = range.Arena.PendingShots.Single(s => s.ShotId == id);
Assert.InRange(shot.PFire, d.PFire * d.PMount * .999f, d.PFire * d.PMount * 1.001f);
Assert.InRange(FireControl.CommitProbability(shot, range.Arena.Time, out _), commit * .999f, commit * 1.001f);
range.Target.Velocity = float2(100, 0);
}
// A round fired at nothing carries no mount factor to freeze: it prices at nothing and nothing throws.
range.Gunner.SetTarget(TargetRef.None);
var untargeted = FireControl.Fire(range.Gun, range.Gun.Item, range.Gunner);
Assert.True(untargeted > 0, "fixture: the weapon fires at nothing");
Assert.Equal(0f, range.Arena.PendingShots.Single(s => s.ShotId == untargeted).PFire);
}
// A gun nobody has authored a rate for follows anything: +infinity, and PMount 1 exactly.
[Fact]
public void AnUnauthoredWeaponHasNoMountLimit()
{
var unauthored = new AutoWeaponData().Tracking;
Assert.True(float.IsPositiveInfinity(unauthored.Min) && float.IsPositiveInfinity(unauthored.Max), "a code-built weapon's rate is +infinity");
var range = EvMountRange(5f, 5f);
EvCross(range, jinking: false, float2(100, 0));
var limited = FireControl.HitProbability(range.Gun, range.Gunner, range.Target);
range.Gun.WeaponData.Tracking = unauthored;
range.Gunner.Update(EvDt);
range.Gunner.Update(EvDt);
Assert.True(float.IsPositiveInfinity(range.Gun.Tracking), $"it evaluates to +infinity: {range.Gun.Tracking}");
Assert.True(float.IsPositiveInfinity(FireControl.TrackingRate(range.Gun, range.Gunner)));
var d = FireControl.Inspect(range.Gun, range.Gunner, range.Target);
Assert.Equal(1f, d.PMount);
var price = FireControl.HitProbability(range.Gun, range.Gunner, range.Target);
var withoutMount = d.PFire * d.PSpread * d.POnHull * d.PEvasion;
Assert.InRange(price, withoutMount * .999f, withoutMount * 1.001f);
Assert.True(price > limited * 2f, $"the mount limit priced the limited gun down: {limited} against {price}");
}
}