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 559ec769f..a201fc6b9 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 Belt() + new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new WeaponFeel(), new FusedRounds(), new RefusedRounds(), new AiFusedDiscipline(), new LongHaul(), new Pursuit(), new Crossing(), new Belt() }; } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 03651b19f..a1d5445e8 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -2580,7 +2580,7 @@ public sealed partial class FireControlCut124Tests : IDisposable return cache; } - // Every WeaponItemData in the shipped catalog has a null BlastRadius and a null Fuse, and the weapon + // Every WeaponItemData a product sells in the shipped catalog has a null BlastRadius and a null Fuse, and the weapon // schema's own migration report is Exact. (Until the scenarios product-gap pass rewrote the catalog on // 2026-09-30 it was CompatibleDrift with only slot 32 defaulted: the file predated BlastRadius and Fuse.) [Fact] @@ -2593,7 +2593,9 @@ public sealed partial class FireControlCut124Tests : IDisposable var weapons = cache.GetAll().ToList(); Assert.NotEmpty(weapons); // A mine layer is the one shipped weapon that carries a blast: its radius is the mine's (MineLauncherCatalogTests). - foreach (var w in weapons.Where(w => !w.Behaviors.OfType().Any())) + // The product-less smoke weapons are the fused designs scenarios fit; only what a product sells is neutral. + var sold = cache.GetAll().Select(product => product.Design.Key).ToHashSet(); + foreach (var w in weapons.Where(w => !w.Behaviors.OfType().Any() && sold.Contains(cache.RefOf(w).Key))) { Assert.Null(w.BlastRadius); Assert.Null(w.Fuse); diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs new file mode 100644 index 000000000..7dd04c2f1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; + +// AetherDb's smoke-rounds command (aetheria-release, cut scenarios-smoke-rounds), run on a scratch copy of the shipped catalog +// with the five smoke weapons removed from it. +public sealed class SmokeRoundsCommandTests : IDisposable +{ + private static readonly string[] Names = { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam", "Smoke Refused Charge" }; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "smoke-rounds-command-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + + // The five designs as the shipped catalog holds them, taken before they are removed from the scratch copy: apply must write + // exactly these, so the command stays the replay of what ships. + private readonly Dictionary _shipped = new Dictionary(); + + private static byte[] Pack(WeaponItemData design) => + MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(WeaponItemData).Assembly)); + + public SmokeRoundsCommandTests() + { + Directory.CreateDirectory(Path.Combine(_root, "GameData")); + Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources")); + var shipped = AetherDb.FindRoot(); + File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath); + File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset")); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + foreach (var weapon in cache.GetAll().Where(w => Names.Contains(w.Name)).ToArray()) + { + _shipped[weapon.Name] = Pack(weapon); + Assert.True(cache.Remove(cache.RefOf(weapon).Key)); + } + Assert.Equal(Names.OrderBy(n => n), _shipped.Keys.OrderBy(n => n)); + cache.FlushAsync().Wait(); + } + + public void Dispose() => Directory.Delete(_root, true); + + private (WeaponItemData[] Smoke, WeaponItemData Spectra, WeaponItemData ChargeBlast, int Products) Read() + { + var db = AetherDb.Open(root: _root); + try + { + var smoke = db.Cache.GetAll().Where(w => Names.Contains(w.Name)).ToArray(); + var keys = smoke.Select(w => db.Cache.RefOf(w).Key).ToArray(); + var products = db.Cache.GetAll().Count(p => keys.Any(k => p.Design.Key.Equals(k))); + return (smoke, db.Cache.GetAll().Single(w => w.Name == "Spectra"), db.Cache.GetAll().Single(w => w.Name == "ChargeBlast SG"), products); + } + finally { db.Cache.Dispose(); } + } + + [Fact] + public void A_dry_run_writes_nothing_and_apply_lands_five_productless_designs_once() + { + var before = File.ReadAllBytes(CatalogPath); + Assert.Empty(Read().Smoke); + + Assert.Equal(0, Program.SmokeRoundsCatalog(apply: false, root: _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + Assert.Empty(Read().Smoke); + + Assert.Equal(0, Program.SmokeRoundsCatalog(apply: true, root: _root)); + var landed = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(before, landed); + var (smoke, spectra, chargeBlast, products) = Read(); + Assert.Equal(Names.OrderBy(n => n), smoke.Select(w => w.Name).OrderBy(n => n)); + Assert.Equal(0, products); + + // Replaying the command on the catalog without them writes the records the shipped catalog holds (the record keys are + // CultCache's to mint and differ run to run; the designs do not). + foreach (var design in smoke) Assert.True(_shipped[design.Name].AsSpan().SequenceEqual(Pack(design)), $"{design.Name} differs from the shipped catalog's record"); + + WeaponItemData Design(string name) => smoke.Single(w => w.Name == name); + WeaponData Data(string name) => Design(name).Behaviors.OfType().Single(); + + // The operator's own values: the fuse and blast of each, Smoke Delayed's two cells of penetration, Smoke Refused's range of 20. + Assert.Equal((WeaponFuse.Proximity, 8f), (Design("Smoke Proximity").Fuse.Value, Design("Smoke Proximity").BlastRadius.Value)); + Assert.Equal((WeaponFuse.Delayed, 4f, 2f, 2f), (Design("Smoke Delayed").Fuse.Value, Design("Smoke Delayed").BlastRadius.Value, Data("Smoke Delayed").Penetration.Min, Data("Smoke Delayed").Penetration.Max)); + Assert.Equal((WeaponFuse.Proximity, 30f, 20f, 20f), (Design("Smoke Refused").Fuse.Value, Design("Smoke Refused").BlastRadius.Value, Data("Smoke Refused").Range.Min, Data("Smoke Refused").Range.Max)); + + // Spectra clones are energy, 1x2, with the Spectra's own gear; the charged one is a ChargeBlast SG clone on its 2x2 energy mount. + foreach (var name in new[] { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam" }) + Assert.Equal((spectra.Hardpoint, spectra.Shape.Width, spectra.Shape.Height, spectra.Mass), (Design(name).Hardpoint, Design(name).Shape.Width, Design(name).Shape.Height, Design(name).Mass)); + Assert.Equal((chargeBlast.Hardpoint, chargeBlast.Shape.Width, chargeBlast.Shape.Height), (Design("Smoke Refused Charge").Hardpoint, 2, 2)); + + // The beam is a constant weapon and the charge a charged one, each fused with a Range below its BlastRadius. + Assert.IsType(Data("Smoke Refused Beam")); + Assert.IsType(Data("Smoke Refused Charge")); + foreach (var name in new[] { "Smoke Refused Beam", "Smoke Refused Charge" }) + { + Assert.NotNull(Design(name).Fuse); + Assert.True(Data(name).Range.Max < Design(name).BlastRadius, $"{name}'s range is not below its blast radius"); + } + + // A second apply finds them authored and writes nothing; so does a dry run. + Assert.Equal(0, Program.SmokeRoundsCatalog(apply: true, root: _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + Assert.Equal(0, Program.SmokeRoundsCatalog(apply: false, root: _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + Assert.Equal(5, Read().Smoke.Length); + } +} diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs new file mode 100644 index 000000000..bfd412452 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs @@ -0,0 +1,173 @@ +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; + var burst = ((InstantWeapon) directWeapon).BurstCount; + Assert.True(magazine > 0 && directWeapon.Damage * burst * magazine > ((HullData) _items.GetData(fragile.Hull)).Durability, + $"the direct weapon cannot kill the fragile hull from its magazine ({directWeapon.Damage} x {burst} x {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"); + } + + // New Game lists the three arenas. The census (EveryScenario) is built from the same listing, so only a read of the listing + // itself notices an arena dropped from it. + [Fact] + public void NewGameListsTheThreeSmokeRoundsArenas() + { + var listed = Scenarios.Tests.Select(scenario => scenario.Name).ToArray(); + Assert.Contains(new FusedRounds().Name, listed); + Assert.Contains(new RefusedRounds().Name, listed); + Assert.Contains(new AiFusedDiscipline().Name, listed); + } +} diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs new file mode 100644 index 000000000..fcacfd43d --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using System.Linq; +using UniRx; +using Xunit; +using static CultMath.math; + +// aetheria-release, cut scenarios-smoke-rounds: the Fused Rounds and AI Fused Discipline arenas flown, stepped through +// Zone.Update with the production fire path (Activate, the weapons' Execute, FireControl.Step), each against a control that +// shows the harness fires when the rule allows it. SmokeRoundsScenarioTests reads what the arenas stage; this reads what they do. +public sealed partial class RunStartTests +{ + private const float SteppedDt = .05f; + + private (Zone Arena, RunStart.Staged Staged) LaunchStepped(Scenario scenario) + { + var (_, arena, staged, failures) = Launch(scenario, Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + staged.Player.GenerateWeaponGroups(); + EvSettle(staged.Player); + return (arena, staged); + } + + // Fused Rounds, nothing selected, the player aiming down the bare hull ahead. The DeathCluster is the control: it fires + // in this arena, so a fused weapon that does not is not a harness that cannot fire. A fused round flies slowly enough + // to be in the air when the direct weapon kills the fragile hull (check A.3) and bursts on the first hull on the aim line. + [Theory] + [InlineData("DeathCluster")] + [InlineData("Smoke Proximity")] + [InlineData("Smoke Delayed")] + public void EveryWeaponAboardFiresInTheSteppedFusedRoundsArena(string design) + { + var (arena, staged) = LaunchStepped(new FusedRounds()); + var player = staged.Player; + var weapon = (InstantWeapon) WeaponOf(Carried(player, design)); + player.Aim = float3(0, 0, 1); + var fired = new List(); + var committed = new List<(float At, ShotOutcome Outcome)>(); + weapon.OnFire += _ => fired.Add(arena.Time); + using var subscription = arena.ShotCommitted.Subscribe(outcome => committed.Add((arena.Time, outcome))); + + weapon.Activate(); + for (var i = 0; i < 60; i++) arena.Update(SteppedDt); + + Assert.True(fired.Count > 0, $"{design} fired nothing in 3 s; the ship's reactor is {(player.PowerBus.TotalGeneration > 0f ? "running" : "down")}"); + if (design == "DeathCluster") return; + var (at, burst) = committed.First(); + Assert.Equal(ShotResult.Burst, burst.Result); + Assert.True(burst.HasBurstPoint); + var reach = length(burst.BurstPoint - player.Position.xz); + Assert.InRange(reach, FusedRounds.Ahead.y - 30f, FusedRounds.Ahead.y); // on the first hull in the way, not at max range + Assert.InRange(at + burst.ArrivalIn - fired[0], 1f, 5f); // in the air for seconds: not resolved in its firing tick, not minutes of flight + } + + // Check A.3 flown: the fragile hull is selected, a Smoke Proximity round is fired at it and the DeathCluster kills it + // before the round lands, so the round never bursts. + [Fact] + public void TheDirectWeaponKillsTheFragileHullWhileASmokeRoundIsStillInTheAir() + { + var (arena, staged) = LaunchStepped(new FusedRounds()); + var player = staged.Player; + var fragile = staged.Entities[2]; + Assert.Equal(FusedRounds.Fragile, fragile.Position.xz); + player.SetTarget(fragile); + player.SetIff(fragile, true); + player.EntityInfoGathered[fragile] = 1f; + var proximity = Carried(player, "Smoke Proximity"); + (float At, ShotResult Result)? landed = null; + using var subscription = arena.ShotResolved.Subscribe(outcome => + { + if (landed == null && outcome.Weapon == proximity) landed = (arena.Time, outcome.Result); + }); + + WeaponOf(proximity).Activate(); + WeaponOf(Carried(player, "DeathCluster")).Activate(); + float? deadAt = null; + for (var i = 0; i < 120 && landed == null; i++) + { + arena.Update(SteppedDt); + if (deadAt == null && !arena.Entities.Contains(fragile)) deadAt = arena.Time; + } + + Assert.NotNull(deadAt); + Assert.NotNull(landed); + Assert.True(deadAt <= landed.Value.At, $"the round landed at {landed.Value.At:F2} s, before the fragile hull died at {deadAt:F2} s"); + Assert.NotEqual(ShotResult.Burst, landed.Value.Result); + } + + // AI Fused Discipline, run for half a minute (the hostile closes from its 700 m in about 15 s, and either control fires within + // a second), against its two controls. The hostile closes on the player and never fires its refused weapon; the neutral never fires its fused one. Each silence is discipline, not inability: the same hostile + // fires once its weapon's Range reaches its blast radius, and the same neutral fires once it is hostile to the player. + [Fact] + public void TheAiHoldsARefusedWeaponAndANeutralHoldsItsFusedOne() + { + (int Hostile, int Neutral, float Closest) Fly(System.Action alter = null) + { + var (arena, staged) = LaunchStepped(new AiFusedDiscipline()); + var (hostile, neutral) = (staged.Entities[0], staged.Entities[1]); + alter?.Invoke(hostile, neutral, staged.Player); + int hostileShots = 0, neutralShots = 0; + using var subscription = arena.ShotCommitted.Subscribe(outcome => + { + if (outcome.Source == hostile) hostileShots++; + else if (outcome.Source == neutral) neutralShots++; + }); + var closest = float.MaxValue; + for (var i = 0; i < 300; i++) + { + arena.Update(.1f); + closest = min(closest, length((hostile.Position - staged.Player.Position).xz)); + } + return (hostileShots, neutralShots, closest); + } + + var shipped = Fly(); + Assert.Equal((0, 0), (shipped.Hostile, shipped.Neutral)); + Assert.True(shipped.Closest < 100f, $"the hostile never closed on the player (nearest {shipped.Closest:F0} m)"); + + // Control: the hostile's weapon given the reach to arm. The design's data is shared, so it is put back. + var refused = _cache.GetAll().Single(design => design.Name == "Smoke Refused").Behaviors.OfType().Single(); + var range = refused.Range; + refused.Range = new PerformanceStat { Min = 2000f, Max = 2000f }; + try { Assert.True(Fly().Hostile > 0, "the hostile fires nothing even when its weapon can arm: the arena cannot show it holding fire"); } + finally { refused.Range = range; } + + // Control: the neutral made hostile to the player, both ways. + Assert.True(Fly((hostile, neutral, player) => { neutral.SetIff(player, true); player.SetIff(neutral, true); }).Neutral > 0, + "the neutral fires nothing even when hostile: the arena cannot show it holding fire"); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index ee928bcae..f3e2f5be7 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -43,6 +43,7 @@ public static class Program case "pd-gear": return PdGearCatalog(args.Contains("apply")); case "mine-launcher": return MineLauncherCatalog(args.Contains("apply")); case "smoke-hull": return SmokeHullCatalog(args.Contains("apply")); + case "smoke-rounds": return SmokeRoundsCatalog(args.Contains("apply")); case "roles-backfill": return RolesBackfill(args.Contains("apply")); case "consumables-first-set": return ConsumablesFirstSetCatalog(args.Contains("apply")); case "pirates-faction": @@ -50,7 +51,7 @@ public static class Program return PiratesFaction(args.Contains("apply")); case "verse-grammar": return VerseGrammarCommand.Run(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); return 1; } } @@ -1678,6 +1679,94 @@ public static class Program return 0; } + // aetheria-release cut scenarios-smoke-rounds (docs/scenarios-cut.md 5.3): the five smoke weapons the Fused Rounds, Refused + // Rounds and AI Fused Discipline arenas fit. None has a product, so no generated ship, station or shop offers one and only a + // scenario fits them. Smoke Proximity, Smoke Delayed and Smoke Refused are Spectra clones (energy, 1x2) with the operator's + // fuse values; Smoke Refused Beam is a constant weapon and Smoke Refused Charge a charged one cloned from ChargeBlast SG, + // each fused with a Range below its BlastRadius, so every refused weapon kind has a design. The rounds that fly are slow + // (Velocity 150) so a scenario can kill the target while one is in the air. Dry run unless passed "apply". + public static int SmokeRoundsCatalog(bool apply, string root = null) + { + var db = AetherDb.Open(catalogWritable: apply, root: root); + var weapons = db.Cache.GetAll().ToArray(); + var names = new[] { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam", "Smoke Refused Charge" }; + var already = names.Count(name => weapons.Any(w => w.Name == name)); + if (already == names.Length) + { + Console.WriteLine("The smoke rounds are already authored; nothing to do."); + return 0; + } + if (already != 0) throw new InvalidOperationException("The smoke rounds are partly authored; the catalog needs a human."); + + WeaponItemData Template(string name) => + weapons.FirstOrDefault(w => w.Name == name) ?? throw new InvalidOperationException($"No weapon design named \"{name}\"."); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(WeaponItemData).Assembly); + WeaponItemData Copy(string template, string name, string description, WeaponFuse fuse, float blast) + { + var source = Template(template); + var design = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(source, options), options); + design.Name = name; + design.Description = description; + design.Fuse = fuse; + design.BlastRadius = blast; + return design; + } + static PerformanceStat Fixed(float value) => new PerformanceStat { Min = value, Max = value, Terms = new List() }; + + var proximity = Copy("Spectra", names[0], "A Spectra that bursts by proximity, blast 8. For scenarios; no one builds it.", WeaponFuse.Proximity, 8f); + var delayed = Copy("Spectra", names[1], "A Spectra that bursts after two cells of metal, blast 4. For scenarios; no one builds it.", WeaponFuse.Delayed, 4f); + var refused = Copy("Spectra", names[2], "A Spectra whose blast is wider than its reach, so it never fires. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f); + var charge = Copy("ChargeBlast SG", names[4], "A ChargeBlast SG whose blast is wider than its reach, so it never charges. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f); + var beam = Copy("Spectra", names[3], "A constant beam whose blast is wider than its reach, so it never starts. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f); + + // The Smoke Test Hull carries one 2x2 reactor. A Spectra's 20-40 energy a shot at its cadence is more than that reactor makes, + // so two of them overload it, it overheats and shuts down within a second, and nothing aboard fires again (Fused Rounds, + // stepped, before this). The two rounds a scenario must fly draw like an Autocannon instead. + foreach (var design in new[] { proximity, delayed }) + { + var data = design.Behaviors.OfType().Single(); + data.Velocity = Fixed(150f); + data.Energy = Fixed(4f); + } + delayed.Behaviors.OfType().Single().Penetration = Fixed(2f); + refused.Behaviors.OfType().Single().Range = Fixed(20f); + charge.Behaviors.OfType().Single().Range = Fixed(20f); + + // The beam keeps the Spectra's gear (shape, hardpoint, thermal envelope, roles) and swaps its behaviour for a constant one. + var instant = beam.Behaviors.OfType().Single(); + var constant = new ConstantWeaponData + { + DamageType = instant.DamageType, Damage = instant.Damage, Penetration = instant.Penetration, DamageSpread = instant.DamageSpread, + MinRange = instant.MinRange, Range = Fixed(20f), DamageCurve = instant.DamageCurve, EffectPrefab = instant.EffectPrefab, + Energy = instant.Energy, Heat = instant.Heat, Visibility = instant.Visibility, AmmoType = instant.AmmoType, + MagazineSize = instant.MagazineSize, ReloadTime = instant.ReloadTime, Spread = instant.Spread, Velocity = instant.Velocity, + Tracking = instant.Tracking + }; + beam.Behaviors.Remove(instant); + beam.Behaviors.Add(constant); + + var designs = new[] { proximity, delayed, refused, beam, charge }; + Console.WriteLine("Adds 5 weapon designs, no products:"); + foreach (var design in designs) + { + var data = design.Behaviors.OfType().Single(); + Console.WriteLine($" {design.Name,-20} {design.Hardpoint,-8} {design.Shape.Width}x{design.Shape.Height} {data.GetType().Name,-18} fuse {design.Fuse} blast {design.BlastRadius} range {data.Range.Min}-{data.Range.Max} velocity {data.Velocity.Min}"); + } + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to author them."); + return 0; + } + + foreach (var design in designs) CultRecordRefs.Validate(design); + db.Cache.Commit(batch => + { + foreach (var design in designs) batch.Upsert(typeof(WeaponItemData), design); + }); + Console.WriteLine($"\nAuthored {designs.Length} designs in Aetheria.cc"); + return 0; + } + // sim-mines-presenter: the Mine Launcher item, so a ship can be fitted with the sim's mine layer. Its numbers // are the 2021 record's (GameData/Legacy/AetherDB.2021-03-05.msgpack, recovered in 8c71a637: mass 75, price // 125000, damage 50, range 2000, energy 15, heat 500, visibility 10, magazine 6, reload 5, spread 5, velocity