diff --git a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs new file mode 100644 index 000000000..65f5062d5 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs @@ -0,0 +1,36 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class AiFusedDiscipline : Scenario +{ + public override string Name => "AI Fused Discipline"; + public override string Brief => "A Djinni; a piloted neutral Longinus carrying a Smoke Proximity and a piloted hostile Longinus whose only weapon is " + + "Smoke Refused. Verify: the hostile closes on you and never fires its refused weapon; the neutral never fires its " + + "fused one."; + public override uint Seed => 110; + public override bool Ambient => false; + + public static readonly CultMath.float2 Hostile = float2(0, 700), Neutral = float2(500, 300); + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast); + + public override void Stage(ScenarioStage stage) + { + ScenarioFit Armed(string weapon) => stage.Fit("Longinus", + ("Cockpit 2x2", int2(2, 6), None), + ("Large Drive", int2(1, 0), Reversed), + ("Large Drive", int2(3, 0), Reversed), + ("Talaria", int2(2, 14), CounterClockwise), + ("Talaria", int2(3, 14), Clockwise), + ("Core Power", int2(2, 4), None), + (weapon, int2(1, 8), None), + ("Iapyx", int2(2, 2), CounterClockwise), + ("Iapyx", int2(3, 2), Clockwise), + ("not if i see you first", int2(3, 10), None), + ("Fire Control Array", int2(1, 6), Clockwise)); + + stage.Player(stage.Generated("Djinni"), float2(0, 0)); + stage.Place(Armed("Smoke Refused"), Hostile, facing: float2(0, -1), stance: ScenarioStance.Hostile, piloted: true); + stage.Place(Armed("Smoke Proximity"), Neutral, facing: float2(-1, 0), piloted: true); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs.meta b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs.meta new file mode 100644 index 000000000..5994a43a7 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0d71dbf0ab5847a6ab58f96a50678ac7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs new file mode 100644 index 000000000..e0a8c029f --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs @@ -0,0 +1,51 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class FusedRounds : Scenario +{ + public override string Name => "Fused Rounds"; + public override string Brief => "The Smoke Test Hull with Smoke Proximity, Smoke Delayed and a DeathCluster; a hostile bare Longinus at mid range, a bare " + + "hull dead ahead, a fragile one to starboard, a close one inside the proximity blast radius, and one astern. " + + "Verify with nothing selected: aim past every hull and the round bursts at max range along the aim; aim at the hull " + + "ahead and it stops and bursts on it; fire at the fragile hull and kill it with the DeathCluster before the round " + + "lands: no burst. Select the astern hull: the round fires and bursts at its range along the arc edge. Select the " + + "close hull: the burst is pushed out to the blast radius from your centre. A Smoke Proximity burst beside the " + + "Longinus damages where the model shows the blast; a Smoke Delayed round into its nose bursts inside. The HUD " + + "reads Direct, Burst at N m or Refused for the fused weapons and a percentage for the DeathCluster, and the " + + "smoke weapons show no Manufacturer row."; + public override uint Seed => 108; + public override bool Ambient => false; + + // Where everything sits: the player is at the origin facing +z. Ahead lies on the initial aim line, inside every forward + // mount's arc. Fragile is a bare Longinus inside the DeathCluster's range; Close is inside Smoke Proximity's blast radius + // (8 m from the ship's centre) and beside the aim line, not across it; Stern is outside every mount's arc. + public static readonly CultMath.float2 Mid = float2(-200, 600), Ahead = float2(0, 300), Fragile = float2(140, 420), + Close = float2(6.4f, 4f), Stern = float2(0, -400); + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast); + + public override void Stage(ScenarioStage stage) + { + var shooter = stage.Fit("Smoke Test Hull", + ("Cockpit 2x2", int2(4, 6), None), + ("Large Drive", int2(3, 0), Reversed), + ("Large Drive", int2(5, 0), Reversed), + ("Talaria", int2(4, 14), CounterClockwise), + ("Talaria", int2(5, 14), Clockwise), + ("Core Power", int2(4, 4), None), + ("Smoke Proximity", int2(3, 8), None), + ("Smoke Delayed", int2(6, 8), None), + ("DeathCluster", int2(5, 17), None), + ("Iapyx", int2(4, 2), CounterClockwise), + ("Iapyx", int2(5, 2), Clockwise), + ("not if i see you first", int2(5, 10), None), + ("Fire Control Array", int2(3, 6), Clockwise)); + + stage.Player(shooter, float2(0, 0), facing: float2(0, 1)); + stage.Place(stage.Bare("Longinus"), Mid, stance: ScenarioStance.Hostile); + stage.Place(stage.Bare("Longinus"), Ahead); + stage.Place(stage.Bare("Longinus"), Fragile); + stage.Place(stage.Bare("Longinus"), Close); + stage.Place(stage.Bare("Longinus"), Stern); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs.meta b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs.meta new file mode 100644 index 000000000..0e96bab70 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4afe22415e8546849f2366c39a40fd8f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs new file mode 100644 index 000000000..5d77cb466 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs @@ -0,0 +1,40 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class RefusedRounds : Scenario +{ + public override string Name => "Refused Rounds"; + public override string Brief => "The Smoke Test Hull with Smoke Refused, Smoke Refused Beam, Smoke Refused Charge and an Autocannon; a bare Longinus " + + "dead ahead. Each smoke weapon's blast radius is wider than its range, so it can never be fired. Verify: pull each " + + "trigger and a refused shot costs no ammo, energy, heat, sound, visibility or cooldown, the beam never starts and " + + "the charged weapon never starts charging; the Autocannon fires normally."; + public override uint Seed => 109; + public override bool Ambient => false; + + // The one target, ahead of the nose: inside every forward mount's arc. + public static readonly CultMath.float2 Target = float2(0, 300); + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast); + + public override void Stage(ScenarioStage stage) + { + var shooter = stage.Fit("Smoke Test Hull", + ("Cockpit 2x2", int2(4, 6), None), + ("Large Drive", int2(3, 0), Reversed), + ("Large Drive", int2(5, 0), Reversed), + ("Talaria", int2(4, 14), CounterClockwise), + ("Talaria", int2(5, 14), Clockwise), + ("Core Power", int2(4, 4), None), + ("Smoke Refused", int2(3, 8), None), + ("Smoke Refused Beam", int2(6, 8), None), + ("Smoke Refused Charge", int2(3, 17), None), + ("Autocannon", int2(5, 17), None), + ("Iapyx", int2(4, 2), CounterClockwise), + ("Iapyx", int2(5, 2), Clockwise), + ("not if i see you first", int2(5, 10), None), + ("Fire Control Array", int2(3, 6), Clockwise)); + + stage.Player(shooter, float2(0, 0), facing: float2(0, 1)); + stage.Place(stage.Bare("Longinus"), Target); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs.meta b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs.meta new file mode 100644 index 000000000..45c0cde82 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c72528ae3b7144989483462543f2de88 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs index 9022f53ee..5f32f2e83 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs @@ -9,6 +9,6 @@ public static class Scenarios public static readonly Scenario[] Tests = { - new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new WeaponFeel(), new LongHaul(), new Pursuit(), new Crossing() + new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new WeaponFeel(), new FusedRounds(), new RefusedRounds(), new AiFusedDiscipline(), new LongHaul(), new Pursuit(), new Crossing() }; } diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 4452fe8c0..3a467909f 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3497996d4601a12a73a32f6888d699a5685128fcab2223b9e0519f96ae2dfd6c -size 12723747 +oid sha256:a44ecdd4d4b470cc67534dd21234da41096ed2d246927be486d45c240164d790 +size 12728634 diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs new file mode 100644 index 000000000..bbf5385ef --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs @@ -0,0 +1,161 @@ +using System.Linq; +using Xunit; +using static CultMath.math; + +// aetheria-release, cut scenarios-smoke-rounds: what the Fused Rounds, Refused Rounds and AI Fused Discipline arenas stage, +// read from the launched run, the shipped catalog and the production fire solution (FireControl.Solve) the HUD and the +// weapons both ask. +public sealed partial class RunStartTests +{ + private static readonly string[] SmokeWeapons = { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam", "Smoke Refused Charge" }; + + private static EquippedItem Carried(Entity ship, string design) => Assert.Single(ship.Equipment, item => item.Data.Name == design); + + private static Weapon WeaponOf(EquippedItem mount) => mount.Behaviors.OfType().Single(); + + private static FireOutcome OutcomeOn(Entity shooter, EquippedItem mount, Entity target) => FireControl.Solve(WeaponOf(mount), mount, shooter, target).Outcome; + + // Fused Rounds: the player carries Smoke Proximity, Smoke Delayed and a direct weapon with nothing selected; a bare hull + // lies within the arc on the initial aim line; the close hull is nearer than Smoke Proximity's blast radius; the stern + // target is outside every mount's arc; the direct weapon can kill the fragile hull (damage x shots in its magazine above + // its hull). The smoke weapons are unbranded and the fused weapons read Burst where the direct one reads Direct. + [Fact] + public void FusedRoundsConditions() + { + var (_, _, staged, failures) = Launch(new FusedRounds(), Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + var player = staged.Player; + player.GenerateWeaponGroups(); + EvSettle(player); // a weapon's Range is read once the ship's stats have settled; the geometry is as staged + Assert.Equal(5, staged.Entities.Count); + var (mid, ahead, fragile, close, stern) = (staged.Entities[0], staged.Entities[1], staged.Entities[2], staged.Entities[3], staged.Entities[4]); + Assert.Equal((FusedRounds.Mid, FusedRounds.Ahead, FusedRounds.Fragile, FusedRounds.Close, FusedRounds.Stern), + (mid.Position.xz, ahead.Position.xz, fragile.Position.xz, close.Position.xz, stern.Position.xz)); + + var proximity = Carried(player, "Smoke Proximity"); + var delayed = Carried(player, "Smoke Delayed"); + var direct = Carried(player, "DeathCluster"); + Assert.Null(player.Target.Value.Entity); + + // The bare hull is on the initial aim line, ahead, and inside every weapon mount's arc. + var aim = player.Direction; + Assert.True(abs(aim.x * ahead.Position.z - aim.y * ahead.Position.x) < 1e-3f && dot(aim, ahead.Position.xz) > 0, "the bare hull is not on the player's initial aim line"); + foreach (var mount in WeaponMounts(player)) + Assert.True(Bears(mount, player, ahead), $"{mount.Data.Name} cannot bear on the bare hull ahead"); + + // The stern target is outside every mount's arc. + foreach (var mount in WeaponMounts(player)) + Assert.False(Bears(mount, player, stern), $"{mount.Data.Name} bears on the stern hull"); + + // The close hull is nearer than Smoke Proximity's blast radius, measured from the ship's centre, and inside its arc. + Assert.NotNull(FireControl.FuseOf(proximity, out var blast)); + Assert.True(length((close.Position - player.Position).xz) < blast, "the close hull is not inside Smoke Proximity's blast radius"); + Assert.True(Bears(proximity, player, close), "Smoke Proximity cannot bear on the close hull"); + + // The fragile and hostile hulls are reachable by the fused weapons, the fragile one by the direct weapon too, and the + // direct weapon's magazine holds enough damage to kill the fragile hull. + foreach (var target in new[] { mid, fragile }) + { + Assert.True(Bears(proximity, player, target) && Bears(delayed, player, target), "a fused weapon cannot bear on a target hull"); + Assert.True(length((target.Position - player.Position).xz) <= WeaponOf(proximity).Range, "a target hull lies beyond Smoke Proximity's range"); + } + var directWeapon = WeaponOf(direct); + Assert.True(Bears(direct, player, fragile), "the direct weapon cannot bear on the fragile hull"); + Assert.True(length((fragile.Position - player.Position).xz) <= directWeapon.Range, "the fragile hull lies beyond the direct weapon's range"); + var magazine = direct.Data.Behaviors.OfType().Single().MagazineSize; + Assert.True(magazine > 0 && directWeapon.Damage * magazine > ((HullData) _items.GetData(fragile.Hull)).Durability, + "the direct weapon cannot kill the fragile hull from its magazine"); + + // Only the Longinus at mid range is hostile. + Assert.True(player.IsHostileTo(mid) && mid.IsHostileTo(player), "the mid-range hull is hostile"); + Assert.False(new[] { ahead, fragile, close, stern }.Any(entity => player.IsHostileTo(entity)), "a bare hull is hostile"); + + // What the HUD forecasts: fused weapons burst, the direct weapon resolves against its target. + Assert.Equal((FireOutcome.Burst, FireOutcome.Burst, FireOutcome.Direct), + (OutcomeOn(player, proximity, ahead), OutcomeOn(player, delayed, ahead), OutcomeOn(player, direct, ahead))); + + // The smoke weapons carry no manufacturer and no product, so the properties panel shows no Manufacturer row. + foreach (var mount in new[] { proximity, delayed }) + { + var (maker, product) = _items.Brand((CraftedItemInstance) mount.EquippableItem); + Assert.Null(maker); + Assert.Null(product); + } + } + + // Refused Rounds: Smoke Refused, Smoke Refused Beam and Smoke Refused Charge, each with a Range below its BlastRadius, and one + // working weapon. AI Fused Discipline: a piloted neutral carrying a fused weapon and a piloted hostile whose only weapon is + // refused. + [Fact] + public void RefusedAndAiConditions() + { + var (_, _, rounds, roundsFailures) = Launch(new RefusedRounds(), Inputs(() => GalaxySeed)); + Assert.True(roundsFailures.Count == 0, string.Join("; ", roundsFailures)); + var player = rounds.Player; + player.GenerateWeaponGroups(); + EvSettle(player); + var target = Assert.Single(rounds.Entities); + Assert.Equal(4, WeaponMounts(player).Count); + + foreach (var design in SmokeWeapons.Skip(2)) + { + var mount = Carried(player, design); + var weapon = WeaponOf(mount); + Assert.NotNull(FireControl.FuseOf(mount, out var blast)); + Assert.True(weapon.Range > 0f, $"{design}'s range has not settled"); + Assert.True(weapon.Range < blast, $"{design}'s range is not below its blast radius"); + Assert.True(Bears(mount, player, target), $"{design} cannot bear on the bare hull"); + Assert.Equal(FireOutcome.Refused, OutcomeOn(player, mount, target)); + } + Assert.IsType(WeaponOf(Carried(player, "Smoke Refused Beam"))); + Assert.IsType(WeaponOf(Carried(player, "Smoke Refused Charge"))); + var working = Carried(player, "Autocannon"); + Assert.True(Bears(working, player, target) && length((target.Position - player.Position).xz) <= WeaponOf(working).Range, "the working weapon cannot reach the bare hull"); + Assert.Equal(FireOutcome.Direct, OutcomeOn(player, working, target)); + + var (_, arena, ai, aiFailures) = Launch(new AiFusedDiscipline(), Inputs(() => GalaxySeed)); + Assert.True(aiFailures.Count == 0, string.Join("; ", aiFailures)); + Assert.Equal(2, ai.Entities.Count); + var (hostile, neutral) = (ai.Entities[0], ai.Entities[1]); + Assert.True(ai.Player.IsHostileTo(hostile) && hostile.IsHostileTo(ai.Player), "the refused-weapon ship is hostile"); + Assert.False(ai.Player.IsHostileTo(neutral) || neutral.IsHostileTo(ai.Player), "the fused-weapon ship is neutral"); + Assert.Equal((1, 1), (AgentsOf(arena, hostile), AgentsOf(arena, neutral))); + Assert.Equal("Djinni", _items.GetData(ai.Player.Hull).Name); + + EvSettle((Ship) hostile); + EvSettle((Ship) neutral); + var refused = Assert.Single(WeaponMounts(hostile)); + Assert.Equal("Smoke Refused", refused.Data.Name); + Assert.True(WeaponOf(refused).Range > 0f, "the hostile's refused weapon range has not settled"); + Assert.Equal(FireOutcome.Refused, OutcomeOn(hostile, refused, ai.Player)); + var fused = Assert.Single(WeaponMounts(neutral)); + Assert.Equal("Smoke Proximity", fused.Data.Name); + Assert.NotNull(FireControl.FuseOf(fused, out _)); + Assert.Equal(FireOutcome.Burst, OutcomeOn(neutral, fused, ai.Player)); + } + + // The five smoke weapons have no product, so nothing offers them: no product names one, the charged one has no home on any + // hull a product sells, and no generated ship carries one. + [Fact] + public void SmokeWeaponsAreNeverOffered() + { + var designs = _cache.GetAll().Where(design => SmokeWeapons.Contains(design.Name)).ToArray(); + Assert.Equal(SmokeWeapons.OrderBy(name => name), designs.Select(design => design.Name).OrderBy(name => name)); + var keys = designs.Select(design => _cache.RefOf(design).Key).ToArray(); + Assert.DoesNotContain(_cache.GetAll(), product => keys.Any(key => product.Design.Key.Equals(key))); + Assert.False(LoadoutGenerator.HasHome(designs.Single(design => design.Name == "Smoke Refused Charge"), LoadoutGenerator.SoldHulls(_cache))); + + var arena = Arena(false); + var generator = new LoadoutGenerator(ref _items.Random, _items, arena.Galaxy, arena.GalaxyZone, null, 2); + var carried = 0; + for (var i = 0; i < 100; i++) + { + var pack = generator.GenerateShipLoadout(); + Assert.NotNull(pack); + var ship = EntitySerializer.Unpack(_items, arena, pack); + carried += ship.Equipment.Count(); + Assert.DoesNotContain(ship.Equipment, item => SmokeWeapons.Contains(item.Data.Name)); + } + Assert.True(carried > 100, "the generated ships carried no gear to check"); + } +}