diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 0c445a100..0e60a544e 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -190,7 +190,7 @@ public static class Loadouts return () => (EquippableItem) itemManager.CreateInstance(product); } - private static FactionProductData[] ProductsInKeyOrder(CultCache cache) => + internal static FactionProductData[] ProductsInKeyOrder(CultCache cache) => cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray(); private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}"; diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index 2fa28dc1f..8f532e03f 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -116,7 +116,9 @@ public sealed class ScenarioStage return entity; } - // Items in the entity's first cargo bay, one per design named. + // Items in the entity's first cargo bay, one per design named. A name is an equippable design (built as a fit + // builds it) or a consumable design (built from its first available product in key order, as a generated hold is + // stocked); a name that is neither is a failure. public void Cargo(Entity entity, params string[] designs) { if (entity == null) return; @@ -124,10 +126,27 @@ public sealed class ScenarioStage var bay = entity.CargoBays.FirstOrDefault(); foreach (var design in designs) { - var data = Named(design, where); + var data = Named(design, where, found => found is EquippableItemData || found is ConsumableItemData); if (data == null) continue; - var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data), _available, where, _failures); - if (build != null && (bay == null || !bay.TryStore(build()))) + Func build; + if (data is ConsumableItemData) + { + var key = _items.ItemData.RefOf(data).Key; + var product = Loadouts.ProductsInKeyOrder(_items.ItemData).Where(p => p.Design.Key.Equals(key)).FirstOrDefault(p => _available(p)); + if (product == null) + { + _failures.Add($"{where}: no product offers {design}"); + continue; + } + build = () => _items.CreateInstance(product); + } + else + { + var equippable = Loadouts.Resolve(_items, _items.ItemData.RefOf((EquippableItemData) data), _available, where, _failures); + if (equippable == null) continue; + build = equippable; + } + if (bay == null || !bay.TryStore(build())) _failures.Add($"{where}: no room for {design}"); } } @@ -151,9 +170,9 @@ public sealed class ScenarioStage return entity; } - private T Named(string name, string where) where T : EquippableItemData + private T Named(string name, string where, Func among = null) where T : CraftedItemData { - var named = _items.ItemData.GetAll().Where(data => data.Name == name).Take(2).ToArray(); + var named = _items.ItemData.GetAll().Where(data => data.Name == name && (among == null || among(data))).Take(2).ToArray(); if (named.Length == 1) return named[0]; _failures.Add(named.Length == 0 ? $"{where}: no design named {name}" : $"{where}: more than one design is named {name}"); return null; diff --git a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs new file mode 100644 index 000000000..a175533de --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs @@ -0,0 +1,60 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class Pursuit : Scenario +{ + // The pursuer starts astern, inside the 400 at which VapourCloudTests.ACloudBreaksALockAtRange shows a fresh vent + // cloud breaking a sensor's lock, and well outside the 40 at which AStrongCloseSensorBurnsThrough shows a close + // sensor burning through. + private const float Astern = 300; + + public override string Name => "Pursuit"; + public override string Brief => "A hostile Longinus on your tail; a Mine Launcher fitted, three Coolant Vents and two Thruster " + + "Overdrives aboard. Verify: lay mines in its path, the mine pulses slowly after 2 s, fast when " + + "the hostile is near, explodes 2 s later; vent and coast to lose it; overdrive away."; + public override uint Seed => 111; + public override bool Ambient => false; + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast); + + public override void Stage(ScenarioStage stage) + { + var layer = 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), + ("Mine Launcher", int2(0, 5), None), + ("pswarm", int2(5, 5), None), + ("FastBlast+-", int2(1, 8), None), + ("FastBlast+-", int2(4, 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), + ("Store-All Plus", int2(2, 8), None)); + var pursuer = 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), + ("GT 3K", int2(0, 5), None), + ("GT 3K", int2(5, 5), None), + ("FastBlast+-", int2(1, 8), None), + ("FastBlast+-", int2(4, 8), None), + ("Iapyx", int2(2, 2), CounterClockwise), + ("Iapyx", int2(3, 2), Clockwise), + ("not if i see you first", int2(3, 10), None), + ("PotaT+-", int2(1, 5), None), + ("Fire Control Array", int2(1, 6), Clockwise), + ("Store-All Plus", int2(2, 8), None)); + + var player = stage.Player(layer, float2(0, 0), facing: float2(0, 1)); + stage.Cargo(player, "Coolant Vent", "Coolant Vent", "Coolant Vent", "Thruster Overdrive", "Thruster Overdrive"); + stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs.meta b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs.meta new file mode 100644 index 000000000..375cb139b --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b0587057e6a4618bf63944364298870 +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 4a43c8960..288f8373d 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 LongHaul() + new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new LongHaul(), new Pursuit() }; } diff --git a/tests/Aetheria.Shared.Tests/PursuitScenarioTests.cs b/tests/Aetheria.Shared.Tests/PursuitScenarioTests.cs new file mode 100644 index 000000000..363a8d3a0 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PursuitScenarioTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using int2 = CultMath.int2; + +// The Pursuit scenario (aetheria-release cut scenarios-pursuit): a Mine Launcher fitted, consumables in the hold, and +// a hostile on the player's tail. Each test launches or stages through the production path, so what it observes is what +// the menu's button builds. The fixture is RunStartTests'. +public sealed partial class RunStartTests +{ + private IEnumerable Held(Entity entity) => entity.CargoBays.SelectMany(bay => bay.Cargo.Keys).OfType(); + + private int HeldOf(Entity entity, string design) => Held(entity).Count(item => _items.GetData(item).Name == design); + + private static float2 Ground(Entity entity) => float2(entity.Position.x, entity.Position.z); + + // The arena the Pursuit button builds stages what the scenario claims: the cast is the demo's, the Mine Launcher is + // fitted on a Launcher cell and sits in a weapon group, the hold has the consumables, and the pursuer is piloted, + // hostile both ways, astern and facing the player inside the range a fresh vent cloud breaks a sight line. + [Fact] + public void PursuitStages() + { + var (galaxy, arena, staged, failures) = Launch(new Pursuit(), Inputs(() => GalaxySeed)); + Assert.Empty(failures); + + var cast = DemoTerminus.Cast; + var named = new[] { cast.ProtagonistFaction, cast.AntagonistFaction, cast.BufferFaction }.Concat(cast.NeutralFactions); + Assert.Equal(named.Select(galaxy.ResolveFaction), galaxy.Factions); + Assert.Equal(cast.ZoneCount, galaxy.Zones.Length); + + var player = staged.Player; + arena.Update(0); // weapon groups assemble on the first update + var mines = player.Equipment.Single(item => _items.GetData(item.EquippableItem).Name == "Mine Launcher"); + Assert.Equal(HardpointType.Launcher, player.Hardpoints[mines.Position.x, mines.Position.y].Type); + Assert.Contains(player.WeaponGroups, group => group.items.Contains(mines)); + + Assert.Equal(3, HeldOf(player, "Coolant Vent")); + Assert.Equal(2, HeldOf(player, "Thruster Overdrive")); + Assert.All(Held(player), item => Assert.IsType(item)); + + var pursuer = Assert.Single(staged.Entities); + Assert.Equal(1, AgentsOf(arena, pursuer)); + Assert.True(player.IsHostileTo(pursuer) && pursuer.IsHostileTo(player), "hostile both ways"); + var offset = Ground(pursuer) - Ground(player); + Assert.True(dot(player.Direction, offset) < 0, "the pursuer is not astern"); + Assert.True(dot(pursuer.Direction, -offset) > 0, "the pursuer is not facing the player"); + // VapourCloudTests: a fresh vent breaks a lock at 400 and a close sensor burns through at 40. + Assert.InRange(length(offset), 40f, 400f); + } + + // Cargo names a consumable design as well as an equippable one; a name that is neither fails by name and admits + // nothing. + [Fact] + public void ACargoConsumableIsStaged() + { + var oneCell = _cache.GetAll() + .Where(design => design.Shape.Coordinates.Length == 1) + .Where(design => _cache.GetAll().Any(p => p.Design.Key.Equals(_cache.RefOf(design).Key))) + .OrderBy(design => design.Name, StringComparer.Ordinal) + .First(); + + var player = Stage(Arena(true), new Scripted(true, stage => + stage.Cargo(stage.Player(stage.Generated("Djinni"), float2(0, 0)), "Coolant Vent", oneCell.Name))).Player; + var vents = Held(player).Where(item => _items.GetData(item).Name == "Coolant Vent").ToList(); + Assert.IsType(Assert.Single(vents)); + Assert.IsType(Assert.Single(Held(player), item => _items.GetData(item).Name == oneCell.Name)); + + var zone = Arena(true); + var failures = Refused(zone, new Scripted(true, stage => + stage.Cargo(stage.Player(stage.Generated("Djinni"), float2(0, 0)), "Coolant Vent", "No Such Thing"))); + Assert.Contains(failures, failure => failure.Contains("no design named No Such Thing")); + } + + // A vent staged in the hold is the real consumable: activating it makes a cloud on the venter. + [Fact] + public void TheStagedVentMakesACloud() + { + var (_, arena, staged, failures) = Launch(new Pursuit(), Inputs(() => GalaxySeed)); + Assert.Empty(failures); + var player = staged.Player; + var vent = _items.ItemData.GetAll().Single(design => design.Name == "Coolant Vent"); + + arena.Update(0); + Assert.True(player.TryActivateConsumable(vent)); + arena.Update(.1f); + + var cloud = Assert.Single(arena.Clouds); + Assert.True(length(cloud.Body.Position - Ground(player)) <= cloud.Radius, "the cloud is not on its venter"); + Assert.Equal(2, HeldOf(player, "Coolant Vent")); + } +}