Smoke Test Hull fixes: the home rule reads sold hulls only; command and flank geometry pinned; turret off the starboard line

LoadoutGenerator.SoldHulls is the one definition of the hulls HasHome considers (a hull some product sells); AvailableProducts and the
tests read it, so the product-less test hull gives no gear a home. TheSoldHardpointGearWithNoHomeToday describes production again.
SmokeHullCommandTests pins each added hardpoint's firing arc, armour and shape; ArcsFlanks asserts the flank hulls abeam from the
player's facing within 5 degrees; Arcs' Turret moves to (600,300). Not yet built or run at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 22:35:25 +02:00
commit 2a943370ab
6 changed files with 28 additions and 9 deletions

View file

@ -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<T>(Predicate<T> filter, bool required) where T : CraftedItemData
{
var hulls = ItemManager.ItemData.GetAll<HullData>().ToArray();
var hulls = SoldHulls(ItemManager.ItemData);
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
.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<FactionProductData>().Select(product => items.Get(product.Design)).OfType<HullData>().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)));

View file

@ -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();

View file

@ -29,7 +29,7 @@ public sealed partial class RunStartTests
var products = PiratesProducts();
Assert.Equal(8, products.Length);
var hulls = _cache.GetAll<HullData>().ToArray();
var hulls = LoadoutGenerator.SoldHulls(_cache);
foreach (var product in products)
{
var design = _cache.Get(product.Design) as EquippableItemData;

View file

@ -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<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
var hulls = _cache.GetAll<HullData>().Where(hull => sold.Contains(_cache.RefOf(hull).Key)).ToArray();
var hulls = LoadoutGenerator.SoldHulls(_cache);
var homeless = _cache.GetAll<EquippableItemData>()
.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);
}

View file

@ -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");

View file

@ -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();