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:
parent
d52b50f911
commit
9c50b7499b
3 changed files with 117 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue