using Aetheria.State.Documents; using GameCult.Aetheria.State.Verse; using Random = CultMath.Random; public sealed class AetheriaDaemonLoadoutGenerator { private const double RareGenerationWeight = 0.05; private readonly AetheriaRuntimeCatalogSnapshot _catalog; private Random _random; private readonly uint _seed; private readonly int _zoneIndex; private readonly IReadOnlyDictionary _homeZones; private readonly IReadOnlyDictionary> _adjacency; private readonly double _priceExponent; private readonly bool _isPrelude; public AetheriaDaemonLoadoutGenerator( AetheriaRuntimeCatalogSnapshot catalog, uint seed, int zoneIndex, IReadOnlyDictionary homeZones, IReadOnlyDictionary> adjacency, double priceExponent = 0.5, bool isPrelude = false) { _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); _seed = seed == 0 ? 1u : seed; _random = new Random(_seed); _zoneIndex = zoneIndex; _homeZones = homeZones ?? throw new ArgumentNullException(nameof(homeZones)); _adjacency = adjacency ?? throw new ArgumentNullException(nameof(adjacency)); _priceExponent = priceExponent; _isPrelude = isPrelude; } public AetheriaDaemonLoadout Build( string entityKind, string availabilityFactionKey) { var hullType = string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase) ? "Station" : "Ship"; var hull = Pick(availabilityFactionKey, 0, item => string.Equals(item.Category, AetheriaRuntimeItemCategories.Hull, StringComparison.Ordinal) && string.Equals(item.HullType, hullType, StringComparison.Ordinal)) ?? throw new InvalidOperationException($"No available {hullType} hull for faction {availabilityFactionKey}."); var occupied = new HashSet<(int X, int Y)>(); foreach (var hardpoint in hull.Hardpoints ?? Array.Empty()) foreach (var cell in hardpoint.ShapeCells ?? Array.Empty()) occupied.Add((hardpoint.PositionX + cell.X, hardpoint.PositionY + cell.Y)); var slots = new List(); if (string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase)) AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 2, item => string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal)); var previous = new List(); foreach (var hardpoint in (hull.Hardpoints ?? Array.Empty()) .OrderByDescending(value => value.OccupiedCells)) { var controllerKind = string.Equals(hardpoint.Type, "ControlModule", StringComparison.Ordinal) ? string.Equals(entityKind, "ship", StringComparison.OrdinalIgnoreCase) ? "Cockpit" : "TurretController" : ""; var item = previous.FirstOrDefault(candidate => FitsHardpoint(candidate, hardpoint)); item ??= PickHardpoint(availabilityFactionKey, hardpoint, candidate => IsGear(candidate) && FitsHardpoint(candidate, hardpoint) && (controllerKind.Length == 0 || HasBehavior(candidate, controllerKind))); if (item == null) { if (controllerKind.Length > 0) throw new InvalidOperationException($"No available {controllerKind} fits {hardpoint.Type}."); continue; } var rotation = ParseRotation(hardpoint.Rotation); slots.Add(Slot(hardpoint.PositionX, hardpoint.PositionY, item.ItemKey, rotation)); Reserve(occupied, item, hardpoint.PositionX, hardpoint.PositionY, rotation); previous.Add(item); } var cargoBay = AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 3, item => string.Equals(item.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal) && item.InteriorOccupiedCells >= 8); AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 2, item => IsGear(item) && HasBehavior(item, "Reactor")); AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 2, item => IsGear(item) && HasBehavior(item, "Capacitor")); var cargo = PackCargo(cargoBay, availabilityFactionKey, string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase)); var equipment = slots .Where(value => !IsBay(_catalog.FindItem(value.ItemKey))) .ToArray(); var cargoBays = slots .Where(value => string.Equals( _catalog.FindItem(value.ItemKey)?.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal)) .ToArray(); var dockingBays = slots .Where(value => string.Equals( _catalog.FindItem(value.ItemKey)?.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal)) .ToArray(); var selectedKeys = new[] { (Role: "hull", Key: hull.ItemKey) } .Concat(equipment.Select(value => (Role: "equipment", Key: value.ItemKey))) .Concat(cargoBays.Select(value => (Role: "cargo-bay", Key: value.ItemKey))) .Concat(dockingBays.Select(value => (Role: "docking-bay", Key: value.ItemKey))) .Concat(cargo.Select(value => (Role: "cargo", Key: value.Item.ItemKey))); var receipt = new AetheriaLoadoutGenerationReceipt { Seed = _seed, SourceZoneIndex = _zoneIndex, AvailabilityFactionKey = availabilityFactionKey, PriceExponent = _priceExponent, Selections = selectedKeys.Select(value => Selection(value.Role, value.Key, availabilityFactionKey)).ToArray() }; var defaultWeaponGroup = equipment .Select((slot, index) => (slot, index)) .Where(value => IsWeapon(_catalog.FindItem(value.slot.ItemKey))) .Select(value => value.index) .ToArray(); var weaponGroups = defaultWeaponGroup.Length == 0 ? Array.Empty() : new[] { defaultWeaponGroup }; return new AetheriaDaemonLoadout( hull.ItemKey, equipment, cargoBays, dockingBays, cargo, weaponGroups, receipt); } private AetheriaLoadoutGenerationSelection Selection(string role, string itemKey, string factionKey) { var item = _catalog.FindItem(itemKey) ?? throw new InvalidOperationException($"Generated item {itemKey} left the catalog."); var faction = _catalog.FindCorporation(factionKey); var allegiance = string.Equals(item.ManufacturerKey, factionKey, StringComparison.Ordinal) ? 1 : (faction?.Allegiances ?? Array.Empty()) .FirstOrDefault(value => value.CorporationKey == item.ManufacturerKey)?.Weight ?? 0; return new AetheriaLoadoutGenerationSelection { Role = role, ItemKey = item.ItemKey, ManufacturerKey = item.ManufacturerKey, Price = item.Price, ManufacturerDistance = _homeZones.TryGetValue(item.ManufacturerKey, out var home) ? Distance(_zoneIndex, home) : 1, Allegiance = allegiance }; } private AetheriaRuntimeCatalogItem AddFreeSpaceItem( AetheriaRuntimeCatalogItem hull, string factionKey, HashSet<(int X, int Y)> occupied, List slots, double sizeExponent, Func filter) { var candidates = Available(factionKey) .Where(filter) .Select(item => (Item: item, Fit: FindFit(hull, item, occupied))) .Where(value => value.Fit.HasValue) .ToArray(); var selected = PickWeighted(candidates, sizeExponent, factionKey, value => value.Item); if (selected.Item == null || !selected.Fit.HasValue) throw new InvalidOperationException("No available mandatory equipment fits the remaining hull space."); var fit = selected.Fit.Value; slots.Add(Slot(fit.X, fit.Y, selected.Item.ItemKey, fit.Rotation)); Reserve(occupied, selected.Item, fit.X, fit.Y, fit.Rotation); return selected.Item; } private AetheriaRuntimeCatalogItem? TryAddFreeSpaceItem( AetheriaRuntimeCatalogItem hull, string factionKey, HashSet<(int X, int Y)> occupied, List slots, double sizeExponent, Func filter) { var candidates = Available(factionKey) .Where(filter) .Select(item => (Item: item, Fit: FindFit(hull, item, occupied))) .Where(value => value.Fit.HasValue) .ToArray(); var selected = PickWeighted(candidates, sizeExponent, factionKey, value => value.Item); if (selected.Item == null || !selected.Fit.HasValue) return null; var fit = selected.Fit.Value; slots.Add(Slot(fit.X, fit.Y, selected.Item.ItemKey, fit.Rotation)); Reserve(occupied, selected.Item, fit.X, fit.Y, fit.Rotation); return selected.Item; } private AetheriaLoadoutItemSlot[] PackCargo( AetheriaRuntimeCatalogItem cargoBay, string factionKey, bool includeStationInventory) { var candidates = new List(); if (includeStationInventory) { var pool = Available(factionKey) .Where(item => !string.Equals(item.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal) && !string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal) && (!string.Equals(item.Category, AetheriaRuntimeItemCategories.Hull, StringComparison.Ordinal) || string.Equals(item.HullType, "Ship", StringComparison.Ordinal))) .ToList(); for (var draw = 0; draw < 16 && pool.Count > 0; draw++) { var selected = PickWeighted(pool, 1, factionKey, item => item); if (selected == null) break; candidates.Add(selected); pool.Remove(selected); } } var interiorCells = Cells(cargoBay.InteriorShapeCells); var width = Math.Max(1, cargoBay.InteriorShapeWidth); var height = Math.Max(1, cargoBay.InteriorShapeHeight); var occupied = new HashSet<(int X, int Y)>(); var packed = new List(); foreach (var item in candidates.OrderByDescending(value => value.OccupiedCells)) { var fit = FindFit(interiorCells, width, height, item, occupied); if (!fit.HasValue) continue; Reserve(occupied, item, fit.Value.X, fit.Value.Y, fit.Value.Rotation); packed.Add(CargoSlot(fit.Value.X, fit.Value.Y, item.ItemKey, fit.Value.Rotation)); } return packed.ToArray(); } private AetheriaRuntimeCatalogItem? Pick( string factionKey, double sizeExponent, Func filter) { return PickWeighted(Available(factionKey).Where(filter).ToArray(), sizeExponent, factionKey, item => item); } private AetheriaRuntimeCatalogItem? PickHardpoint( string factionKey, AetheriaRuntimeHardpoint hardpoint, Func filter) { var candidates = Available(factionKey).Where(filter).ToArray(); if (candidates.Length == 0) return null; var largestFootprint = candidates.Max(item => item.OccupiedCells); return PickWeighted( candidates.Where(item => item.OccupiedCells == largestFootprint).ToArray(), 2, factionKey, item => item); } private T PickWeighted( IReadOnlyList values, double sizeExponent, string factionKey, Func itemOf) { if (values.Count == 0) return default!; var weights = values.Select(value => Weight(itemOf(value), factionKey, sizeExponent)).ToArray(); var total = weights.Sum(); if (total <= 0) return default!; var cursor = _random.NextFloat() * total; for (var i = 0; i < values.Count; i++) { cursor -= weights[i]; if (cursor <= 0) return values[i]; } return values[^1]; } private IEnumerable Available(string factionKey) { var faction = _catalog.Corporations.FirstOrDefault(value => string.Equals(value.CorporationKey, factionKey, StringComparison.Ordinal)); if (faction == null) yield break; var allegiances = (faction.Allegiances ?? Array.Empty()) .ToDictionary(value => value.CorporationKey, value => value.Weight, StringComparer.Ordinal); foreach (var item in _catalog.EquipmentItems) { if (item.Price <= 0 || string.IsNullOrWhiteSpace(item.ManufacturerKey)) continue; if (!_isPrelude && (!_homeZones.ContainsKey(item.ManufacturerKey) || !allegiances.ContainsKey(item.ManufacturerKey))) continue; yield return item; } } private double Weight(AetheriaRuntimeCatalogItem item, string factionKey, double sizeExponent) { var faction = _catalog.Corporations.First(value => value.CorporationKey == factionKey); double allegiance; if (string.Equals(item.ManufacturerKey, factionKey, StringComparison.Ordinal)) { allegiance = 1; } else { var authoredWeight = (faction.Allegiances ?? Array.Empty()) .FirstOrDefault(value => value.CorporationKey == item.ManufacturerKey)?.Weight ?? 0; var distance = _homeZones.TryGetValue(item.ManufacturerKey, out var home) ? Distance(_zoneIndex, home) : 1; allegiance = authoredWeight / Math.Max(1, distance); } return allegiance * Math.Pow(Math.Max(1, item.OccupiedCells), sizeExponent) / Math.Pow(item.Price, _priceExponent) * RarityWeight(item); } private static double RarityWeight(AetheriaRuntimeCatalogItem item) => (item.Tags ?? Array.Empty()).Contains("rarity:rare", StringComparer.Ordinal) ? RareGenerationWeight : 1; private int Distance(int start, int target) { if (start == target) return 1; var visited = new HashSet { start }; var queue = new Queue<(int Zone, int Distance)>(); queue.Enqueue((start, 0)); while (queue.Count > 0) { var current = queue.Dequeue(); foreach (var adjacent in _adjacency.TryGetValue(current.Zone, out var values) ? values : Array.Empty()) { if (!visited.Add(adjacent)) continue; if (adjacent == target) return current.Distance + 1; queue.Enqueue((adjacent, current.Distance + 1)); } } return 1; } private static bool FitsHardpoint(AetheriaRuntimeCatalogItem item, AetheriaRuntimeHardpoint hardpoint) { if (!string.Equals(item.HardpointType, hardpoint.Type, StringComparison.Ordinal)) return false; var target = Cells(hardpoint.ShapeCells); return RotatedCells(item, ParseRotation(hardpoint.Rotation)).All(target.Contains); } private static (int X, int Y, int Rotation)? FindFit( AetheriaRuntimeCatalogItem hull, AetheriaRuntimeCatalogItem item, HashSet<(int X, int Y)> occupied) { return FindFit(Cells(hull.ShapeCells), Math.Max(1, hull.ShapeWidth), Math.Max(1, hull.ShapeHeight), item, occupied); } private static (int X, int Y, int Rotation)? FindFit( HashSet<(int X, int Y)> targetCells, int width, int height, AetheriaRuntimeCatalogItem item, HashSet<(int X, int Y)> occupied) { for (var rotation = 0; rotation < 4; rotation++) { var cells = RotatedCells(item, rotation).ToArray(); for (var y = 0; y < height; y++) for (var x = 0; x < width; x++) { if (cells.All(cell => targetCells.Contains((cell.X + x, cell.Y + y)) && !occupied.Contains((cell.X + x, cell.Y + y)))) return (x, y, rotation); } } return null; } private static void Reserve(HashSet<(int X, int Y)> occupied, AetheriaRuntimeCatalogItem item, int x, int y, int rotation) { foreach (var cell in RotatedCells(item, rotation)) occupied.Add((cell.X + x, cell.Y + y)); } private static HashSet<(int X, int Y)> Cells(IReadOnlyList? cells) => (cells ?? Array.Empty()).Select(value => (value.X, value.Y)).ToHashSet(); private static IEnumerable<(int X, int Y)> RotatedCells(AetheriaRuntimeCatalogItem item, int rotation) => AetheriaRuntimeEquipmentGridGeometry.RotatedCells(item, rotation); private static int ParseRotation(string value) => AetheriaRuntimeEquipmentGridGeometry.ParseRotation(value); private static string RotationName(int rotation) => AetheriaRuntimeEquipmentGridGeometry.RotationName(rotation); private static bool HasBehavior(AetheriaRuntimeCatalogItem item, string kind) => (item.BehaviorKinds ?? Array.Empty()).Contains(kind, StringComparer.Ordinal); private static bool IsGear(AetheriaRuntimeCatalogItem item) => string.Equals(item.Category, AetheriaRuntimeItemCategories.Gear, StringComparison.Ordinal) || string.Equals(item.Category, AetheriaRuntimeItemCategories.Weapon, StringComparison.Ordinal); private static bool IsWeapon(AetheriaRuntimeCatalogItem? item) => item != null && (string.Equals(item.Category, AetheriaRuntimeItemCategories.Weapon, StringComparison.Ordinal) || (item.BehaviorKinds ?? Array.Empty()).Any(kind => kind.Contains("Weapon", StringComparison.Ordinal))); private static bool IsBay(AetheriaRuntimeCatalogItem? item) => item != null && (string.Equals(item.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal) || string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal)); private static AetheriaEntityItemSlot Slot(int x, int y, string itemKey, int rotation = 0) => new() { Position = new AetheriaGridCoord { X = x, Y = y }, ItemKey = itemKey, Quality = 1, Durability = 1, Quantity = 1, Enabled = true, Rotation = RotationName(rotation) }; private static AetheriaLoadoutItemSlot CargoSlot(int index, string itemKey) => CargoSlot(index % 4, index / 4, itemKey); private static AetheriaLoadoutItemSlot CargoSlot(int x, int y, string itemKey, int rotation = 0) => new() { Position = new AetheriaGridCoord { X = x, Y = y }, Item = new AetheriaLoadoutItem { ItemKey = itemKey, Quality = 1, Durability = 1, Quantity = 1, Enabled = true }, Rotation = RotationName(rotation) }; } public sealed record AetheriaDaemonLoadout( string HullItemKey, AetheriaEntityItemSlot[] Equipment, AetheriaEntityItemSlot[] CargoBays, AetheriaEntityItemSlot[] DockingBays, AetheriaLoadoutItemSlot[] Cargo, int[][] WeaponGroups, AetheriaLoadoutGenerationReceipt Receipt);