Generate stations that respect empty hardpoints and required items

UnoccupiedSpace now counts empty hardpoint cells as free, matching
ItemFits: general gear may use a hardpoint until hardpoint gear claims
it. LoadoutGenerator equips hardpoint gear before the docking bay, so the
bay cannot take the reactor's slot. Hulls, docking bays, controllers,
cargo bays and capacitors are required: if no manufacturer present in the
galaxy and known to the faction makes one, any manufacturer is used and
the database gap is logged, so a galaxy without AU still gets stations.
Manufacturer preference is allegiance / (1 + hops to headquarters); the
distance term previously never applied due to operator precedence, and
would have divided by zero in the headquarters zone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-11 23:23:19 +02:00
commit 25ecb46fcd
2 changed files with 71 additions and 32 deletions

View file

@ -420,7 +420,8 @@ public abstract class Entity
var emptyShape = new Shape(HullData.Shape.Width, HullData.Shape.Height);
foreach (var v in HullData.Shape.Coordinates)
{
if (HullData.InteriorCells[v] && GearOccupancy[v.x, v.y] == null && Hardpoints[v.x,v.y] == null)
// Empty hardpoint cells count as free: general gear may use them until hardpoint gear claims them
if (HullData.InteriorCells[v] && GearOccupancy[v.x, v.y] == null)
emptyShape[v] = true;
}

View file

@ -74,10 +74,13 @@ public class LoadoutGenerator
var hull = ItemManager.CreateInstance(hullData) as EquippableItem;
var entity = new OrbitalEntity(ItemManager, null, hull, Guid.Empty, 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 dockingBayData = RandomItem<DockingBayData>(2, item => item.Shape.FitsWithin(emptyShape, out _, out _));
var dockingBayData = RandomItem<DockingBayData>(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);
@ -87,8 +90,8 @@ public class LoadoutGenerator
{
throw new InvalidLoadoutException("Failed to equip selected docking bay!");
}
OutfitEntity(entity);
FillInterior(entity);
var cargo = entity.CargoBays.First();
IEnumerable<EquippableItemData> inventory = RandomItems<EquippableItemData>(16, 1,
@ -106,46 +109,76 @@ public class LoadoutGenerator
return EntitySerializer.Pack(entity) as OrbitalEntityPack;
}
// Every entity needs a hull, so hulls are always required
public HullData RandomHull(HullType type, Predicate<HullData> hullFilter = null)
{
return RandomItem<HullData>(0, item =>
return RandomItem<HullData>(0, item =>
(hullFilter?.Invoke(item) ?? true) &&
item.HullType == type);
}
public T[] RandomItems<T>(int count, float sizeExponent, Predicate<T> filter = null) where T : EquippableItemData
{
return ItemManager.ItemData.GetAll<T>()
.Where(item =>
item.Price > 0 &&
item.Manufacturer != Guid.Empty &&
(Galaxy.IsPrelude || Galaxy.ContainsFaction(item.Manufacturer) &&
(Faction == null || Faction.Allegiance.ContainsKey(item.Manufacturer))) &&
(filter?.Invoke(item) ?? true))
.WeightedRandomElements(ref Random, item =>
Faction == null ? 1 :
(item.Manufacturer == Faction.ID ? 1 : Faction.Allegiance.ContainsKey(item.Manufacturer) ? Faction.Allegiance[item.Manufacturer] : 0.0f / // Prioritize items from allied manufacturers
(Galaxy?.ContainsFaction(item.Manufacturer) ?? false ? Zone?.Distance[Galaxy.HomeZones[ItemManager.ItemData.Get<Faction>(item.Manufacturer)]] ?? 1 : 1)) * // Penalize distance to manufacturer headquarters
pow(item.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(item.Price, PriceExponent), // Penalize item price to a controllable degree
count
);
item.HullType == type, required: true);
}
public T RandomItem<T>(float sizeExponent, Predicate<T> filter = null) where T : EquippableItemData
// Items 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; that means the item
// database lacks variety, so it is logged rather than hidden.
public T[] RandomItems<T>(int count, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
{
return RandomItems(1, sizeExponent, filter).FirstOrDefault();
var candidates = ItemManager.ItemData.GetAll<T>()
.Where(item =>
item.Price > 0 &&
item.Manufacturer != Guid.Empty &&
(filter?.Invoke(item) ?? true))
.ToArray();
var available = candidates.Where(IsAvailable).ToArray();
var preferManufacturers = true;
if (available.Length == 0 && required && candidates.Length > 0)
{
ItemManager.Log($"No {typeof(T).Name} available from manufacturers known to {Faction?.Name ?? "this galaxy"}; using any manufacturer. The item database needs more variety.");
available = candidates;
preferManufacturers = false;
}
return available.WeightedRandomElements(ref Random, item =>
(preferManufacturers ? ManufacturerPreference(item.Manufacturer) : 1) *
pow(item.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(item.Price, PriceExponent), // Penalize item price to a controllable degree
count);
}
public T RandomItem<T>(HardpointData hardpoint, float sizeExponent, Predicate<T> filter = null) where T : EquippableItemData
private bool IsAvailable(EquippableItemData item) =>
Galaxy.IsPrelude ||
Galaxy.ContainsFaction(item.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(item.Manufacturer));
// Prioritize items from the zone faction and its allies, penalizing distance to the manufacturer's headquarters
private float ManufacturerPreference(Guid manufacturer)
{
if (Faction == null) return 1;
var allegiance = manufacturer == Faction.ID ? 1 :
Faction.Allegiance.TryGetValue(manufacturer, out var a) ? a : 0;
var home = Galaxy.HomeZones.FirstOrDefault(h => h.Key.ID == manufacturer).Value;
var distance = home != null && Zone?.Distance != null && Zone.Distance.TryGetValue(home, out var d) ? d : 0;
return allegiance / (1 + distance);
}
public T RandomItem<T>(float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
{
return RandomItems(1, sizeExponent, filter, required).FirstOrDefault();
}
public T RandomItem<T>(HardpointData hardpoint, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
{
return RandomItem<T>(sizeExponent, item => item.HardpointType == hardpoint.Type &&
(filter?.Invoke(item) ?? true) &&
(filter?.Invoke(item) ?? true) &&
item.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) &&
item.Shape.Coordinates.Length==hardpoint.Shape.Coordinates.Length);
item.Shape.Coordinates.Length==hardpoint.Shape.Coordinates.Length, required);
}
private void OutfitEntity(Entity entity)
{
EquipHardpoints(entity);
FillInterior(entity);
}
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;
@ -155,7 +188,7 @@ public class LoadoutGenerator
if (hardpoint.Type == HardpointType.ControlModule)
{
var controllerData = RandomItem<GearData>(hardpoint, 2,
item => item.Behaviors.Any(b => entity is Ship && b is CockpitData || entity is OrbitalEntity && b is TurretControllerData));
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(controllerData) as EquippableItem;
@ -188,11 +221,16 @@ public class LoadoutGenerator
}
}
}
// Interior gear may use any free interior cell, including empty hardpoint cells
private void FillInterior(Entity entity)
{
var emptyShape = entity.UnoccupiedSpace;
var cargoData = RandomItem<CargoBayData>(3, item =>
!(item is DockingBayData) &&
item.Shape.FitsWithin(emptyShape, out _, out _));
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);
@ -205,7 +243,7 @@ public class LoadoutGenerator
var capacitorData = RandomItem<GearData>(2, item =>
item.Behaviors.Any(b => b is CapacitorData) &&
item.Shape.FitsWithin(emptyShape, out _, out _));
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);