diff --git a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs new file mode 100644 index 000000000..65ef6542b --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs @@ -0,0 +1,50 @@ +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// Fire mode is a design property (ruling fire-mode-is-the-design): a plain InstantWeapon is semi-automatic, and its cycle +// stays at a pace a finger can keep. A gun that cycles faster is an AutoWeapon. +public sealed class SemiAutoCatalogTests +{ + private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; + + private static GearData Gun(string name, InstantWeaponData data) => new GearData { Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, Behaviors = { data } }; + + [Fact] + public void TheShippedCatalogAndItsPackagesHoldNoFasterPlainGun() + { + var root = RunStartTests.FindRepoRoot(); + using var work = new TempDirectory(); + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Path.Combine(root, "GameData", "Aetheria.cc"), Path.Combine(work.Path, "derived.cc"), + Path.Combine(root, "GameData", "Mods"), TestCatalog.Registry()); + Assert.Empty(excluded); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog, true), AetheriaStores.CatalogTypes); + var gear = cache.GetAll().ToArray(); + Assert.Empty(InstantWeaponData.SemiAutoFaults(gear)); + // The check reads real guns, and the two automatic ones are AutoWeapons with their authored cadence. + foreach (var name in new[] { "Slug Rifle", "Long Slug Rifle" }) + Assert.IsType(gear.Single(g => g.Name == name).Behaviors.Single(b => b is InstantWeaponData)); + Assert.Contains(gear, g => g.Behaviors.Any(b => b.GetType() == typeof(InstantWeaponData))); + } + + [Fact] + public void ThePlainGunCheckRefusesAFastSemiAutoAndOnlyThat() + { + var floor = InstantWeaponData.ShortestSemiAutoCooldown; + var fast = Gun("Fast", new InstantWeaponData { Cooldown = Const(floor / 2) }); + var edge = Gun("Edge", new InstantWeaponData { Cooldown = Const(floor) }); + var auto = Gun("Auto", new AutoWeaponData { Cooldown = Const(.01f) }); + var wide = Gun("Wide", new InstantWeaponData { Cooldown = new PerformanceStat { Min = floor / 2, Max = floor * 2 } }); + var faults = InstantWeaponData.SemiAutoFaults(new[] { fast, edge, auto, wide }).ToArray(); + // The floor is 0.1 s: ten presses a second is allowed, twenty is not. + Assert.Equal(.1f, floor); + Assert.Single(InstantWeaponData.SemiAutoFaults(new[] { Gun("Twenty", new InstantWeaponData { Cooldown = Const(.05f) }) })); + Assert.Empty(InstantWeaponData.SemiAutoFaults(new[] { Gun("Ten", new InstantWeaponData { Cooldown = Const(.1f) }) })); + Assert.Equal(2, faults.Length); + Assert.Contains(faults, f => f.StartsWith("Fast:")); + Assert.Contains(faults, f => f.StartsWith("Wide:")); + } +} diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 9f2aab07f..e89f14c0d 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -41,11 +41,15 @@ public sealed partial class ContactRevealTests private static readonly float[] Cooldowns = { .25f, .13f }; // A gun of its own cooldown, bottomless magazine, so only the cooldown paces it. Upserted by name into the open catalog. - private string GunWithCooldown(float cooldown) + // Automatic by default, as a gun that holds is; a semi-automatic one is the plain InstantWeaponData. + private string GunWithCooldown(float cooldown, bool auto = true) { - var name = $"Gun@{cooldown:R}"; + var name = $"{(auto ? "Auto" : "Gun")}@{cooldown:R}"; if (_openCache.GetByName(name) != null) return name; - var gun = Armed(new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) }, 0); + InstantWeaponData data = auto + ? new AutoWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) } + : new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) }; + var gun = Armed(data, 0); gun.Energy = Const(1); // the fixture reactor pays a round a hundredth of a second apart; the rounds-per-step tests top up nothing _openCache.Upsert(new WeaponItemData { @@ -307,72 +311,109 @@ public sealed partial class ContactRevealTests private InputCapacitor CapacitorOf(InstantWeapon gun) => (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); - // A press that is let go before the step runs fires one burst, whatever the cooldown and the tick: the trigger is the - // weapon's state, not a pulse once per step. The press goes through the pilot's command (the player's action bar and the - // agents' Combat both do) and straight to the weapon (a turret does). + // A press that is let go before the step runs fires one burst, whatever the cooldown, the tick and the kind of gun: the + // trigger is the weapon's state, not a pulse once per step. The press goes through the pilot's command (the player's + // action bar and the agents' Combat both do) and straight to the weapon (a turret does). [Fact] public void ATapFiresOneBurstAtEveryRate() { - foreach (var cooldown in new[] { .01f, .025f, .1f }) - foreach (var viaCommand in new[] { false, true }) + foreach (var auto in new[] { false, true }) + foreach (var cooldown in auto ? new[] { .01f, .025f, .1f } : new[] { .1f, .25f }) + foreach (var viaCommand in new[] { false, true }) + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown, auto) }, new[] { "Targeting", "Reactor" }); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var capacitor = CapacitorOf(gun); + var rounds = 0; + gun.OnFire += _ => rounds++; + ship.SetTarget(TargetRef.None); + capacitor.AddStepCharge(1000f, dt); + if (viaCommand) + { + ship.Command.Pull(gun); + ship.Command.Release(gun); + } + else + { + gun.Activate(); + gun.Deactivate(); + } + for (var step = 0; step < (int) MathF.Round(1f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + Assert.True(rounds == 1, $"auto {auto} cooldown {cooldown} at dt {dt}, command {viaCommand}: {rounds} rounds from one tap, expected 1"); + } + } + + // A trigger pressed once and held: an AutoWeapon delivers the authored cadence (the rounds of ten seconds are the same at + // 15, 30 and 60 Hz) and stops when the trigger is let go; a plain gun is semi-automatic and fires its one burst however + // long the key stays down. + [Fact] + public void AHeldTriggerFiresPerTheKindOfGun() + { + foreach (var auto in new[] { false, true }) + foreach (var cooldown in auto ? new[] { .01f, .1f } : new[] { .1f, .25f }) foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) { - var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" }); + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown, auto) }, new[] { "Targeting", "Reactor" }); var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); var capacitor = CapacitorOf(gun); var rounds = 0; gun.OnFire += _ => rounds++; ship.SetTarget(TargetRef.None); capacitor.AddStepCharge(1000f, dt); - if (viaCommand) + gun.Activate(); + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) { - ship.Command.Pull(gun); - ship.Command.Release(gun); - } - else - { - gun.Activate(); - gun.Deactivate(); + capacitor.AddStepCharge(1000f, dt); + Tick(dt); } + var expected = auto ? 10f / cooldown : 1f; + Assert.True(abs(rounds - expected) <= (auto ? 1.01f : 0f), $"auto {auto} cooldown {cooldown} at dt {dt}: {rounds} rounds held 10 s, expected {expected}"); + gun.Deactivate(); + var held = rounds; for (var step = 0; step < (int) MathF.Round(1f / dt); step++) { capacitor.AddStepCharge(1000f, dt); Tick(dt); } - Assert.True(rounds == 1, $"cooldown {cooldown} at dt {dt}, command {viaCommand}: {rounds} rounds from one tap, expected 1"); + Assert.True(rounds - held <= 1, $"auto {auto} cooldown {cooldown} at dt {dt}: {rounds - held} rounds after the trigger was let go"); } } - // A trigger pulled once and held, as the player's key and a turret's pull are, delivers the authored cadence: the rounds - // of ten seconds are the same at 15, 30 and 60 Hz, and the weapon stops when the trigger is let go. + // An agent's Combat pulls every weapon it means to fire each step. A plain gun it taps: each pull is a press, and the gun + // fires when it is ready, its authored cadence on average at any tick. An AutoWeapon it holds: the pulls land on a + // trigger already down. Both deliver the cadence; the plain one stops when the pulls stop, with no release needed. [Fact] - public void AHeldTriggerFiresTheAuthoredCadenceAtEveryRate() + public void CombatTapsAPlainGunAndHoldsAnAutoWeapon() { - foreach (var cooldown in new[] { .01f, .1f }) + foreach (var auto in new[] { false, true }) foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) { - var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" }); + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(.1f, auto) }, new[] { "Targeting", "Reactor" }); var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); var capacitor = CapacitorOf(gun); var rounds = 0; gun.OnFire += _ => rounds++; ship.SetTarget(TargetRef.None); - capacitor.AddStepCharge(1000f, dt); - gun.Activate(); for (var step = 0; step < (int) MathF.Round(10f / dt); step++) { capacitor.AddStepCharge(1000f, dt); + ship.Command.Pull(gun); Tick(dt); } - Assert.True(abs(rounds - 10f / cooldown) <= 1.01f, $"cooldown {cooldown} at dt {dt}: {rounds} rounds held 10 s, expected {10f / cooldown} +-1"); - gun.Deactivate(); - var held = rounds; + Assert.True(abs(rounds - 100f) <= 1.01f, $"auto {auto} at dt {dt}: {rounds} rounds pulled each step for 10 s, expected 100 +-1"); + var pulled = rounds; for (var step = 0; step < (int) MathF.Round(1f / dt); step++) { capacitor.AddStepCharge(1000f, dt); + if (auto) ship.Command.Release(gun); Tick(dt); } - Assert.True(rounds - held <= 1, $"cooldown {cooldown} at dt {dt}: {rounds - held} rounds after the trigger was let go"); + Assert.True(rounds - pulled <= 1, $"auto {auto} at dt {dt}: {rounds - pulled} rounds after the pulls stopped"); } }