Restore faction-aware loadout generation
This commit is contained in:
parent
4810780e03
commit
99e4f1d3e7
5 changed files with 380 additions and 146 deletions
|
|
@ -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",
|
||||
"",
|
||||
|
|
|
|||
262
Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs
Normal file
262
Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs
Normal 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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in a new issue