Generation never offers gear no hull can mount

Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.

Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 18:01:35 +02:00
commit d91ab8daf9
4 changed files with 42 additions and 9 deletions

View file

@ -567,6 +567,12 @@ public class HardpointData
return $"{Enum.GetName(typeof(HardpointType), Type)} Hardpoint {Rotation.Arrow()}";
}
// The one fit rule for hardpoint gear: its type, laid in at this hardpoint's rotation, filling every cell.
public bool Takes(EquippableItemData design) =>
design.HardpointType == Type &&
design.Shape.FitsWithin(Shape, Rotation, out _) &&
design.Shape.Coordinates.Length == Shape.Coordinates.Length;
[IgnoreMember]
public float3 TintColor
{

View file

@ -141,12 +141,14 @@ public class LoadoutGenerator
// lacks variety, so it is logged rather than hidden.
public (FactionProductData product, T design)[] RandomProducts<T>(int count, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
{
var hulls = ItemManager.ItemData.GetAll<HullData>().ToArray();
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
.Select(product => (product, design: ItemManager.ItemData.Get(product.Design) as T))
.Where(entry =>
entry.design != null &&
entry.design.Price > 0 &&
entry.product.Manufacturer.IsSet() &&
HasHome(entry.design, hulls) &&
(filter?.Invoke(entry.design) ?? true))
.ToArray();
var available = candidates.Where(entry => IsAvailable(entry.product)).ToArray();
@ -165,6 +167,13 @@ public class LoadoutGenerator
count);
}
// 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.
private 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)));
// No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that
// way, so a test can exercise placement and products without standing up a whole galaxy; no game path does.
// Loadouts.Materialize takes availability as a predicate; RunStart, the game's one preset spawner, passes this, so
@ -192,10 +201,7 @@ public class LoadoutGenerator
public (FactionProductData product, T design) RandomProduct<T>(HardpointData hardpoint, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
{
return RandomProduct<T>(sizeExponent, item => item.HardpointType == hardpoint.Type &&
(filter?.Invoke(item) ?? true) &&
item.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) &&
item.Shape.Coordinates.Length==hardpoint.Shape.Coordinates.Length, required);
return RandomProduct<T>(sizeExponent, item => hardpoint.Takes(item) && (filter?.Invoke(item) ?? true), required);
}
private void OutfitEntity(Entity entity)
@ -227,7 +233,7 @@ public class LoadoutGenerator
{
// If a previously selected product fits, use that one (this is why we must process larger hardpoints first)
var entry = previousProducts
.FirstOrDefault(e => e.design.HardpointType == hardpoint.Type && e.design.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _));
.FirstOrDefault(e => hardpoint.Takes(e.design));
var previousItem = entity.Equipment.FirstOrDefault(item => item.Data == entry.design);
if (entry.design == null) entry = RandomProduct<GearData>(hardpoint, 2);
if (entry.design == null) ItemManager.Log($"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!");

View file

@ -486,6 +486,30 @@ public sealed class RunStartTests : IDisposable
Assert.True(stalls.Count == 0, $"{stalls.Count} readings of an orbit standing still; first {stalls.FirstOrDefault()}");
}
// Gear no hull can mount has no home (operator, 2026-09-30): generation never offers it, so no station stocks it
// and no loadout carries it, though it keeps its product and price. The Autocannon fits no hardpoint of any
// shipped hull. Authoring a hull whose hardpoint takes it makes it one generation offers.
[Fact]
public void GearNoHullCanMountIsOfferedOnlyOnceAHullTakesIt()
{
var autocannon = _cache.GetAll<EquippableItemData>().Single(design => design.Name == "Autocannon");
Assert.True(autocannon.Price > 0 && _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(_cache.RefOf(autocannon).Key)),
"the Autocannon is priced and sold");
var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f);
int Offered() => generator.RandomProducts<EquippableItemData>(2000, 0, design => design.HardpointType == autocannon.HardpointType)
.Count(entry => entry.design == autocannon);
Assert.Equal(0, Offered());
var shape = new Shape(autocannon.Shape.Width + 2, autocannon.Shape.Height + 2);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
_cache.Upsert(new HullData
{
Name = "Autocannon Mount", HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1,
Hardpoints = { new HardpointData { Type = autocannon.HardpointType, Position = int2(1, 1), Shape = autocannon.Shape } }
});
Assert.True(Offered() > 0, "a hull that takes the Autocannon gives it a home");
}
// Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a
// scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of
// it, or cargo it carries. These are the unsold designs no scenario places, by name.

View file

@ -468,10 +468,7 @@ public static class Program
foreach (var hardpoint in hull.Hardpoints.OrderByDescending(h => h.Shape.Coordinates.Length))
{
var cells = hardpoint.Shape.Coordinates.Length;
var matches = gear.Where(g =>
g.HardpointType == hardpoint.Type &&
g.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) &&
g.Shape.Coordinates.Length == cells).ToArray();
var matches = gear.Where(hardpoint.Takes).ToArray();
var soldMatches = matches.Where(m => sold.Contains(db.Cache.RefOf(m).Key)).ToArray();
if (soldMatches.Length == 0) unfillable++;
Console.WriteLine($" {hardpoint.Type,-14} {cells,2} cells: {matches.Length} designs match, {soldMatches.Length} sold" +