Restore faction-aware loadout generation

This commit is contained in:
Meta Vens 2026-07-11 20:32:33 +01:00
commit 99e4f1d3e7
5 changed files with 380 additions and 146 deletions

View file

@ -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<AetheriaRuntimeBehaviorField>())).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<AetheriaRuntimeNameFile>());
var homes = new Dictionary<string, int> { ["forge"] = 0, ["foreign"] = 1 };
var adjacency = new Dictionary<int, IReadOnlyList<int>> { [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",
"",

View file

@ -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<string, int> _homeZones;
private readonly IReadOnlyDictionary<int, IReadOnlyList<int>> _adjacency;
private readonly double _priceExponent;
public AetheriaDaemonLoadoutGenerator(
AetheriaRuntimeCatalogSnapshot catalog,
uint seed,
int zoneIndex,
IReadOnlyDictionary<string, int> homeZones,
IReadOnlyDictionary<int, IReadOnlyList<int>> 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<string> 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<AetheriaRuntimeHardpoint>())
foreach (var cell in hardpoint.ShapeCells ?? Array.Empty<AetheriaRuntimeShapeCell>())
occupied.Add((hardpoint.PositionX + cell.X, hardpoint.PositionY + cell.Y));
var slots = new List<AetheriaEntityItemSlot>();
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<AetheriaRuntimeCatalogItem>();
foreach (var hardpoint in (hull.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>())
.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<string>())
.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<AetheriaEntityItemSlot> slots,
double sizeExponent,
Func<AetheriaRuntimeCatalogItem, bool> 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<AetheriaRuntimeCatalogItem, bool> filter)
{
return PickWeighted(Available(factionKey).Where(filter).ToArray(), sizeExponent, factionKey, item => item);
}
private T PickWeighted<T>(
IReadOnlyList<T> values,
double sizeExponent,
string factionKey,
Func<T, AetheriaRuntimeCatalogItem> 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<AetheriaRuntimeCatalogItem> 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<AetheriaRuntimeCorporationAllegiance>())
.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<int> { 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<int>())
{
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<AetheriaRuntimeShapeCell>? cells) =>
(cells ?? Array.Empty<AetheriaRuntimeShapeCell>()).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<AetheriaRuntimeShapeCell>())
{
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<string>()).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);

View file

@ -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<AetheriaRuntimeCorporation>())
.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<int, IReadOnlyList<int>> { [0] = Array.Empty<int>() });
var availabilityFactions = new Dictionary<string, string>(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<string, string> 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<string, string> 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<string> 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<AetheriaRuntimeCatalogItem> PickEquipment(
AetheriaRuntimeCatalogItem? hull,
string factionKey)
{
var hardpoints = hull?.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>();
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<string> PickCargo(IReadOnlyList<string> 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);
}

View file

@ -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))

View file

@ -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 |