From d91ab8daf9b4c843afefb33eb42506b32f0fdf5d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 18:01:35 +0200 Subject: [PATCH] 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 --- Assets/Scripts/ServerShared/ItemData.cs | 6 +++++ .../Scripts/ServerShared/LoadoutGenerator.cs | 16 +++++++++---- tests/Aetheria.Shared.Tests/RunStartTests.cs | 24 +++++++++++++++++++ tools/AetherDb/Program.cs | 5 +--- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 59d93c1ba..e7b9355cc 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -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 { diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index e78dc9a4f..15e76e401 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -141,12 +141,14 @@ public class LoadoutGenerator // lacks variety, so it is logged rather than hidden. public (FactionProductData product, T design)[] RandomProducts(int count, float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData { + var hulls = ItemManager.ItemData.GetAll().ToArray(); var candidates = ItemManager.ItemData.GetAll() .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(HardpointData hardpoint, float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData { - return RandomProduct(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(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(hardpoint, 2); if (entry.design == null) ItemManager.Log($"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!"); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index b9ec9a7a0..a17a7fe89 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -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().Single(design => design.Name == "Autocannon"); + Assert.True(autocannon.Price > 0 && _cache.GetAll().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(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. diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 88762abc5..253d0598f 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -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" +