From 99e4f1d3e7d286ff6e81654ccf44c04539f1d2ca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 11 Jul 2026 20:32:33 +0100 Subject: [PATCH] Restore faction-aware loadout generation --- Aetheria.State.Daemon.Smoke/Program.cs | 66 +++++ .../AetheriaDaemonLoadoutGenerator.cs | 262 ++++++++++++++++++ .../AetheriaDaemonZoneGenerator.cs | 177 +++--------- Aetheria.State.Verify/Program.cs | 19 +- docs/original-game-migration-matrix.md | 2 +- 5 files changed, 380 insertions(+), 146 deletions(-) create mode 100644 Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 49e1aa828..8f0c74ab4 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -35,6 +35,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks RejectedHaulTransferDoesNotAdvanceTask(); AgentPatrolsHistoricalOrbitCircuitThroughMovementCommands(); TickReconcilesAndEvaluatesCatalogBehaviors(); + DaemonLoadoutsRespectFactionAvailabilityAndHullRoles(); AgentMinesAsteroidThroughEquippedBehavior(); CargoCapacityComesFromHullAndCatalogVolumes(); AgentSurveysBodyIntoCorporationKnowledge(); @@ -243,6 +244,71 @@ internal sealed class AetheriaDaemonYmirSmokeChecks RequireNear(50, behavior.EvaluateStat(4), 0.000001, "behavior query must evaluate mining range from catalog"); } + private static void DaemonLoadoutsRespectFactionAvailabilityAndHullRoles() + { + var cells4 = Enumerable.Range(0, 4).SelectMany(y => Enumerable.Range(0, 4) + .Select(x => new AetheriaRuntimeShapeCell(x, y))).ToArray(); + var one = new[] { new AetheriaRuntimeShapeCell(0, 0) }; + + AetheriaRuntimeCatalogItem Item(string key, string category, string manufacturer, int price, + string hardpointType = "", params string[] behaviors) + { + var item = CatalogItem(key, behaviors.Select((kind, index) => + new AetheriaRuntimeBehaviorPayload(index, kind, 0, Array.Empty())).ToArray()); + item.Category = category; item.ManufacturerKey = manufacturer; item.Price = price; + item.HardpointType = hardpointType; item.ShapeWidth = 1; item.ShapeHeight = 1; + item.OccupiedCells = 1; item.ShapeCells = one; item.BehaviorKinds = behaviors; + return item; + } + + var availableHull = Item("available-hull", AetheriaRuntimeItemCategories.Hull, "forge", 100); + availableHull.HardpointType = "Hull"; + availableHull.HullType = "Ship"; availableHull.ShapeWidth = 4; availableHull.ShapeHeight = 4; + availableHull.OccupiedCells = 16; availableHull.ShapeCells = cells4; + availableHull.Hardpoints = + [ + new AetheriaRuntimeHardpoint("ControlModule", 0, 0, 1, 1, 1, one, "", "None", 0), + new AetheriaRuntimeHardpoint("Weapon", 1, 0, 1, 1, 1, one, "", "None", 0) + ]; + var unavailableHull = Item("cheap-foreign-hull", AetheriaRuntimeItemCategories.Hull, "foreign", 1); + unavailableHull.HardpointType = "Hull"; + unavailableHull.HullType = "Ship"; unavailableHull.ShapeWidth = 4; unavailableHull.ShapeHeight = 4; + unavailableHull.OccupiedCells = 16; unavailableHull.ShapeCells = cells4; + var cockpit = Item("cockpit", AetheriaRuntimeItemCategories.Gear, "forge", 20, "ControlModule", "Cockpit"); + var wrongController = Item("cheap-turret-controller", AetheriaRuntimeItemCategories.Gear, "forge", 1, + "ControlModule", "TurretController"); + var weapon = Item("cannon", AetheriaRuntimeItemCategories.Weapon, "forge", 40, "Weapon", "LockWeapon"); + var cargo = Item("cargo-bay", AetheriaRuntimeItemCategories.CargoBay, "forge", 30); + cargo.HardpointType = "Internal"; + var capacitor = Item("capacitor", AetheriaRuntimeItemCategories.Gear, "forge", 25, "", "Capacitor"); + capacitor.HardpointType = "Internal"; + var faction = new AetheriaRuntimeCorporation("forge", "Forge", "F", "", "", "", 1, 1, + [new AetheriaRuntimeCorporationAllegiance("forge", 1)]); + var foreign = new AetheriaRuntimeCorporation("foreign", "Foreign", "X", "", "", "", 1, 1, + [new AetheriaRuntimeCorporationAllegiance("foreign", 1)]); + var catalog = new AetheriaRuntimeCatalogSnapshot( + [availableHull, unavailableHull, cockpit, wrongController, weapon, cargo, capacitor], + [faction, foreign], Array.Empty()); + var homes = new Dictionary { ["forge"] = 0, ["foreign"] = 1 }; + var adjacency = new Dictionary> { [0] = [1], [1] = [0] }; + + var first = new AetheriaDaemonLoadoutGenerator(catalog, 42, 0, homes, adjacency).Build("ship", "forge", []); + var second = new AetheriaDaemonLoadoutGenerator(catalog, 42, 0, homes, adjacency).Build("ship", "forge", []); + + RequireEqual("available-hull", first.HullItemKey, + "loadout generation must exclude manufacturers outside the faction allegiance graph"); + Require(first.Equipment.Any(value => value.ItemKey == "cockpit") && + first.Equipment.All(value => value.ItemKey != "cheap-turret-controller"), + "ship control hardpoints must require the cockpit role even when the wrong controller is cheaper"); + Require(first.Equipment.Any(value => value.ItemKey == "cannon") && + first.Equipment.Any(value => value.ItemKey == "cargo-bay") && + first.Equipment.Any(value => value.ItemKey == "capacitor"), + "generated ships must fit hardpoint equipment, a cargo bay, and a capacitor"); + Require(first.HullItemKey == second.HullItemKey && + first.Equipment.Select(value => value.ItemKey).SequenceEqual(second.Equipment.Select(value => value.ItemKey)), + "same seed, map, faction and catalog must produce the same loadout"); + } + private static AetheriaRuntimeBehaviorValue PerformanceStat(double value) => new( "performance-stat", "", diff --git a/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs b/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs new file mode 100644 index 000000000..58cab6af7 --- /dev/null +++ b/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs @@ -0,0 +1,262 @@ +using Aetheria.State.Documents; +using GameCult.Aetheria.State.Verse; +using Random = CultMath.Random; + +public sealed class AetheriaDaemonLoadoutGenerator +{ + private readonly AetheriaRuntimeCatalogSnapshot _catalog; + private readonly Random _random; + private readonly int _zoneIndex; + private readonly IReadOnlyDictionary _homeZones; + private readonly IReadOnlyDictionary> _adjacency; + private readonly double _priceExponent; + + public AetheriaDaemonLoadoutGenerator( + AetheriaRuntimeCatalogSnapshot catalog, + uint seed, + int zoneIndex, + IReadOnlyDictionary homeZones, + IReadOnlyDictionary> adjacency, + double priceExponent = 0.5) + { + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _random = new Random(seed == 0 ? 1u : seed); + _zoneIndex = zoneIndex; + _homeZones = homeZones ?? throw new ArgumentNullException(nameof(homeZones)); + _adjacency = adjacency ?? throw new ArgumentNullException(nameof(adjacency)); + _priceExponent = priceExponent; + } + + public AetheriaDaemonLoadout Build( + string entityKind, + string availabilityFactionKey, + IReadOnlyList scenarioCargo) + { + 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 ??= Pick(availabilityFactionKey, 2, 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; + } + + slots.Add(Slot(hardpoint.PositionX, hardpoint.PositionY, item.ItemKey)); + Reserve(occupied, item, hardpoint.PositionX, hardpoint.PositionY, ParseRotation(hardpoint.Rotation)); + previous.Add(item); + } + + AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 3, item => + string.Equals(item.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal)); + AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 2, item => + IsGear(item) && HasBehavior(item, "Capacitor")); + + var cargo = (scenarioCargo ?? Array.Empty()) + .Where(key => !string.IsNullOrWhiteSpace(key) && _catalog.FindItem(key) != null) + .Select((key, index) => CargoSlot(index, key)) + .ToArray(); + return new AetheriaDaemonLoadout(hull.ItemKey, slots.ToArray(), cargo); + } + + private void 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)); + Reserve(occupied, selected.Item, fit.X, fit.Y, fit.Rotation); + } + + private AetheriaRuntimeCatalogItem? Pick( + string factionKey, + double sizeExponent, + Func filter) + { + return PickWeighted(Available(factionKey).Where(filter).ToArray(), sizeExponent, 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) || + !_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); + var allegiance = string.Equals(item.ManufacturerKey, factionKey, StringComparison.Ordinal) + ? 1 + : faction.Allegiances.First(value => value.CorporationKey == item.ManufacturerKey).Weight / + Math.Max(1, Distance(_zoneIndex, _homeZones[item.ManufacturerKey])); + return allegiance * Math.Pow(Math.Max(1, item.OccupiedCells), sizeExponent) / + Math.Pow(item.Price, _priceExponent); + } + + 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) || + item.OccupiedCells != hardpoint.OccupiedCells) + 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) + { + var hullCells = Cells(hull.ShapeCells); + for (var rotation = 0; rotation < 4; rotation++) + { + var cells = RotatedCells(item, rotation).ToArray(); + for (var y = 0; y < Math.Max(1, hull.ShapeHeight); y++) + for (var x = 0; x < Math.Max(1, hull.ShapeWidth); x++) + { + if (cells.All(cell => hullCells.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) + { + foreach (var cell in item.ShapeCells ?? Array.Empty()) + { + yield return rotation switch + { + 1 => (item.ShapeHeight - 1 - cell.Y, cell.X), + 2 => (item.ShapeWidth - 1 - cell.X, item.ShapeHeight - 1 - cell.Y), + 3 => (cell.Y, item.ShapeWidth - 1 - cell.X), + _ => (cell.X, cell.Y) + }; + } + } + + private static int ParseRotation(string value) => value switch + { + "Clockwise" or "Right" or "Rotate90" => 1, + "Half" or "Rotate180" => 2, + "CounterClockwise" or "Left" or "Rotate270" => 3, + _ => 0 + }; + + 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 AetheriaEntityItemSlot Slot(int x, int y, string itemKey) => new() + { + Position = new AetheriaGridCoord { X = x, Y = y }, ItemKey = itemKey, + Quality = 1, Durability = 1, Quantity = 1, Enabled = true + }; + + private static AetheriaLoadoutItemSlot CargoSlot(int index, string itemKey) => new() + { + Position = new AetheriaGridCoord { X = index % 4, Y = index / 4 }, + Item = new AetheriaLoadoutItem { ItemKey = itemKey, Quality = 1, Durability = 1, Quantity = 1, Enabled = true } + }; +} + +public sealed record AetheriaDaemonLoadout( + string HullItemKey, + AetheriaEntityItemSlot[] Equipment, + AetheriaLoadoutItemSlot[] Cargo); diff --git a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs index 4f747fbf1..71816342f 100644 --- a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs +++ b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs @@ -15,8 +15,25 @@ internal static class AetheriaDaemonZoneGenerator { var runKey = new CultRecordKey($"global:aetheria.run_state.{RunId}.v1"); var zoneKey = new CultRecordKey($"global:aetheria.zone_state.{RunId}.0.v1"); - var loadouts = new DaemonLoadoutGenerator(catalog); - var entities = GenerateEntities(loadouts); + var corporationKeys = (catalog.Corporations ?? Array.Empty()) + .Select(value => value.CorporationKey) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + var homeZones = corporationKeys.ToDictionary(value => value, _ => 0, StringComparer.Ordinal); + var loadouts = new AetheriaDaemonLoadoutGenerator( + catalog, + GenerationSeed, + 0, + homeZones, + new Dictionary> { [0] = Array.Empty() }); + var availabilityFactions = new Dictionary(StringComparer.Ordinal) + { + ["player"] = corporationKeys.ElementAtOrDefault(0) ?? "", + ["raider"] = corporationKeys.ElementAtOrDefault(1) ?? corporationKeys.ElementAtOrDefault(0) ?? "", + ["neutral"] = corporationKeys.ElementAtOrDefault(2) ?? corporationKeys.ElementAtOrDefault(0) ?? "" + }; + var entities = GenerateEntities(loadouts, availabilityFactions); var entityKeys = Enumerable.Range(0, entities.Length) .Select(index => EntityKey(0, index)) .ToArray(); @@ -169,7 +186,9 @@ internal static class AetheriaDaemonZoneGenerator waveSpeed); } - private static AetheriaEntitySnapshot[] GenerateEntities(DaemonLoadoutGenerator loadouts) + private static AetheriaEntitySnapshot[] GenerateEntities( + AetheriaDaemonLoadoutGenerator loadouts, + IReadOnlyDictionary availabilityFactions) { var keys = Enumerable.Range(0, 12) .Select(index => EntityKey(0, index)) @@ -177,18 +196,18 @@ internal static class AetheriaDaemonZoneGenerator var entities = new[] { - Entity(loadouts, "Anchor Station", "station", -170, -80, 0, 0, "player", 760, keys[6], [keys[1], keys[2], keys[3], keys[4], keys[6], keys[7], keys[11]], ["repair-parts", "reactor-fuel", "drone-core"]), - Entity(loadouts, "Vanguard One", "ship", -40, -30, 10, 4, "player", 540, keys[6], [keys[0], keys[2], keys[4], keys[6], keys[7]], ["coilgun-ammo", "field-rations"]), - Entity(loadouts, "Wing Two", "ship", 145, 125, -5, 7, "player", 450, keys[6], [keys[0], keys[1], keys[6], keys[8]], ["sensor-buoy", "shield-cell"]), - Entity(loadouts, "Torch Three", "ship", -235, 210, 8, -4, "player", 500, keys[7], [keys[0], keys[1], keys[7]], ["micro-missile", "coolant-pack"]), - Entity(loadouts, "Foundry Tug", "ship", 330, -155, -2, 3, "player", 390, keys[6], [keys[0], keys[1], keys[6], keys[10]], ["ore-canister", "field-rations"]), - Entity(loadouts, "Derelict Relay", "station", -390, 270, 0, 0, "neutral", 160, "", [keys[0], keys[3]], ["ancient-transponder"]), - Entity(loadouts, "Ash Raider", "ship", 455, 180, -5, -2, "raider", 320, keys[1], [keys[0], keys[1], keys[2], keys[4]], ["scrap-metal", "stolen-capacitor"]), - Entity(loadouts, "Cinder Knife", "ship", 585, -115, -7, 1, "raider", 280, keys[3], [keys[0], keys[3]], ["volatile-fuel"]), - Entity(loadouts, "Blackwake", "ship", 690, 300, -4, -3, "raider", 230, keys[2], [keys[2]], ["ore-cache", "burned-relay-core"]), - Entity(loadouts, "Vesper Sloop", "ship", 960, 620, -3, -5, "raider", 250, keys[4], [keys[4]], ["stolen-map-fragment"]), - Entity(loadouts, "Survey Skiff", "ship", -610, -365, 3, 5, "neutral", 210, "", [keys[4], keys[5]], ["soil-sample", "deep-scan"]), - Entity(loadouts, "Lagrange Beacon", "station", 105, 510, 0, 0, "neutral", 190, "", [keys[0], keys[1], keys[6]], ["navigation-key"]) + Entity(loadouts, availabilityFactions, "Anchor Station", "station", -170, -80, 0, 0, "player", 760, keys[6], [keys[1], keys[2], keys[3], keys[4], keys[6], keys[7], keys[11]], ["repair-parts", "reactor-fuel", "drone-core"]), + Entity(loadouts, availabilityFactions, "Vanguard One", "ship", -40, -30, 10, 4, "player", 540, keys[6], [keys[0], keys[2], keys[4], keys[6], keys[7]], ["coilgun-ammo", "field-rations"]), + Entity(loadouts, availabilityFactions, "Wing Two", "ship", 145, 125, -5, 7, "player", 450, keys[6], [keys[0], keys[1], keys[6], keys[8]], ["sensor-buoy", "shield-cell"]), + Entity(loadouts, availabilityFactions, "Torch Three", "ship", -235, 210, 8, -4, "player", 500, keys[7], [keys[0], keys[1], keys[7]], ["micro-missile", "coolant-pack"]), + Entity(loadouts, availabilityFactions, "Foundry Tug", "ship", 330, -155, -2, 3, "player", 390, keys[6], [keys[0], keys[1], keys[6], keys[10]], ["ore-canister", "field-rations"]), + Entity(loadouts, availabilityFactions, "Derelict Relay", "station", -390, 270, 0, 0, "neutral", 160, "", [keys[0], keys[3]], ["ancient-transponder"]), + Entity(loadouts, availabilityFactions, "Ash Raider", "ship", 455, 180, -5, -2, "raider", 320, keys[1], [keys[0], keys[1], keys[2], keys[4]], ["scrap-metal", "stolen-capacitor"]), + Entity(loadouts, availabilityFactions, "Cinder Knife", "ship", 585, -115, -7, 1, "raider", 280, keys[3], [keys[0], keys[3]], ["volatile-fuel"]), + Entity(loadouts, availabilityFactions, "Blackwake", "ship", 690, 300, -4, -3, "raider", 230, keys[2], [keys[2]], ["ore-cache", "burned-relay-core"]), + Entity(loadouts, availabilityFactions, "Vesper Sloop", "ship", 960, 620, -3, -5, "raider", 250, keys[4], [keys[4]], ["stolen-map-fragment"]), + Entity(loadouts, availabilityFactions, "Survey Skiff", "ship", -610, -365, 3, 5, "neutral", 210, "", [keys[4], keys[5]], ["soil-sample", "deep-scan"]), + Entity(loadouts, availabilityFactions, "Lagrange Beacon", "station", 105, 510, 0, 0, "neutral", 190, "", [keys[0], keys[1], keys[6]], ["navigation-key"]) }; foreach (var index in new[] { 1, 2, 3, 4 }) entities[index].HomeEntityKey = keys[0]; @@ -199,7 +218,8 @@ internal static class AetheriaDaemonZoneGenerator } private static AetheriaEntitySnapshot Entity( - DaemonLoadoutGenerator loadouts, + AetheriaDaemonLoadoutGenerator loadouts, + IReadOnlyDictionary availabilityFactions, string name, string kind, double x, @@ -212,7 +232,7 @@ internal static class AetheriaDaemonZoneGenerator string[] contactKeys, string[] cargo) { - var loadout = loadouts.Build(kind, faction, cargo); + var loadout = loadouts.Build(kind, availabilityFactions[faction], cargo); return new AetheriaEntitySnapshot { Name = name, @@ -347,127 +367,4 @@ internal static class AetheriaDaemonZoneGenerator double WaveDepth, double WaveSpeed); - private sealed class DaemonLoadoutGenerator - { - private readonly AetheriaRuntimeCatalogSnapshot _catalog; - - public DaemonLoadoutGenerator(AetheriaRuntimeCatalogSnapshot? catalog) - { - _catalog = catalog ?? new AetheriaRuntimeCatalogSnapshot(); - } - - public DaemonLoadout Build(string entityKind, string factionKey, IReadOnlyList scenarioCargo) - { - var hullType = string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase) - ? "Station" - : "Ship"; - var hull = PickHull(hullType, factionKey); - var equipment = PickEquipment(hull, factionKey) - .Select((item, index) => Slot(index, item.ItemKey)) - .ToArray(); - var cargo = PickCargo(scenarioCargo) - .Select((itemKey, index) => CargoSlot(index, itemKey)) - .ToArray(); - - return new DaemonLoadout( - hull?.ItemKey ?? FallbackHull(entityKind, factionKey), - equipment, - cargo); - } - - private AetheriaRuntimeCatalogItem? PickHull(string hullType, string factionKey) - { - return _catalog.Items - .Where(item => string.Equals(item.HullType, hullType, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(item => ManufacturerScore(item, factionKey)) - .ThenBy(item => item.Price <= 0 ? int.MaxValue : item.Price) - .ThenBy(item => item.ItemKey, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(); - } - - private IEnumerable PickEquipment( - AetheriaRuntimeCatalogItem? hull, - string factionKey) - { - var hardpoints = hull?.Hardpoints ?? Array.Empty(); - foreach (var hardpoint in hardpoints.Take(6)) - { - var item = _catalog.FindItemsByHardpoint(hardpoint.Type) - .Where(candidate => candidate.Price > 0) - .OrderByDescending(candidate => ManufacturerScore(candidate, factionKey)) - .ThenBy(candidate => candidate.Price) - .ThenBy(candidate => candidate.ItemKey, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(); - if (item != null) - yield return item; - } - } - - private IEnumerable PickCargo(IReadOnlyList scenarioCargo) - { - if (scenarioCargo != null && scenarioCargo.Count > 0) - return scenarioCargo.Where(itemKey => !string.IsNullOrWhiteSpace(itemKey)); - - var catalogCargo = _catalog.TradeItems - .Where(item => item.MaxStack != 1 || string.Equals(item.Category, AetheriaRuntimeItemCategories.Gear, StringComparison.OrdinalIgnoreCase)) - .OrderBy(item => item.Price <= 0 ? int.MaxValue : item.Price) - .ThenBy(item => item.ItemKey, StringComparer.OrdinalIgnoreCase) - .Select(item => item.ItemKey) - .Take(4) - .ToArray(); - return catalogCargo; - } - - private static int ManufacturerScore(AetheriaRuntimeCatalogItem item, string factionKey) - { - if (item == null || string.IsNullOrWhiteSpace(factionKey)) - return 0; - - return string.Equals(item.ManufacturerKey, factionKey, StringComparison.OrdinalIgnoreCase) ? 2 : - string.IsNullOrWhiteSpace(item.ManufacturerKey) ? 0 : 1; - } - - private static string FallbackHull(string entityKind, string factionKey) - { - if (string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase)) - return "station-hull"; - return string.Equals(factionKey, "raider", StringComparison.OrdinalIgnoreCase) - ? "raider-hull" - : "ship-hull"; - } - - private static AetheriaEntityItemSlot Slot(int index, string itemKey) - { - return new AetheriaEntityItemSlot - { - Position = new AetheriaGridCoord { X = index % 4, Y = index / 4 }, - ItemKey = itemKey, - Quality = 1, - Durability = 1, - Quantity = 1, - Enabled = true - }; - } - - private static AetheriaLoadoutItemSlot CargoSlot(int index, string itemKey) - { - return new AetheriaLoadoutItemSlot - { - Position = new AetheriaGridCoord { X = index % 4, Y = index / 4 }, - Item = new AetheriaLoadoutItem - { - ItemKey = itemKey, - Quality = 1, - Durability = 1, - Quantity = 1, - Enabled = true - } - }; - } - } - - private sealed record DaemonLoadout( - string HullItemKey, - AetheriaEntityItemSlot[] Equipment, - AetheriaLoadoutItemSlot[] Cargo); } diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index a3ed7ee5a..791db3a5c 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -1217,6 +1217,7 @@ static void RequireDaemonPlayableRunGenerationAuthority(string root) { var daemonProgramPath = Path.Combine(root, "Aetheria.State.Daemon", "Program.cs"); var daemonZoneGeneratorPath = Path.Combine(root, "Aetheria.State.Daemon", "AetheriaDaemonZoneGenerator.cs"); + var daemonLoadoutGeneratorPath = Path.Combine(root, "Aetheria.State.Daemon", "AetheriaDaemonLoadoutGenerator.cs"); var sharedLoadoutGeneratorPath = Path.Combine(root, "Assets", "Scripts", "ServerShared", "LoadoutGenerator.cs"); var daemonProgram = File.Exists(daemonProgramPath) @@ -1225,6 +1226,9 @@ static void RequireDaemonPlayableRunGenerationAuthority(string root) var daemonZoneGenerator = File.Exists(daemonZoneGeneratorPath) ? File.ReadAllText(daemonZoneGeneratorPath) : throw new InvalidOperationException("Cannot verify daemon playable-run generation authority; daemon zone generator is missing."); + var daemonLoadoutGenerator = File.Exists(daemonLoadoutGeneratorPath) + ? File.ReadAllText(daemonLoadoutGeneratorPath) + : throw new InvalidOperationException("Cannot verify daemon playable-run generation authority; daemon loadout generator is missing."); var sharedLoadoutGenerator = File.Exists(sharedLoadoutGeneratorPath) ? File.ReadAllText(sharedLoadoutGeneratorPath) : throw new InvalidOperationException("Cannot verify daemon playable-run generation authority; legacy Unity LoadoutGenerator.cs is missing."); @@ -1233,12 +1237,15 @@ static void RequireDaemonPlayableRunGenerationAuthority(string root) { "await AetheriaDaemonZoneGenerator.WritePlayableRunAsync(", "internal static class AetheriaDaemonZoneGenerator", - "private sealed class DaemonLoadoutGenerator", - "var loadouts = new DaemonLoadoutGenerator(catalog);", - "GenerateEntities(loadouts)" + "new AetheriaDaemonLoadoutGenerator(", + "GenerateEntities(loadouts, availabilityFactions)", + "Math.Pow(item.Price, _priceExponent)", + "Distance(_zoneIndex, _homeZones[item.ManufacturerKey])", + "FitsHardpoint(candidate, hardpoint)", + "HasBehavior(candidate, controllerKind)" }; - var daemonText = daemonProgram + "\n" + daemonZoneGenerator; + var daemonText = daemonProgram + "\n" + daemonZoneGenerator + "\n" + daemonLoadoutGenerator; var missingDaemonSymbols = requiredDaemonSymbols .Where(symbol => !daemonText.Contains(symbol, StringComparison.Ordinal)) .ToArray(); @@ -1254,7 +1261,9 @@ static void RequireDaemonPlayableRunGenerationAuthority(string root) "new LoadoutGenerator(", "ZoneGenerator.Generate(", "Assets.Scripts.ServerShared", - "ServerShared.LoadoutGenerator" + "ServerShared.LoadoutGenerator", + "ManufacturerScore(", + "FallbackHull(" }; var survivingDaemonSymbols = forbiddenDaemonSymbols .Where(symbol => daemonText.Contains(symbol, StringComparison.Ordinal)) diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index f28feb860..94b2be9f4 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -17,7 +17,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. |---|---|---|---|---|---|---| | Run/session state | `ServerShared`, `ActionGameManager` | Aetheria daemon/CultCache | provider/session advertisement | all clients | owner-exists; wired; projected; not proven | restart and multi-client convergence | | Entity identity/loadout | `ServerShared/Entity*` | daemon | typed entity and inventory surfaces | EveUnity/Electron | owner-exists; wired; projected; not proven | loadout round trip and reconnect | -| Loadout generation | map/faction availability, item cost, hull hardpoints and role constraints | daemon using canonical galaxy, faction knowledge/economy and typed catalog | generated loadout plus provenance/availability facts | Eve inspection and refit surfaces | typed catalog selection exists, but full fossil map- and faction-aware generation is not yet daemon-owned | same seed/map/faction/budget produces an affordable available legal loadout; unavailable foreign equipment cannot appear | +| Loadout generation | map/faction availability, item cost, hull hardpoints and role constraints | daemon using canonical galaxy, faction knowledge/economy and typed catalog | generated loadout plus provenance/availability facts | Eve inspection and refit surfaces | daemon now uses the fossil candidate filter and seeded weighted selection: manufacturer must be present and allied, ally weight decays by home-zone graph distance, price uses the authored exponent, hardpoints require exact type/cell/rotation fit and controller role, and cargo/capacitor plus station docking equipment must fit remaining hull space; Terminus explicitly maps combat-role factions to catalog availability corporations | multi-zone generated-world parity, station inventory draws, faction-local stream/call-order parity, and generation provenance projection | | Flight intent | `Ship`, `Thruster`, `AetherDrive`, input paths | daemon intent + Ymir physics | analog movement/look/tractor | EveUnity input driver | movement intents feed Ymir-owned body integration; authored thrust allocation, torque, energy and heat remain simplified | prove original drive/equipment behavior ordering and runtime interpolation | | Collision | Unity physics and entity handlers | Ymir | collision/result facts | visual interpolation only | Ymir.Core world stepping wired for entity bodies, radial fields and contacts; missing-owner negative proof passes | publish authoritative collision events and prove Unity cannot write transforms back | | Targeting/contacts | sensors + `ActionGameManager` | daemon | contact/target semantics | target HUD and indicators | simplified owner wired/projected; daemon weapon lock now acquires and decays from facing, elapsed time, and observer-local contact confidence instead of granting an immediate perfect lock | target-switch reset, angular decay, visibility/nearest/explicit cases, and runtime lock feedback |