using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using GameCult.Caching; using CultMath; using static CultMath.math; using Random = CultMath.Random; public class LoadoutGenerator { public Random Random; public ItemManager ItemManager { get; } public Galaxy Galaxy { get; } public GalaxyZone Zone { get; } public Faction Faction { get; } public float PriceExponent { get; } // Consumables are scarce (operator, 2026-10-09): a quarter of stations stock one product, one ship in ten carries // one unit, and a carried consumable becomes loot when its ship dies. The units are the most a roll can stock; a // station draws 1 to the maximum. public const float StationConsumableChance = .25f; public const int StationConsumableUnits = 2; public const float ShipConsumableChance = .1f; public const int ShipConsumableUnits = 1; public LoadoutGenerator( ref Random random, ItemManager itemManager, Galaxy galaxy, GalaxyZone zone, Faction faction, float priceExponent) { Random = random; ItemManager = itemManager; Galaxy = galaxy; Zone = zone; Faction = faction; PriceExponent = priceExponent; } public EntityPack GenerateShipLoadout(Predicate hullFilter = null) { var (hullProduct, hullData) = RandomHull(HullType.Ship, hullFilter); if(hullData==null) { ItemManager.Log("Unable to generate ship loadout: no compatible hull found!"); return null; } var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem; if(hull==null) ItemManager.Log("WHAT???"); var entity = new Ship(ItemManager, null, hull, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; OutfitEntity(entity); EquipPickup(entity); StockConsumables(entity, ShipConsumableChance, ShipConsumableUnits); return EntitySerializer.Pack(entity); } public OrbitalEntityPack GenerateTurretLoadout() { var (hullProduct, hullData) = RandomHull(HullType.Turret); if(hullData==null) { ItemManager.Log("Unable to generate turret loadout: no compatible hull found!"); return null; } var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem; var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; OutfitEntity(entity); return EntitySerializer.Pack(entity) as OrbitalEntityPack; } public OrbitalEntityPack GenerateStationLoadout() { var (hullProduct, hullData) = RandomHull(HullType.Station); if(hullData==null) { ItemManager.Log("Unable to generate station loadout: no compatible hull found!"); return null; } var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem; var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; // Hardpoint gear goes in first so the docking bay can only take space that gear left free EquipHardpoints(entity); var emptyShape = entity.UnoccupiedSpace; var (dockingBayProduct, dockingBayData) = RandomProduct(2, item => item.Shape.FitsWithin(emptyShape, out _, out _), required: true); if (dockingBayData == null) throw new InvalidLoadoutException("No compatible docking bay found for station!"); dockingBayData.Shape.FitsWithin(emptyShape, out var cargoRotation, out var cargoPosition); var dockingBay = ItemManager.CreateInstance(dockingBayProduct) as EquippableItem; dockingBay.Rotation = cargoRotation; if (!entity.TryEquip(dockingBay, cargoPosition)) { throw new InvalidLoadoutException("Failed to equip selected docking bay!"); } // Every station carries a heater (operator, 2026-09-30): an idle station has no other heat source and cools // toward freezing and invisibility. A heater is general gear whose thermostat runs its heat only below a // target, the composition a cockpit uses: a low-pass Thermotoggle, then Heat, in its behaviours. emptyShape = entity.UnoccupiedSpace; var (heaterProduct, heaterData) = RandomProduct(2, item => IsHeater(item) && item.Shape.FitsWithin(emptyShape, out _, out _), required: true); if (heaterData == null) throw new InvalidLoadoutException("No compatible heater found for station!"); heaterData.Shape.FitsWithin(emptyShape, out var heaterRotation, out var heaterPosition); var heater = ItemManager.CreateInstance(heaterProduct) as EquippableItem; heater.Rotation = heaterRotation; if (!entity.TryEquip(heater, heaterPosition)) throw new InvalidLoadoutException("Failed to equip selected heater!"); FillInterior(entity); // The consumable goes in before the gear stock: sixteen gear picks drawn with replacement can fill a real hold, // and a roll that hit must leave the entity holding one (the ruled rate is the rule). With no consumable on // offer nothing is drawn here, so the gear draws are exactly what they were. StockConsumables(entity, StationConsumableChance, StationConsumableUnits); var cargo = entity.CargoBays.First(); var inventory = RandomProducts(16, 1, data => !(data is HullData hull && hull.HullType != HullType.Ship) && !(data is CargoBayData)) .OrderByDescending(entry => entry.design.Shape.Coordinates.Length); foreach (var entry in inventory) { var instance = ItemManager.CreateInstance(entry.product); cargo.TryStore(instance); } entity.CanTow = hullData.CanTow; return EntitySerializer.Pack(entity) as OrbitalEntityPack; } // A loadout's one consumable step. With no consumable on offer, or no hold to put one in, nothing is drawn, so a // catalog without consumables generates exactly what it did before them. Otherwise one roll decides whether the // entity has any, and a hit stocks 1 to maxUnits instances of one product, stopping when no hold takes another. private void StockConsumables(Entity entity, float chance, int maxUnits) { var (available, _) = AvailableProducts(null, false); if (available.Length == 0 || !entity.CargoBays.Any()) return; if (Random.NextFloat() >= chance) return; var (product, design) = RandomProducts(1, 0).FirstOrDefault(); if (design == null) return; var units = Random.NextInt(1, maxUnits + 1); for (var i = 0; i < units; i++) { var instance = ItemManager.CreateInstance(product); if (!entity.CargoBays.Any(bay => bay.TryStore(instance))) break; } } private static bool IsHeater(GearData item) => item.HardpointType == HardpointType.Tool && item.Behaviors.Any(b => b is ThermotoggleData { HighPass: false }) && item.Behaviors.Any(b => b is HeatData); // Every entity needs a hull, so hulls are always required public (FactionProductData product, HullData design) RandomHull(HullType type, Predicate hullFilter = null) { return RandomProduct(0, item => (hullFilter?.Invoke(item) ?? true) && item.HullType == type, required: true); } // What a faction makes is which products it has, so generation selects branded products rather than designs. // Products normally come only from manufacturers present in the galaxy and known to the zone's faction; a // required item falls back to any manufacturer when none of those make one, which means the product table // 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 : CraftedItemData { var (available, preferManufacturers) = AvailableProducts(filter, required); var shares = BinShares(); return available.WeightedRandomElements(ref Random, entry => Weight(entry.product, shares, preferManufacturers) * pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree count); } // The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does // 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(Predicate filter, bool required) where T : CraftedItemData { var hulls = SoldHulls(ItemManager.ItemData); 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() && (!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) && (filter?.Invoke(entry.design) ?? true)) .ToArray(); var available = candidates.Where(entry => IsAvailable(entry.product)).ToArray(); var preferManufacturers = true; if (available.Length == 0 && required && candidates.Length > 0) { ItemManager.Log($"No {typeof(T).Name} product available from manufacturers known to {Faction?.Name ?? "this galaxy"}; using any manufacturer. The product table needs more variety."); available = candidates; preferManufacturers = false; } return (available, preferManufacturers); } // A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull // the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all. public EntityPack GenerateBossLoadout() { var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null; if (bossHull != null) { var boss = GenerateShipLoadout(hull => hull == bossHull); if (boss != null) return boss; ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead."); } else ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead."); var (available, _) = AvailableProducts(hull => hull.HullType == HullType.Ship, required: true); if (available.Length == 0) return null; var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design; return GenerateShipLoadout(hull => hull == priciest); } // 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().Select(product => items.Get(product.Design)).OfType().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))); // No galaxy, or no faction, means no allegiance 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; a scenario // stage has no faction and reaches every maker. Loadouts.Materialize takes a chooser and ChooseProduct is the game's, // so presets and generation share this one availability rule. A faction // 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). public bool IsAvailable(FactionProductData product) => Galaxy == null || Faction == null || ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer); // 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 // the rest (ruling bin-share-by-floor), so binning never changes a maker's presence for the design. public float ProductWeight(FactionProductData product) => Weight(product, BinShares(), true); // The one weight, with the share of the stock computed from shares (BinShares, once per weighting pass). A draw that has // fallen back to any manufacturer weighs by share alone: the maker's preference no longer applies, its bins' shares still do. private float Weight(FactionProductData product, Dictionary shares, bool preferManufacturers) => (preferManufacturers ? ManufacturerPreference(product.Manufacturer) : 1) * Share(product, shares); // 1 - Phi(floor): the fraction of a normal draw that lies at least floor deviations above its mean. private static float PassFraction(float floor) => .5f * (1 - erf(floor / sqrt(2f))); // Each line's key to the sum of its bins' pass fractions: read from the catalog once per weighting pass, never kept. private Dictionary BinShares() => ItemManager.ItemData.GetAll() .Where(product => product.Bin != null) .GroupBy(product => product.Bin.Line.Key.Value) .ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor))); private float Share(FactionProductData product, Dictionary shares) { if (product.Bin != null) return PassFraction(product.Bin.Floor); return max(0f, 1 - shares.GetValueOrDefault(ItemManager.ItemData.RefOf(product).Key.Value)); } // The product a design named without one is built as (ruling loadout-product-optional-context-chooses): among the // products of the design this outfitter can get, one draw from the run's seeded Random weighted by ProductWeight; // null when none, and by share alone when every weight is 0. Candidates are ordered by name so the draw's // enumeration, and so its result, never depends on record keys. All of the policy is this body. public FactionProductData ChooseProduct(CraftedItemData design) { var key = ItemManager.ItemData.RefOf(design).Key; var candidates = ItemManager.ItemData.GetAll() .Where(product => product.Design.Key.Equals(key) && IsAvailable(product)) .OrderBy(product => product.Name, StringComparer.Ordinal) .ToArray(); if (candidates.Length == 0) return null; var shares = BinShares(); var uniform = candidates.All(product => Weight(product, shares, true) <= 0); return candidates.WeightedRandomElements(ref Random, product => Weight(product, shares, !uniform), 1)[0]; } // Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters. // A manufacturer with no home zone in this galaxy weighs as if its headquarters lay one jump beyond the farthest // zone this zone reaches: reachable through allegiance, least preferred. public float ManufacturerPreference(CultRecordRef manufacturer) { if (Faction == null || Galaxy == null) return 1; var maker = ItemManager.ItemData.Get(manufacturer); var allegiance = maker == Faction ? 1 : Faction.Allegiance.TryGetValue(manufacturer, out var a) ? a : 0; var home = maker != null && Galaxy.HomeZones.TryGetValue(maker, out var h) ? h : null; var distance = Zone?.Distance == null ? 0 : home != null && Zone.Distance.TryGetValue(home, out var d) ? d : home == null && Zone.Distance.Count > 0 ? Zone.Distance.Values.Max() + 1 : 0; return allegiance / (1 + distance); } public (FactionProductData product, T design) RandomProduct(float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData { return RandomProducts(1, sizeExponent, filter, required).FirstOrDefault(); } public (FactionProductData product, T design) RandomProduct(HardpointData hardpoint, float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData { // Every candidate passes the one fit rule, HardpointData.Takes. Generation prefers gear that fills the hardpoint // and falls back to anything that fits only when nothing that fills it is on offer. var filling = RandomProduct(sizeExponent, item => hardpoint.IsFilledBy(item) && (filter?.Invoke(item) ?? true)); if (filling.design != null) return filling; return RandomProduct(sizeExponent, item => hardpoint.Takes(item) && (filter?.Invoke(item) ?? true), required); } private void OutfitEntity(Entity entity) { EquipHardpoints(entity); FillInterior(entity); } // Every ship carries a tractor beam: picking up needs a pickup behaviour (scenarios ruling, "everyone gets tractor // beams"). It goes in after the shared outfitting, which stations and turrets also run and which adds none, into // whatever interior the hull has left; a hull with no cell for one cannot be generated. private void EquipPickup(Entity entity) { var emptyShape = entity.UnoccupiedSpace; var (product, design) = RandomProduct(2, item => item.Behaviors.Any(b => b is PickupData) && item.Shape.FitsWithin(emptyShape, out _, out _), required: true); if (design == null) throw new InvalidLoadoutException("No compatible tractor beam found for ship!"); design.Shape.FitsWithin(emptyShape, out var rotation, out var position); var beam = ItemManager.CreateInstance(product) as EquippableItem; beam.Rotation = rotation; if (!entity.TryEquip(beam, position)) throw new InvalidLoadoutException("Failed to equip selected tractor beam!"); } private void EquipHardpoints(Entity entity) { var hullData = ItemManager.GetData(entity.Hull) as HullData; foreach (var v in hullData.Shape.Coordinates) entity.HullConductivity[v.x, v.y] = true; var previousProducts = new List<(FactionProductData product, GearData design)>(); foreach (var hardpoint in hullData.Hardpoints.OrderByDescending(h=>h.Shape.Coordinates.Length)) { if (hardpoint.Type == HardpointType.ControlModule) { var (controllerProduct, controllerData) = RandomProduct(hardpoint, 2, item => item.Behaviors.Any(b => entity is Ship && b is CockpitData || entity is OrbitalEntity && b is TurretControllerData), required: true); if (controllerData == null) throw new InvalidLoadoutException("No compatible controller found for entity!"); var controller = ItemManager.CreateInstance(controllerProduct) as EquippableItem; if (!entity.TryEquip(controller)) { throw new InvalidLoadoutException($"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!"); } } else { // If a previously selected product fits, use that one (this is why we must process larger hardpoints first) var entry = previousProducts .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!"); else { // Matching hardpoints carry matching units: same lot var item = (previousItem != null ? ItemManager.CreateInstance(previousItem.EquippableItem.Lot) : ItemManager.CreateInstance(entry.product)) as EquippableItem; if (!entity.TryEquip(item)) { throw new InvalidLoadoutException($"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!"); } previousProducts.Add(entry); } } } } // Interior gear may use any free interior cell, including empty hardpoint cells private void FillInterior(Entity entity) { var emptyShape = entity.UnoccupiedSpace; var (cargoProduct, cargoData) = RandomProduct(3, item => !(item is DockingBayData) && item.Shape.FitsWithin(emptyShape, out _, out _), required: true); if (cargoData == null) throw new InvalidLoadoutException("No compatible cargo bay found for entity!"); cargoData.Shape.FitsWithin(emptyShape, out var cargoRotation, out var cargoPosition); var cargo = ItemManager.CreateInstance(cargoProduct) as EquippableItem; cargo.Rotation = cargoRotation; if (!entity.TryEquip(cargo, cargoPosition)) throw new InvalidLoadoutException("Failed to equip selected cargo bay!"); emptyShape = entity.UnoccupiedSpace; var (capacitorProduct, capacitorData) = RandomProduct(2, item => item.Behaviors.Any(b => b is CapacitorData) && item.Shape.FitsWithin(emptyShape, out _, out _), required: true); if (capacitorData == null) throw new InvalidLoadoutException("No compatible capacitor found for entity!"); capacitorData.Shape.FitsWithin(emptyShape, out var capacitorRotation, out var capacitorPosition); var capacitor = ItemManager.CreateInstance(capacitorProduct) as EquippableItem; capacitor.Rotation = capacitorRotation; if (!entity.TryEquip(capacitor, capacitorPosition)) throw new InvalidLoadoutException("Failed to equip selected capacitor!"); // Cut 2 (docs/fire-control-cut.md, Q4): required equipment for anything that can fire -- unaided // fire is a last resort, not an alternative loadout choice. EquipHardpoints runs before FillInterior // (OutfitEntity), so entity.Weapons is already populated here. Zenith and anything else with no // weapon hardpoints gets none. // // Cut 6c, 6c.2: this used to be `required: true` and throw InvalidLoadoutException when no seller was // available -- a galaxy that happened not to draw a targeting design's manufacturer failed entity // generation outright, a single point of failure the ten-seed smoke never sampled. It degrades instead: // required: false, so RandomProducts' own manufacturer-widening fallback never fires and an unmet // requirement returns null quietly rather than searching outside the galaxy for one. An entity that // ends up with no targeting system fires through FireControl's unaided fallback // (GameplaySettings.UnaidedTracking, Cut 5.1) rather than not firing at all -- that floor is what makes // this degrade safe. The gap is logged so it surfaces as content debt (AetherDb loadout reports it per // seed) instead of vanishing silently. if (entity.Weapons.Any()) { emptyShape = entity.UnoccupiedSpace; var (targetingProduct, targetingData) = RandomProduct(2, item => item.Behaviors.Any(b => b is TargetingSystemData) && item.Shape.FitsWithin(emptyShape, out _, out _)); if (targetingData == null) { ItemManager.Log("No targeting system available for armed entity; it will fire unaided."); } else { targetingData.Shape.FitsWithin(emptyShape, out var targetingRotation, out var targetingPosition); var targetingSystem = ItemManager.CreateInstance(targetingProduct) as EquippableItem; targetingSystem.Rotation = targetingRotation; if (!entity.TryEquip(targetingSystem, targetingPosition)) throw new InvalidLoadoutException("Failed to equip selected targeting system!"); } } } } public class InvalidLoadoutException : Exception { public InvalidLoadoutException() { } public InvalidLoadoutException(string message) : base(message) { } public InvalidLoadoutException(string message, Exception inner) : base(message, inner) { } }