- Delete ABigGunHitsABigShipAtRange and TheCatalogMeetsTheRuledOutcomes (rulings cshot-rainbowlite-rate, evasion-angular-velocity-r2). - TheJinkIsForgivenPerShipInThePrice: HitProbability of two guns of different rates carries PDeviation(Evasion, Tracking(shooter)). - TrackingRate: an unlimited gun is +infinity whatever the gear reads, not inf * 0 = NaN. - EveryAggregateAndTheCompensationReadLiveThrust: trim membership read from the thrusters' torques, not Ship's private list. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
326 lines
18 KiB
C#
326 lines
18 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 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);
|
|
}
|
|
}
|
|
|
|
// Finding jink-price-unpinned, ruling jink-per-ship at the price layer: with the target flying straight at the shooter (no
|
|
// sweep, PMount 1) the live price of two guns of different rates carries the same jink factor, PDeviation(Evasion,
|
|
// Tracking(shooter)), whatever their rates. The prices differ by the guns' own spread and fire factors; the jink does not.
|
|
[Fact]
|
|
public void TheJinkIsForgivenPerShipInThePrice()
|
|
{
|
|
var range = EvMountRange(40f, 3f);
|
|
EvCross(range, jinking: true, float2(0, -50));
|
|
var guns = EvGuns(range);
|
|
Assert.NotEqual(guns[0].weapon.Tracking, guns[1].weapon.Tracking);
|
|
var factors = new List<float>();
|
|
foreach (var (weapon, shooter) in guns)
|
|
{
|
|
var d = FireControl.Inspect(weapon, shooter, range.Target);
|
|
Assert.Equal(1f, d.PMount, 4);
|
|
var forgiven = FireControl.PDeviation(d.Evasion, FireControl.Tracking(shooter));
|
|
Assert.True(forgiven > .01f && forgiven < .99f, $"fixture: the jink prices between nothing and everything: {forgiven}");
|
|
var shared = d.PFire * d.PMount * d.PSpread * d.POnHull;
|
|
Assert.True(shared > 0f, "fixture: the shared factors price the shot");
|
|
var price = FireControl.HitProbability(weapon, shooter, range.Target);
|
|
Assert.InRange(price, shared * forgiven * .999f, shared * forgiven * 1.001f);
|
|
factors.Add(price / shared);
|
|
}
|
|
Assert.InRange(factors[0], factors[1] * .999f, factors[1] * 1.001f);
|
|
}
|
|
|
|
// Finding zero-gear-nan: a gun with no authored limit follows anything, whatever the gear reads. Infinity times a gear
|
|
// that reads zero (active, not yet executed) is still infinity, so the price stays a number; a limited gun on that gear
|
|
// follows nothing that moves (rate 0). Both keep the ship's gear as the other factor of the product (tracking-per-gun).
|
|
[Fact]
|
|
public void AnUnlimitedGunFollowsAnythingOnGearThatReadsZero()
|
|
{
|
|
var range = EvMountRange(float.PositiveInfinity, 3f);
|
|
EvCross(range, jinking: false, float2(100, 0));
|
|
var gear = range.Gunner.GetBehaviors<TargetingSystem>().First(t => t.Item.Active.Value);
|
|
Assert.True(FireControl.Tracking(range.Gunner) > 0f, "fixture: the gear reads a positive Tracking before it is zeroed");
|
|
((TargetingSystemData) gear.Data).Tracking = EvRate(0f);
|
|
range.Gunner.Update(EvDt);
|
|
range.Gunner.Update(EvDt);
|
|
Assert.Equal(0f, FireControl.Tracking(range.Gunner));
|
|
Assert.True(float.IsPositiveInfinity(FireControl.TrackingRate(range.Gun, range.Gunner)), "an unlimited gun's rate is +infinity on any gear");
|
|
Assert.Equal(1f, FireControl.Inspect(range.Gun, range.Gunner, range.Target).PMount);
|
|
Assert.False(float.IsNaN(FireControl.HitProbability(range.Gun, range.Gunner, range.Target)));
|
|
Assert.Equal(0f, FireControl.TrackingRate(range.GunnerLaser, range.Gunner));
|
|
Assert.Equal(0f, FireControl.Inspect(range.GunnerLaser, range.Gunner, range.Target).PMount);
|
|
}
|
|
|
|
// 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}");
|
|
}
|
|
|
|
// The shipped catalog's weapon behaviours: launchers and guided weapons never mount-track, every other gun is a mount.
|
|
private static bool EvMountless(WeaponData data) => data is LauncherData || data is GuidedWeaponData;
|
|
|
|
private IEnumerable<(WeaponItemData item, WeaponData data)> EvCatalogGuns() =>
|
|
_cache.GetAll<WeaponItemData>().SelectMany(item => item.Behaviors.OfType<WeaponData>().Select(data => (item, data)));
|
|
|
|
// Data check, not a behaviour test: it reads the migrated catalog. Pins finiteness and the authored shape of the
|
|
// value (Min = Max > 0), never a value (ruling weapon-tracking-authored).
|
|
[Fact]
|
|
public void EveryCatalogGunTracks()
|
|
{
|
|
var guns = EvCatalogGuns().ToArray();
|
|
Assert.NotEmpty(guns);
|
|
foreach (var (item, data) in guns)
|
|
{
|
|
var rate = data.Tracking;
|
|
if (EvMountless(data))
|
|
Assert.True(float.IsPositiveInfinity(rate.Min) && float.IsPositiveInfinity(rate.Max), $"{item.Name} is a launcher or guided weapon and tracks without a mount limit");
|
|
else
|
|
{
|
|
Assert.True(!float.IsInfinity(rate.Min) && !float.IsNaN(rate.Min), $"{item.Name} has a finite rate, not {rate.Min}");
|
|
Assert.Equal(rate.Min, rate.Max);
|
|
Assert.True(rate.Min > 0f, $"{item.Name} has a positive rate, not {rate.Min}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Data check, not a behaviour test: ruling plight-shape, plight is one wide, two high and fits a Longinus Energy hardpoint.
|
|
[Fact]
|
|
public void PlightHasItsRuledShape()
|
|
{
|
|
var plight = _cache.GetAll<WeaponItemData>().Single(w => w.Name == "plight");
|
|
Assert.Equal(1, plight.Shape.Width);
|
|
Assert.Equal(2, plight.Shape.Height);
|
|
Assert.True(plight.Shape.Cells[0, 0] && plight.Shape.Cells[0, 1], "both cells are occupied");
|
|
var longinus = _cache.GetAll<HullData>().Single(h => h.Name == "Longinus");
|
|
Assert.Contains(longinus.Hardpoints, hardpoint => hardpoint.Type == HardpointType.Energy && hardpoint.Takes(plight));
|
|
}
|
|
}
|