FactionProductData.NotOffered: an off-market product is never drawn, stocked, chosen or a gear home

One reader, LoadoutGenerator.IsOffered, read by IsAvailable (ahead of the no-faction
shortcut), the AvailableProducts candidates (so the any-maker fallback cannot reach one)
and SoldHulls. A Loadout that pins the product still builds it. Rulings
off-market-is-unoffered-product and boss-hulls-exclusive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-21 20:28:40 +02:00
commit 9c50b7499b
3 changed files with 117 additions and 7 deletions

View file

@ -38,6 +38,12 @@ public class FactionProductData
// no Assembly; the mint derives both from the line.
[Inspectable, JsonProperty("bin"), Key(7)]
public ProductBin Bin;
// Off the market (ruling off-market-is-unoffered-product): the product keeps its maker, brand, quality and price, but no
// station stocks it, no generation or chooser draws it, and its hull gives no gear a home. It reaches a ship only through a
// Loadout that pins it. LoadoutGenerator.IsOffered is its one reader.
[Inspectable, JsonProperty("notOffered"), Key(8)]
public bool NotOffered;
}
// How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is

View file

@ -197,6 +197,8 @@ public class LoadoutGenerator
entry.design != null &&
entry.design.Price > 0 &&
entry.product.Manufacturer.IsSet() &&
// A not-offered product is never a candidate, so the any-manufacturer fallback below cannot reach it either.
IsOffered(entry.product) &&
(!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) &&
(filter?.Invoke(entry.design) ?? true))
.ToArray();
@ -216,9 +218,10 @@ public class LoadoutGenerator
// 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.
// hardpoints never put gear on a shelf. An off-market hull gives no gear a home either: only its pinned Loadout flies it.
// 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();
items.GetAll<FactionProductData>().Where(IsOffered).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 ||
@ -231,9 +234,14 @@ public class LoadoutGenerator
// always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names
// (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy
// does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available).
// A not-offered product is available to nobody, a scenario stage included (ruling boss-hulls-exclusive).
public bool IsAvailable(FactionProductData product) =>
Galaxy == null || Faction == null ||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer);
IsOffered(product) && (
Galaxy == null || Faction == null ||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer));
// Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered.
public static bool IsOffered(FactionProductData product) => !product.NotOffered;
// How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of
// generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes

View file

@ -65,8 +65,10 @@ public sealed class ProductChoiceTests : IDisposable
private Rig Build(params string[] lampOrder) => Build(new float[0], lampOrder);
// binFloors adds one bin of lamp-a's line per floor, named "lamp-a bin" and "lamp-a bin 2"..., by the same maker.
private Rig Build(float[] binFloors, string[] lampOrder)
// binFloors adds one bin of lamp-a's line per floor, named "lamp-a bin" and "lamp-a bin 2"..., by the same maker. Every rig also
// holds a Barge, the one hull with a ballistic hardpoint, and a Gun only it mounts, each sold by Alpha. notOffered names the
// products the rig takes off the market.
private Rig Build(float[] binFloors, string[] lampOrder, params string[] notOffered)
{
var root = Path.Combine(_root, "rig" + _catalogs++);
Directory.CreateDirectory(root);
@ -92,10 +94,18 @@ public sealed class ProductChoiceTests : IDisposable
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } }
});
var lamp = seed.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 });
var barge = seed.Upsert(new HullData
{
Name = "Barge", HullType = HullType.Ship, Shape = shape, Price = 100,
Hardpoints = { new HardpointData { Type = HardpointType.Ballistic, Position = HardpointCell, Shape = new Shape() } }
});
var gun = seed.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Ballistic, Shape = new Shape(), Price = 10 });
void Sell(string name, CultRecordKey design, CultRecordRef<Faction> maker, string key) =>
seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData
{ Name = name, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = maker }, new CultRecordKey(key)));
{ Name = name, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = maker, NotOffered = notOffered.Contains(name) }, new CultRecordKey(key)));
Sell("Skiff by Alpha", skiff.Key, alpha, "skiff-a");
Sell("Barge by Alpha", barge.Key, alpha, "barge-a");
Sell("Gun by Alpha", gun.Key, alpha, "gun-a");
var makers = new Dictionary<string, CultRecordRef<Faction>> { ["lamp-a"] = alpha, ["lamp-z"] = zeta, ["lamp-m"] = mu };
// lampOrder holds product names; the record key of the i-th inserted is "k" + i, so keys and insertion order
// both follow the permutation.
@ -367,5 +377,91 @@ public sealed class ProductChoiceTests : IDisposable
{
Assert.Equal(4, typeof(Loadout).GetField(nameof(Loadout.HullProduct)).GetCustomAttribute<KeyAttribute>().IntKey);
Assert.Equal(3, typeof(LoadoutSlot).GetField(nameof(LoadoutSlot.Product)).GetCustomAttribute<KeyAttribute>().IntKey);
Assert.Equal(8, typeof(FactionProductData).GetField(nameof(FactionProductData.NotOffered)).GetCustomAttribute<KeyAttribute>().IntKey);
}
// Ruling off-market-is-unoffered-product: a not-offered product is never chosen, by a faction that reaches its maker or by a
// stage with no faction, which otherwise reaches every maker (ruling boss-hulls-exclusive).
[Fact]
public void ANotOfferedProductIsNeverChosen()
{
var rig = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-a");
var lamp = rig.Cache.GetByName<GearData>("Lamp");
for (var i = 1; i <= 64; i++)
{
Assert.Equal("lamp-z", rig.Outfitter(null, Seed(i)).ChooseProduct(lamp).Name);
Assert.Equal("lamp-z", rig.Outfitter(rig.OutEven, Seed(i), 0).ChooseProduct(lamp).Name);
// OutA reaches only Alpha, whose lamp is off the market.
Assert.Null(rig.Outfitter(rig.OutA, Seed(i)).ChooseProduct(lamp));
}
Assert.False(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-a")));
Assert.True(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-z")));
}
// The any-manufacturer fallback of a required draw is closed to off-market products: when every product of the design is
// not offered the draw yields nothing, for a faction that reaches the maker, one that reaches nobody, and no faction.
[Fact]
public void ARequiredDrawNeverFallsBackToANotOfferedProduct()
{
var rig = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-z", "lamp-a");
var lamp = rig.Cache.GetByName<GearData>("Lamp");
foreach (var faction in new[] { rig.OutA, rig.OutNone, null })
for (var i = 1; i <= 16; i++)
Assert.Null(rig.Outfitter(faction, Seed(i)).RandomProduct<EquippableItemData>(0, design => design == lamp, required: true).product);
// With one product still offered, a faction that reaches only the other maker falls back to it: the empty draw above is
// the field's doing, not a closed fallback.
var one = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-a");
var oneLamp = one.Cache.GetByName<GearData>("Lamp");
for (var i = 1; i <= 16; i++)
Assert.Equal("lamp-z", one.Outfitter(one.OutA, Seed(i)).RandomProduct<EquippableItemData>(0, design => design == oneLamp, required: true).product.Name);
}
// A hull sold only off the market is no gear's home: gear only it mounts is never drawn, so no station stocks it.
[Fact]
public void AnOffMarketHullGivesNoGearAHome()
{
var sold = Build("lamp-a");
var soldGun = sold.Cache.GetByName<GearData>("Gun");
Assert.Contains(sold.Cache.GetByName<HullData>("Barge"), LoadoutGenerator.SoldHulls(sold.Cache));
Assert.Equal("Gun by Alpha", sold.Outfitter(null, 1).RandomProduct<EquippableItemData>(0, design => design == soldGun, required: true).product.Name);
var off = Build(new float[0], new[] { "lamp-a" }, "Barge by Alpha");
var barge = off.Cache.GetByName<HullData>("Barge");
var gun = off.Cache.GetByName<GearData>("Gun");
var hulls = LoadoutGenerator.SoldHulls(off.Cache);
Assert.DoesNotContain(barge, hulls);
Assert.Contains(off.Cache.GetByName<HullData>("Skiff"), hulls);
Assert.False(LoadoutGenerator.HasHome(gun, hulls));
foreach (var faction in new[] { off.OutA, null })
for (var i = 1; i <= 16; i++)
{
var generator = off.Outfitter(faction, Seed(i));
Assert.Null(generator.RandomProduct<EquippableItemData>(0, design => design == gun, required: true).product);
Assert.Null(generator.RandomProduct<HullData>(0, design => design == barge, required: true).product);
}
}
// A Loadout that pins a not-offered hull product still builds it, under its brand: that is how a boss flies its hull.
[Fact]
public void APinnedNotOfferedHullStillMaterializes()
{
var rig = Build(new float[0], new[] { "lamp-a" }, "Barge by Alpha");
var fit = new Loadout
{
Name = "Boss barge",
Hull = rig.Cache.RefOf(rig.Cache.GetByName<HullData>("Barge")),
HullProduct = rig.ProductRef("Barge by Alpha"),
WeaponGroups = new int[0][]
};
var failures = new List<string>();
var ship = Materialize(rig, fit, rig.Outfitter(rig.OutA, 1).ChooseProduct, failures);
Assert.Empty(failures);
Assert.Equal("Barge by Alpha", rig.Items.Brand(ship.Hull).Product.Name);
// Unpinned, the same hull has no product to be built as.
fit.HullProduct = default;
Assert.Null(Materialize(rig, fit, rig.Outfitter(null, 1).ChooseProduct, failures));
Assert.NotEmpty(failures);
}
}