Use typed rows for loadout selection

This commit is contained in:
Meta Vens 2026-06-14 05:25:56 +01:00
commit ce8adfbaf2
4 changed files with 135 additions and 83 deletions

Binary file not shown.

View file

@ -61,6 +61,11 @@ public class ItemManager
public AetheriaRuntimeCatalogItem GetRuntimeItem(ItemInstance item)
{
var itemId = item?.Data?.ItemId ?? Guid.Empty;
return GetRuntimeItem(itemId);
}
public AetheriaRuntimeCatalogItem GetRuntimeItem(Guid itemId)
{
return itemId == Guid.Empty ? null : _itemProjections.GetRuntimeItem(itemId);
}

View file

@ -47,7 +47,7 @@ public class LoadoutGenerator
// PriceExponent = priceExponent;
// }
public RuntimeEntityBlueprint GenerateShipLoadout(Predicate<HullData> hullFilter = null)
public RuntimeEntityBlueprint GenerateShipLoadout(Predicate<AetheriaRuntimeCatalogItem> hullFilter = null)
{
var hullData = RandomHull(HullType.Ship, hullFilter);
if(hullData==null)
@ -93,12 +93,11 @@ public class LoadoutGenerator
var emptyShape = entity.UnoccupiedSpace;
var dockingBayData = RandomItem<DockingBayData>(2,
item => item.Shape.FitsWithin(emptyShape, out _, out _),
item => FitsWithin(item, emptyShape));
var dockingBayRow = RandomCatalogItem<DockingBayData>(2, item => FitsWithin(item, emptyShape));
var dockingBayData = ProjectRuntimeItem<DockingBayData>(dockingBayRow);
if (dockingBayData == null) throw new InvalidLoadoutException("No compatible docking bay found for station!");
dockingBayData.Shape.FitsWithin(emptyShape, out var cargoRotation, out var cargoPosition);
ToShape(dockingBayRow).FitsWithin(emptyShape, out var cargoRotation, out var cargoPosition);
var dockingBay = ItemManager.CreateInstance(dockingBayData) as EquippableItem;
dockingBay.Rotation = cargoRotation;
if (!entity.TryEquip(dockingBay, cargoPosition))
@ -109,10 +108,12 @@ public class LoadoutGenerator
OutfitEntity(entity);
var cargo = entity.CargoBays.First();
IEnumerable<EquippableItemData> inventory = RandomItems<EquippableItemData>(16, 1,
data => !(data is HullData hull && hull.HullType != HullType.Ship) && !(data is CargoBayData),
item => item.Category != "CargoBayData" && item.Category != "DockingBayData" &&
IEnumerable<EquippableItemData> inventory = RandomCatalogItems<EquippableItemData>(16, 1,
item => item.Category != "CargoBayData" && item.Category != "DockingBayData" &&
(item.Category != "HullData" || item.HullType == nameof(HullType.Ship)));
inventory = inventory
.Select(ProjectRuntimeItem<EquippableItemData>)
.Where(item => item != null);
inventory = inventory
.OrderByDescending(item=>item.Shape.Coordinates.Length);
foreach (var item in inventory)
@ -126,18 +127,17 @@ public class LoadoutGenerator
return RuntimeEntityBlueprintProjector.CaptureBlueprint(entity) as RuntimeOrbitalEntityBlueprint;
}
public HullData RandomHull(HullType type, Predicate<HullData> hullFilter = null)
public HullData RandomHull(HullType type, Predicate<AetheriaRuntimeCatalogItem> hullFilter = null)
{
return RandomItem<HullData>(0, item =>
(hullFilter?.Invoke(item) ?? true) &&
item.HullType == type,
item => string.Equals(item.HullType, type.ToString(), StringComparison.Ordinal));
var hullRow = RandomCatalogItem<HullData>(0, item =>
string.Equals(item.HullType, type.ToString(), StringComparison.Ordinal) &&
(hullFilter?.Invoke(item) ?? true));
return ProjectRuntimeItem<HullData>(hullRow);
}
public T[] RandomItems<T>(
public AetheriaRuntimeCatalogItem[] RandomCatalogItems<T>(
int count,
float sizeExponent,
Predicate<T> filter = null,
Predicate<AetheriaRuntimeCatalogItem> typedFilter = null) where T : EquippableItemData
{
return RuntimeCatalog.EquipmentItems
@ -149,20 +149,24 @@ public class LoadoutGenerator
(Galaxy.IsPrelude || Galaxy.ContainsFaction(manufacturer) &&
(Faction == null || Faction.Allegiance.ContainsKey(manufacturer))) &&
(typedFilter?.Invoke(item) ?? true))
.Select(ProjectRuntimeItem<T>)
.Where(item => item != null)
.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
ManufacturerDistancePenalty(item.Manufacturer)) * // 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
{
if (!Guid.TryParse(item.ManufacturerLegacyId, out var manufacturer))
{
return 0;
}
var allegianceWeight = Faction == null
? 1
: manufacturer == Faction.ID
? 1
: Faction.Allegiance.TryGetValue(manufacturer, out var allegiance)
? allegiance / ManufacturerDistancePenalty(manufacturer)
: 0;
return allegianceWeight *
pow(item.OccupiedCells, sizeExponent) / // Prioritize larger items
pow(item.Price, PriceExponent); // Penalize item price to a controllable degree
},
count
);
}
@ -190,6 +194,11 @@ public class LoadoutGenerator
private T ProjectRuntimeItem<T>(AetheriaRuntimeCatalogItem item) where T : EquippableItemData
{
if (item == null)
{
return null;
}
return Guid.TryParse(item.LegacyId, out var legacyId)
? ItemManager.GetRuntimeItemProjection<T>(legacyId)
: null;
@ -208,30 +217,27 @@ public class LoadoutGenerator
: 1;
}
public T RandomItem<T>(
public AetheriaRuntimeCatalogItem RandomCatalogItem<T>(
float sizeExponent,
Predicate<T> filter = null,
Predicate<AetheriaRuntimeCatalogItem> typedFilter = null) where T : EquippableItemData
{
return RandomItems(1, sizeExponent, filter, typedFilter).FirstOrDefault();
return RandomCatalogItems<T>(1, sizeExponent, typedFilter).FirstOrDefault();
}
public T RandomItem<T>(HardpointData hardpoint, float sizeExponent, Predicate<T> filter = null) where T : EquippableItemData
public AetheriaRuntimeCatalogItem RandomCatalogItem<T>(AetheriaRuntimeHardpoint hardpoint, float sizeExponent, Predicate<AetheriaRuntimeCatalogItem> filter = null) where T : EquippableItemData
{
return RandomItem<T>(
return RandomCatalogItem<T>(
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,
item => FitsHardpoint(item, hardpoint));
item => FitsHardpoint(item, hardpoint) && (filter?.Invoke(item) ?? true));
}
private static bool FitsHardpoint(AetheriaRuntimeCatalogItem item, HardpointData hardpoint)
private static bool FitsHardpoint(AetheriaRuntimeCatalogItem item, AetheriaRuntimeHardpoint hardpoint)
{
return string.Equals(item.HardpointType, hardpoint.Type.ToString(), StringComparison.Ordinal) &&
item.OccupiedCells == hardpoint.Shape.Coordinates.Length &&
FitsWithin(item, hardpoint.Shape, hardpoint.Rotation);
return item != null &&
hardpoint != null &&
string.Equals(item.HardpointType, hardpoint.Type, StringComparison.Ordinal) &&
item.OccupiedCells == hardpoint.OccupiedCells &&
FitsWithin(item, ToShape(hardpoint), GetRotation(hardpoint));
}
private static bool HasBehaviorKind(AetheriaRuntimeCatalogItem item, string behaviorKind)
@ -242,12 +248,42 @@ public class LoadoutGenerator
private static bool FitsWithin(AetheriaRuntimeCatalogItem item, Shape target)
{
return ToShape(item.ShapeWidth, item.ShapeHeight, item.ShapeCells).FitsWithin(target, out _, out _);
if (item == null)
{
return false;
}
return ToShape(item).FitsWithin(target, out _, out _);
}
private static bool FitsWithin(AetheriaRuntimeCatalogItem item, Shape target, ItemRotation rotation)
{
return ToShape(item.ShapeWidth, item.ShapeHeight, item.ShapeCells).FitsWithin(target, rotation, out _);
if (item == null)
{
return false;
}
return ToShape(item).FitsWithin(target, rotation, out _);
}
private static Shape ToShape(AetheriaRuntimeCatalogItem item)
{
if (item == null)
{
throw new InvalidLoadoutException("Typed catalog shape was requested before a compatible item was selected.");
}
return ToShape(item.ShapeWidth, item.ShapeHeight, item.ShapeCells);
}
private static Shape ToShape(AetheriaRuntimeHardpoint hardpoint)
{
if (hardpoint == null)
{
throw new InvalidLoadoutException("Typed hardpoint shape was requested before a hardpoint was selected.");
}
return ToShape(hardpoint.ShapeWidth, hardpoint.ShapeHeight, hardpoint.ShapeCells);
}
private static Shape ToShape(int width, int height, IReadOnlyList<AetheriaRuntimeShapeCell> cells)
@ -261,64 +297,77 @@ public class LoadoutGenerator
return shape;
}
private static ItemRotation GetRotation(AetheriaRuntimeHardpoint hardpoint)
{
return Enum.TryParse(hardpoint.Rotation, out ItemRotation rotation)
? rotation
: ItemRotation.None;
}
private void OutfitEntity(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 previousItems = new List<EquippableItemData>();
foreach (var hardpoint in hullData.Hardpoints.OrderByDescending(h=>h.Shape.Coordinates.Length))
var typedHull = ItemManager.GetRuntimeItem(entity.Hull);
if (typedHull == null)
{
if (hardpoint.Type == HardpointType.ControlModule)
throw new InvalidLoadoutException("Selected hull is missing from the typed runtime catalog.");
}
foreach (var v in ToShape(typedHull).Coordinates) entity.HullConductivity[v.x, v.y] = true;
var previousItems = new List<AetheriaRuntimeCatalogItem>();
foreach (var hardpoint in typedHull.Hardpoints.OrderByDescending(h=>h.OccupiedCells))
{
if (hardpoint.Type == nameof(HardpointType.ControlModule))
{
var controllerBehaviorKind = entity is Ship
? nameof(CockpitData)
: entity is OrbitalEntity
? nameof(TurretControllerData)
: null;
var controllerData = RandomItem<GearData>(hardpoint, 2,
item => item.Behaviors.Any(b => entity is Ship && b is CockpitData || entity is OrbitalEntity && b is TurretControllerData),
item => HasBehaviorKind(item, controllerBehaviorKind));
var controllerRow = RandomCatalogItem<GearData>(hardpoint, 2, item => HasBehaviorKind(item, controllerBehaviorKind));
var controllerData = ProjectRuntimeItem<GearData>(controllerRow);
if (controllerData == null)
throw new InvalidLoadoutException("No compatible controller found for entity!");
var controller = ItemManager.CreateInstance(controllerData) as EquippableItem;
if (!entity.TryEquip(controller))
{
throw new InvalidLoadoutException($"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!");
throw new InvalidLoadoutException($"Failed to equip selected {hardpoint.Type}!");
}
}
else
{
// If a previously selected item fits, use that one (this is why we must process larger hardpoints first)
var itemData = previousItems
.FirstOrDefault(i => i.HardpointType == hardpoint.Type && i.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _));
var previousItem = entity.Equipment.FirstOrDefault(item => item.Data == itemData);
itemData ??= RandomItem<GearData>(hardpoint, 2);
if (itemData == null) ItemManager.Log($"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!");
var itemRow = previousItems.FirstOrDefault(i => FitsHardpoint(i, hardpoint));
var previousItem = itemRow == null
? null
: Guid.TryParse(itemRow.LegacyId, out var previousItemId)
? entity.Equipment.FirstOrDefault(item => item.EquippableItem.Data.ItemId == previousItemId)
: null;
itemRow ??= RandomCatalogItem<GearData>(hardpoint, 2);
var itemData = ProjectRuntimeItem<GearData>(itemRow);
if (itemData == null) ItemManager.Log($"No compatible item found for entity {hardpoint.Type} hardpoint!");
else
{
//throw new InvalidLoadoutException($"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!");
//throw new InvalidLoadoutException($"No compatible item found for entity {hardpoint.Type} hardpoint!");
EquippableItem item;
if(previousItem!=null)
item = ItemManager.CreateInstance(itemData, previousItem.EquippableItem.Quality) as EquippableItem;
else item = ItemManager.CreateInstance(itemData) as EquippableItem;
if (!entity.TryEquip(item))
{
throw new InvalidLoadoutException($"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!");
throw new InvalidLoadoutException($"Failed to equip selected {hardpoint.Type}!");
}
previousItems.Add(itemData);
previousItems.Add(itemRow);
}
}
}
var emptyShape = entity.UnoccupiedSpace;
var cargoData = RandomItem<CargoBayData>(3,
item => !(item is DockingBayData) &&
item.Shape.FitsWithin(emptyShape, out _, out _),
item => item.Category != "DockingBayData" && FitsWithin(item, emptyShape));
var cargoRow = RandomCatalogItem<CargoBayData>(3, item => item.Category != "DockingBayData" && FitsWithin(item, emptyShape));
var cargoData = ProjectRuntimeItem<CargoBayData>(cargoRow);
if (cargoData == null) throw new InvalidLoadoutException("No compatible cargo bay found for entity!");
cargoData.Shape.FitsWithin(emptyShape, out var cargoRotation, out var cargoPosition);
ToShape(cargoRow).FitsWithin(emptyShape, out var cargoRotation, out var cargoPosition);
var cargo = ItemManager.CreateInstance(cargoData) as EquippableItem;
cargo.Rotation = cargoRotation;
if (!entity.TryEquip(cargo, cargoPosition))
@ -326,14 +375,13 @@ public class LoadoutGenerator
emptyShape = entity.UnoccupiedSpace;
var capacitorData = RandomItem<GearData>(2,
item => item.Behaviors.Any(b => b is CapacitorData) &&
item.Shape.FitsWithin(emptyShape, out _, out _),
var capacitorRow = RandomCatalogItem<GearData>(2,
item => item.BehaviorKinds.Contains(nameof(CapacitorData), StringComparer.Ordinal) &&
FitsWithin(item, emptyShape));
var capacitorData = ProjectRuntimeItem<GearData>(capacitorRow);
if (capacitorData == null) throw new InvalidLoadoutException("No compatible capacitor found for entity!");
capacitorData.Shape.FitsWithin(emptyShape, out var capacitorRotation, out var capacitorPosition);
ToShape(capacitorRow).FitsWithin(emptyShape, out var capacitorRotation, out var capacitorPosition);
var capacitor = ItemManager.CreateInstance(capacitorData) as EquippableItem;
capacitor.Rotation = capacitorRotation;
if (!entity.TryEquip(capacitor, capacitorPosition))

View file

@ -185,10 +185,13 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted.
and mass/thermal mass from runtime catalog rows. `Entity.MapEntity`,
`Entity.UnoccupiedSpace`, grid-offset placement, and `Entity.UpdateTemperature`
also read typed hull shape/interior, hardpoints, base armor, mass, specific
heat, and conductivity from runtime catalog rows. `EquippedItem` inset-shape
and behavior construction, docking bay max-size, and loadout generator helper
predicates still belong to legacy simulation projections until those owners
are rebuilt. Ship drag, combat/turret shot prediction height, and thruster
heat, and conductivity from runtime catalog rows. `LoadoutGenerator`
candidate weighting, hardpoint/cargo/capacitor fit, selected-item reuse, and
hull conductivity setup now use typed runtime catalog rows; selected DTO
hydration is only the remaining item-instantiation bridge. `EquippedItem`
inset-shape and behavior construction plus docking bay max-size still belong
to legacy simulation projections until those owners are rebuilt. Ship drag,
combat/turret shot prediction height, and thruster
torque geometry now consume typed hull facets and typed shape masks.
The item properties panel also reads typed catalog title names, descriptions,
manufacturer, base mass, max durability, thermal bounds, and thermal
@ -648,15 +651,11 @@ First Aetheria surfaces to publish:
- Done: move entity faction restore and loadout manufacturer-distance
weighting to `Galaxy.ResolveFaction`, so legacy `Faction` catalog entries
no longer decide runtime faction references after generation.
- Done: move `LoadoutGenerator` item candidate selection to the typed
runtime catalog; legacy item DTO hydration remains only for exact fitting
checks and behavior construction.
- Done: move loadout hardpoint fit, station bay fit, hull/category, and
behavior-kind prefilters ahead of DTO hydration so typed catalog rows
reject incompatible candidates before `GetRuntimeItemProjection<T>` is
called.
- Done: move loadout hull type and control-module behavior-kind checks onto
typed catalog rows before hydrating `HullData`/`BehaviorData` projections.
- Done: move `LoadoutGenerator` weighted candidate selection, hardpoint fit,
station bay fit, cargo/capacitor fit, selected-item reuse, hull/category,
hull type, hull conductivity setup, and behavior-kind prefilters onto typed
runtime catalog rows; selected legacy DTO projection remains only as the
item-instantiation bridge after a typed row wins.
- Done: delete unused `TradeMenuDebug`; the old debug uGUI trade path no
longer hydrates typed trade rows back into legacy `ItemData` objects.
- Done: move surviving trade menu size, hardpoint, and behavior filters onto