diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index ca89ac58d..1ce9ab03c 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -186,7 +186,7 @@ public class LoadoutGenerator // not once a required item has fallen back to any manufacturer). The one availability rule for every selection. private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts(Predicate filter, bool required) where T : CraftedItemData { - var hulls = ItemManager.ItemData.GetAll().ToArray(); + var hulls = SoldHulls(ItemManager.ItemData); var candidates = ItemManager.ItemData.GetAll() .Select(product => (product, design: ItemManager.ItemData.Get(product.Design) as T)) .Where(entry => @@ -231,6 +231,11 @@ public class LoadoutGenerator // Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a // bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station // stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior. + // Only a hull some product sells gives gear a home: a hull no faction builds (the Smoke Test Hull) is a test fixture, so its + // hardpoints never put gear on a shelf. The one definition of the hulls HasHome considers. + public static HullData[] SoldHulls(CultCache items) => + items.GetAll().Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); + public static bool HasHome(EquippableItemData design, HullData[] hulls) => design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull || hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design))); diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index b054d8e12..fcbd5dbe7 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -16,7 +16,7 @@ public sealed class Arcs : Scenario // Where the targets sit: the player is at the origin facing +z. The forward mounts fire forward, the starboard mount // (Clockwise) along +x and the port mount (CounterClockwise) along -x. - public static readonly CultMath.float2 Bow = float2(0, 400), Stern = float2(0, -400), Turret = float2(600, 0), + public static readonly CultMath.float2 Bow = float2(0, 400), Stern = float2(0, -400), Turret = float2(600, 300), Port = float2(-400, 0), Starboard = float2(400, 0); public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); diff --git a/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs b/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs index 9d9b79056..67ae08d22 100644 --- a/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs +++ b/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs @@ -29,7 +29,7 @@ public sealed partial class RunStartTests var products = PiratesProducts(); Assert.Equal(8, products.Length); - var hulls = _cache.GetAll().ToArray(); + var hulls = LoadoutGenerator.SoldHulls(_cache); foreach (var product in products) { var design = _cache.Get(product.Design) as EquippableItemData; diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 321048e44..2b5ae2d4d 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -439,17 +439,17 @@ public sealed partial class RunStartTests : IDisposable Assert.True(disagreements.Count == 0, string.Join("; ", disagreements.Take(10)) + $" ({disagreements.Count} in all)"); } - // The sold hardpoint gear that no sold hull fits even loosely today (a test hull no product sells gives no gear a - // home), stated so that authoring a hull that takes one shows up here as a changed list. + // The sold hardpoint gear the generator gives no home today: the production rule (LoadoutGenerator.HasHome over the hulls a + // product sells). The Smoke Test Hull, which no product sells, gives ChargeBlast SG and ChargeBlast+- no home; authoring a + // sold hull that takes one shows up here as a changed list. [Fact] public void TheSoldHardpointGearWithNoHomeToday() { var sold = _cache.GetAll().Select(product => product.Design.Key).ToHashSet(); - var hulls = _cache.GetAll().Where(hull => sold.Contains(_cache.RefOf(hull).Key)).ToArray(); + var hulls = LoadoutGenerator.SoldHulls(_cache); var homeless = _cache.GetAll() .Where(design => sold.Contains(_cache.RefOf(design).Key) && design.Price > 0) - .Where(design => design.HardpointType != HardpointType.Tool && design.HardpointType != HardpointType.Hull) - .Where(design => !hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design)))) + .Where(design => !LoadoutGenerator.HasHome(design, hulls)) .Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToArray(); Assert.Equal(new[] { "CShot RainbowLite Lazer", "ChargeBlast SG", "ChargeBlast+-", "OK Disperser" }, homeless); } diff --git a/tests/Aetheria.Shared.Tests/SmokeHullCommandTests.cs b/tests/Aetheria.Shared.Tests/SmokeHullCommandTests.cs index 1fc75c453..fdf3f9526 100644 --- a/tests/Aetheria.Shared.Tests/SmokeHullCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/SmokeHullCommandTests.cs @@ -72,6 +72,13 @@ public sealed class SmokeHullCommandTests : IDisposable Assert.Single(added, h => h.Type == HardpointType.Ballistic && h.Rotation == ItemRotation.None && h.Takes(autocannon)); Assert.Single(added, h => h.Type == HardpointType.Ballistic && h.Rotation == ItemRotation.Clockwise && h.Takes(autocannon)); Assert.Single(added, h => h.Type == HardpointType.Energy && h.Rotation == ItemRotation.CounterClockwise && h.Takes(chargeBlast)); + // Each is a full 2x2 block with the default firing arc (0 uses the game setting; the side mounts bear by it, never wider) + // and the Longinus-class armour the command writes. + Assert.All(added, hardpoint => + { + Assert.Equal((2, 2, 0f, 20f), (hardpoint.Shape.Width, hardpoint.Shape.Height, hardpoint.FiringArc, hardpoint.Armor)); + Assert.Equal(4, hardpoint.Shape.Coordinates.Length); + }); foreach (var hardpoint in added) foreach (var cell in hardpoint.Shape.Coordinates) Assert.True(hull.Shape[hardpoint.Position + cell], "a new hardpoint lies outside the hull's cells"); diff --git a/tests/Aetheria.Shared.Tests/SmokeHullScenarioTests.cs b/tests/Aetheria.Shared.Tests/SmokeHullScenarioTests.cs index b7eb3edfd..7cc2ef469 100644 --- a/tests/Aetheria.Shared.Tests/SmokeHullScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/SmokeHullScenarioTests.cs @@ -46,7 +46,14 @@ public sealed partial class RunStartTests var player = staged.Player; player.GenerateWeaponGroups(); var (bow, stern, port, starboard) = (staged.Entities[0], staged.Entities[1], staged.Entities[3], staged.Entities[4]); - Assert.Equal((Arcs.Port, Arcs.Starboard), (port.Position.xz, starboard.Position.xz)); + // The flank hulls lie abeam from the player's facing, read from the staged positions: within 5 degrees of the + // perpendicular, starboard on the right of the facing and port on the left. + var facing = player.Direction; + var right = CultMath.math.float2(facing.y, -facing.x); + float Abeam(Entity flank, CultMath.float2 side) => CultMath.math.dot(CultMath.math.normalize(flank.Position.xz - player.Position.xz), side); + var abeam = CultMath.math.cos(CultMath.math.radians(5f)); + Assert.True(Abeam(starboard, right) >= abeam, "the starboard hull is not abeam to starboard"); + Assert.True(Abeam(port, -right) >= abeam, "the port hull is not abeam to port"); var mounts = WeaponMounts(player); var forward = mounts.Where(item => item.EquippableItem.Rotation == ItemRotation.None).ToList();