Add daemon-owned dockyard station variant

This commit is contained in:
Meta Vens 2026-07-19 15:59:35 +01:00
commit 0b5284f5af
10 changed files with 438 additions and 15 deletions

View file

@ -93,7 +93,11 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
public void RunCombatLock() => RunCheck(InstantWeaponRequestSurvivesLockAcquisition);
public void RunLoadout() => DaemonLoadoutsRespectFactionAvailabilityAndHullRoles();
public void RunLoadout()
{
DaemonNativeDockyardMaterializesTypedCatalog();
DaemonLoadoutsRespectFactionAvailabilityAndHullRoles();
}
public void RunPickup()
{
@ -1761,6 +1765,10 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
private static void InputCapabilityPublishesNativeTargetAndInteractControls()
{
var actor = Entity(0, 0, "player");
actor.VelocityX = 12;
actor.VelocityY = -7;
actor.HelmStrafe = 0.5;
actor.HelmForward = 1;
var near = Entity(1, 0, "enemy");
near.PositionX = 4;
var middle = Entity(2, 0, "enemy");
@ -2553,6 +2561,14 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
var disarmed = actor.WeaponStates.Single();
RequireEqual(0, actor.ActiveWeaponGroups.Count,
"docking must clear the daemon-owned held weapon-group latch");
RequireNear(0, actor.VelocityX, 0.000001,
"docking must clear retained X velocity before Ymir excludes the attached child body");
RequireNear(0, actor.VelocityY, 0.000001,
"docking must clear retained Z velocity before Ymir excludes the attached child body");
RequireNear(0, actor.HelmStrafe, 0.000001,
"docking must clear retained strafe intent");
RequireNear(0, actor.HelmForward, 0.000001,
"docking must clear retained forward intent");
Require(!disarmed.Firing && !disarmed.TriggerPending && disarmed.BurstRemaining == 0 &&
!disarmed.Charging && !disarmed.Charged && disarmed.Charge == 0 &&
disarmed.LockTargetEntityIndex < 0 && disarmed.LockProgress == 0,
@ -5522,7 +5538,23 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
cargo.InteriorShapeCells = Enumerable.Range(0, 3).SelectMany(y => Enumerable.Range(0, 3)
.Select(x => new AetheriaRuntimeShapeCell(x, y))).ToArray();
var docking = Item("docking-bay", AetheriaRuntimeItemCategories.DockingBay, "forge", 35);
docking.HardpointType = "Internal";
docking.HardpointType = "Tool";
var dockyardBay = Item("dockyard-bay", AetheriaRuntimeItemCategories.DockingBay, "forge", 70, "DockingBay");
var dockyardHull = Item("dockyard-hull", AetheriaRuntimeItemCategories.Hull, "forge", 600);
dockyardHull.HardpointType = "Hull";
dockyardHull.HullType = "Station";
dockyardHull.ShapeWidth = 6;
dockyardHull.ShapeHeight = 4;
dockyardHull.ShapeCells = Enumerable.Range(0, 4).SelectMany(y => Enumerable.Range(0, 6)
.Select(x => new AetheriaRuntimeShapeCell(x, y))).ToArray();
dockyardHull.OccupiedCells = dockyardHull.ShapeCells.Count;
dockyardHull.Hardpoints = Enumerable.Range(0, 6)
.SelectMany(x => new[]
{
new AetheriaRuntimeHardpoint("DockingBay", x, 0, 1, 1, 1, one, "", "None", 0),
new AetheriaRuntimeHardpoint("DockingBay", x, 3, 1, 1, 1, one, "", "None", 0)
})
.ToArray();
var capacitor = Item("capacitor", AetheriaRuntimeItemCategories.Gear, "forge", 25, "", "Capacitor");
capacitor.HardpointType = "Internal";
var faction = new AetheriaRuntimeCorporation("forge", "Forge", "F", "", "", "", 1, 1,
@ -5532,7 +5564,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
var reactor = Item("reactor", AetheriaRuntimeItemCategories.Gear, "forge", 25, "", "Reactor");
reactor.HardpointType = "Internal";
var catalog = new AetheriaRuntimeCatalogSnapshot(
[availableHull, unavailableHull, stationHull, cockpit, wrongController, weapon, shipSensor, stationSensor, cargo, docking, capacitor, reactor],
[availableHull, unavailableHull, stationHull, dockyardHull, cockpit, wrongController, weapon, shipSensor, stationSensor, cargo, docking, dockyardBay, capacitor, reactor],
[faction, foreign], Array.Empty<AetheriaRuntimeNameFile>());
var fallbackCatalog = new AetheriaRuntimeCatalogSnapshot(
[availableHull, unavailableHull, stationHull, cockpit, wrongController, weapon, shipSensor, cargo, docking, capacitor, reactor],
@ -5555,9 +5587,12 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
var first = new AetheriaDaemonLoadoutGenerator(catalog, 42, 0, homes, adjacency).Build("ship", "forge");
var second = new AetheriaDaemonLoadoutGenerator(catalog, 42, 0, homes, adjacency).Build("ship", "forge");
var station = new AetheriaDaemonLoadoutGenerator(catalog, 84, 0, homes, adjacency).Build("station", "forge");
var station = new AetheriaDaemonLoadoutGenerator(catalog, 84, 0, homes, adjacency)
.Build("station", "forge", "station-hull");
var fallbackStation = new AetheriaDaemonLoadoutGenerator(fallbackCatalog, 84, 0, homes, adjacency)
.Build("station", "forge");
.Build("station", "forge", "station-hull");
var dockyard = new AetheriaDaemonLoadoutGenerator(catalog, 85, 0, homes, adjacency)
.Build("station", "forge", "dockyard-hull");
var uninterruptedForge = new AetheriaDaemonLoadoutGenerator(catalog, 101, 0, homes, adjacency);
var interleavedForge = new AetheriaDaemonLoadoutGenerator(catalog, 101, 0, homes, adjacency);
var foreignStream = new AetheriaDaemonLoadoutGenerator(catalog, 202, 1, homes, adjacency);
@ -5609,6 +5644,14 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
"station generation must fill a large sensor hardpoint with the largest available compatible array");
Require(fallbackStation.Equipment.Any(value => value.ItemKey == "ship-sensor"),
"a smaller sensor must remain a valid fallback when it fits inside a larger same-type hardpoint");
Require(fallbackStation.DockingBays.Single().ItemKey == "docking-bay",
"a station without dedicated docking mounts must retain the canonical non-dedicated free-space bay path");
RequireEqual("dockyard-hull", dockyard.HullItemKey,
"an authored station variant must be selectable without bypassing hull generation");
RequireEqual(12, dockyard.DockingBays.Length,
"the dockyard hull must fill every authored docking hardpoint through normal loadout generation");
Require(dockyard.DockingBays.All(value => value.ItemKey == "dockyard-bay"),
"dedicated docking hardpoints must only accept the matching docking-bay equipment type");
Require(station.Cargo.Length > 0 && station.Cargo.Length <= cargo.InteriorOccupiedCells &&
station.Cargo.All(value => value.Item.ItemKey != "cheap-foreign-hull" &&
catalog.FindItem(value.Item.ItemKey) != null) &&
@ -5766,6 +5809,89 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
additionalItems.Concat([hull, thruster]).ToArray(), [], []);
}
private static void DaemonNativeDockyardMaterializesTypedCatalog()
{
Require(AetheriaDocumentRegistry.DocumentTypes.Contains(
typeof(GameCult.Eve.PluginFields.EveFieldsSplatsDocument)),
"Aetheria's public registry must include every typed Eve Fields document the daemon persists");
const string zenithId = "82efc0a5-1ba5-4ff3-a281-b2e6e247521d";
const string mediumBayId = "3e930a2c-ac72-4385-98aa-1c5b0b90db46";
var root = Path.Combine(Path.GetTempPath(), $"aetheria-dockyard-catalog-{Guid.NewGuid():N}");
var statePath = Path.Combine(root, "aetheria.cc");
Directory.CreateDirectory(root);
try
{
using var node = AetheriaStateNode.OpenAsync(statePath).GetAwaiter().GetResult();
node.MutableDocument<AetheriaItemDefinition>(AetheriaCatalogKeys.ItemDefinitionFromLegacyId(zenithId))
.ReplaceAsync(new AetheriaItemDefinition
{
Name = "Zenith",
LegacyId = zenithId,
Category = AetheriaRuntimeItemCategories.Hull,
HullType = "Station",
HardpointType = "Hull",
ManufacturerLegacyId = "11111111-1111-1111-1111-111111111111",
Price = 100,
Mass = 100,
ShapeWidth = 12,
ShapeHeight = 12,
ShapeCells = Enumerable.Range(0, 12).SelectMany(y => Enumerable.Range(0, 12)
.Select(x => new AetheriaShapeCell { X = x, Y = y })).ToArray(),
HullPrefab = "Assets/Prefabs/Zenith.prefab",
HullArmor = 4,
HullDrag = 2
}).GetAwaiter().GetResult();
node.MutableDocument<AetheriaItemDefinition>(AetheriaCatalogKeys.ItemDefinitionFromLegacyId(mediumBayId))
.ReplaceAsync(new AetheriaItemDefinition
{
Name = "Medium Docking Bay",
LegacyId = mediumBayId,
Category = AetheriaRuntimeItemCategories.DockingBay,
HardpointType = "Tool",
ManufacturerLegacyId = "11111111-1111-1111-1111-111111111111",
Price = 20,
ShapeWidth = 4,
ShapeHeight = 4,
OccupiedCells = 16,
ShapeCells = Enumerable.Range(0, 4).SelectMany(y => Enumerable.Range(0, 4)
.Select(x => new AetheriaShapeCell { X = x, Y = y })).ToArray(),
DockingMaxSizeX = 12,
DockingMaxSizeY = 12
}).GetAwaiter().GetResult();
AetheriaDaemonNativeCatalog.EnsureAsync(node).GetAwaiter().GetResult();
node.FlushAsync().GetAwaiter().GetResult();
var catalog = node.RuntimeCatalog().Latest();
var hull = catalog.FindItem(AetheriaDaemonNativeCatalog.DockyardHullItemKey);
var bay = catalog.FindItem(AetheriaDaemonNativeCatalog.DockyardBayItemKey);
Require(hull != null && hull.HullType == "Station" && hull.ShapeWidth == 24 && hull.ShapeHeight == 24,
"daemon-native station content must materialize as a distinct typed hull definition");
RequireEqual(12, hull!.Hardpoints.Count(hardpoint => hardpoint.Type == "DockingBay"),
"dockyard capacity must be authored by twelve docking hardpoints on the hull");
Require(bay != null && bay.Category == AetheriaRuntimeItemCategories.DockingBay &&
bay.HardpointType == "DockingBay" && bay.DockingMaxSizeX == 12 && bay.DockingMaxSizeY == 12,
"dockyard berths must remain typed docking-bay equipment with the source bay's ship-size contract");
var dockyardEntity = Entity(0, 0, "neutral");
dockyardEntity.Kind = "station";
dockyardEntity.HullItemKey = hull.ItemKey;
var zenithItemKey = $"aetheria.item_definition:legacy:{zenithId}";
var zenithAssetKey = AetheriaRuntimeAssets.HullPrefabAssetKey(zenithItemKey);
RequireEqual(zenithAssetKey, AetheriaRuntimeAssets.ResolveEntityPrefabAssetRef(dockyardEntity, catalog),
"daemon-native hull variants must explicitly reuse their provider-owned presentation hull asset");
var manifest = AetheriaRuntimeAssets.ProjectManifest(catalog);
Require(manifest.Assets.Any(asset => asset.Ref.AssetKey == zenithAssetKey),
"provider manifest must advertise the canonical Zenith presentation asset used by the dockyard");
Require(!manifest.Assets.Any(asset => asset.Ref.AssetKey ==
AetheriaRuntimeAssets.HullPrefabAssetKey(hull.ItemKey)),
"provider manifest must not invent an unbaked prefab for a hull with an explicit presentation alias");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static AetheriaRuntimeCatalogSnapshot EquipAetherDrive(
IEnumerable<AetheriaRuntimeEntitySnapshotCommit> entities,
params AetheriaRuntimeCatalogItem[] additionalItems)

View file

@ -35,12 +35,14 @@ public sealed class AetheriaDaemonLoadoutGenerator
public AetheriaDaemonLoadout Build(
string entityKind,
string availabilityFactionKey)
string availabilityFactionKey,
string hullItemKey = "")
{
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))
string.Equals(item.HullType, hullType, StringComparison.Ordinal) &&
(string.IsNullOrWhiteSpace(hullItemKey) || string.Equals(item.ItemKey, hullItemKey, StringComparison.Ordinal)))
?? throw new InvalidOperationException($"No available {hullType} hull for faction {availabilityFactionKey}.");
var occupied = new HashSet<(int X, int Y)>();
@ -48,9 +50,12 @@ public sealed class AetheriaDaemonLoadoutGenerator
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))
var hasDedicatedDockingMounts = (hull.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>())
.Any(hardpoint => string.Equals(hardpoint.Type, "DockingBay", StringComparison.Ordinal));
if (string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase) && !hasDedicatedDockingMounts)
AddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 2,
item => string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal));
item => string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal) &&
!string.Equals(item.HardpointType, "DockingBay", StringComparison.Ordinal));
var previous = new List<AetheriaRuntimeCatalogItem>();
foreach (var hardpoint in (hull.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>())
@ -61,7 +66,7 @@ public sealed class AetheriaDaemonLoadoutGenerator
: "";
var item = previous.FirstOrDefault(candidate => FitsHardpoint(candidate, hardpoint));
item ??= PickHardpoint(availabilityFactionKey, hardpoint, candidate =>
IsGear(candidate) && FitsHardpoint(candidate, hardpoint) &&
(IsGear(candidate) || IsDockingBay(candidate)) && FitsHardpoint(candidate, hardpoint) &&
(controllerKind.Length == 0 || HasBehavior(candidate, controllerKind)));
if (item == null)
{
@ -412,6 +417,9 @@ public sealed class AetheriaDaemonLoadoutGenerator
(string.Equals(item.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal) ||
string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal));
private static bool IsDockingBay(AetheriaRuntimeCatalogItem item) =>
string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal);
private static AetheriaEntityItemSlot Slot(int x, int y, string itemKey, int rotation = 0) => new()
{
Position = new AetheriaGridCoord { X = x, Y = y }, ItemKey = itemKey,

View file

@ -0,0 +1,196 @@
using Aetheria.State;
using Aetheria.State.Documents;
internal static class AetheriaDaemonNativeCatalog
{
private const string ZenithHullLegacyId = "82efc0a5-1ba5-4ff3-a281-b2e6e247521d";
private const string MediumDockingBayLegacyId = "3e930a2c-ac72-4385-98aa-1c5b0b90db46";
private const string DockyardHullLegacyId = "ca098005-8cc8-47f4-be99-7bc842805359";
private const string DockyardBayLegacyId = "8ec30f8d-8536-48b4-bd64-65f29f229895";
public static string DockyardHullItemKey => ItemKey(DockyardHullLegacyId);
public static string DockyardBayItemKey => ItemKey(DockyardBayLegacyId);
public static async Task EnsureAsync(AetheriaStateNode node)
{
ArgumentNullException.ThrowIfNull(node);
var zenith = await node.MutableDocument<AetheriaItemDefinition>(
AetheriaCatalogKeys.ItemDefinitionFromLegacyId(ZenithHullLegacyId))
.ReadAsync()
.ConfigureAwait(false)
?? throw new InvalidDataException("The daemon-native dockyard requires the canonical Zenith station hull.");
var mediumBay = await node.MutableDocument<AetheriaItemDefinition>(
AetheriaCatalogKeys.ItemDefinitionFromLegacyId(MediumDockingBayLegacyId))
.ReadAsync()
.ConfigureAwait(false)
?? throw new InvalidDataException("The daemon-native dockyard requires the canonical medium docking bay.");
await node.MutableDocument<AetheriaItemDefinition>(
AetheriaCatalogKeys.ItemDefinitionFromLegacyId(DockyardHullLegacyId))
.ReplaceAsync(CreateDockyardHull(zenith))
.ConfigureAwait(false);
await node.MutableDocument<AetheriaItemDefinition>(
AetheriaCatalogKeys.ItemDefinitionFromLegacyId(DockyardBayLegacyId))
.ReplaceAsync(CreateDockyardBay(mediumBay))
.ConfigureAwait(false);
}
private static AetheriaItemDefinition CreateDockyardHull(AetheriaItemDefinition source)
{
const int width = 24;
const int height = 24;
var hardpoints = Enumerable.Range(0, 6)
.SelectMany(index => new[]
{
Hardpoint("DockingBay", index * 4, 0, 4, 4, source.HullArmor),
Hardpoint("DockingBay", index * 4, 20, 4, 4, source.HullArmor)
})
.Concat(new[]
{
Hardpoint("Reactor", 10, 10, 4, 4, source.HullArmor),
Hardpoint("Radiator", 8, 10, 2, 2, source.HullArmor),
Hardpoint("Radiator", 14, 10, 2, 2, source.HullArmor),
Hardpoint("Radiator", 10, 14, 2, 2, source.HullArmor),
Hardpoint("Radiator", 12, 14, 2, 2, source.HullArmor),
Hardpoint("Sensors", 11, 16, 2, 2, source.HullArmor)
})
.ToArray();
var hullCells = RectangleCells(0, 0, width, height);
var interiorCells = RectangleCells(1, 1, width - 2, height - 2);
var hull = Copy(source, DockyardHullLegacyId, "Zenith Dockyard");
hull.Description = "A high-capacity Zenith station variant built around twelve dedicated docking berths.";
hull.Mass = source.Mass * 4.5;
hull.Volume = hullCells.Length;
hull.Price = checked(source.Price * 5);
hull.ShapeWidth = width;
hull.ShapeHeight = height;
hull.OccupiedCells = hullCells.Length;
hull.ShapeCells = hullCells;
hull.InteriorShapeWidth = width;
hull.InteriorShapeHeight = height;
hull.InteriorOccupiedCells = interiorCells.Length;
hull.InteriorShapeCells = interiorCells;
hull.Hardpoints = hardpoints;
hull.HullDrag = source.HullDrag * 1.75;
hull.Tags = Tags(
source.Tags,
"origin:aetheria-daemon",
"station-role:dockyard",
"docking-capacity:12",
$"presentation-hull-item:{ItemKey(ZenithHullLegacyId)}");
return hull;
}
private static AetheriaItemDefinition CreateDockyardBay(AetheriaItemDefinition source)
{
var bay = Copy(source, DockyardBayLegacyId, "Dockyard Berth");
bay.Description = "A medium docking bay packaged for a dedicated station docking hardpoint.";
bay.HardpointType = "DockingBay";
bay.Price = checked(source.Price * 2);
bay.Tags = Tags(source.Tags, "origin:aetheria-daemon", "station-role:dockyard");
return bay;
}
private static AetheriaItemDefinition Copy(AetheriaItemDefinition source, string legacyId, string name) => new()
{
Name = name,
Category = source.Category,
LegacyId = legacyId,
Description = source.Description,
Mass = source.Mass,
Volume = source.Volume,
Tags = (source.Tags ?? []).ToArray(),
ManufacturerLegacyId = source.ManufacturerLegacyId,
Price = source.Price,
ShapeWidth = source.ShapeWidth,
ShapeHeight = source.ShapeHeight,
OccupiedCells = source.OccupiedCells,
HardpointType = source.HardpointType,
HullType = source.HullType,
BehaviorKinds = (source.BehaviorKinds ?? []).ToArray(),
BehaviorCount = source.BehaviorCount,
MaxStack = source.MaxStack,
Stackable = source.Stackable,
Duration = source.Duration,
Durability = source.Durability,
WeaponRange = source.WeaponRange,
WeaponCaliber = source.WeaponCaliber,
WeaponType = source.WeaponType,
WeaponFireTypes = source.WeaponFireTypes,
WeaponModifiers = source.WeaponModifiers,
ShapeCells = Clone(source.ShapeCells),
InteriorShapeWidth = source.InteriorShapeWidth,
InteriorShapeHeight = source.InteriorShapeHeight,
InteriorOccupiedCells = source.InteriorOccupiedCells,
InteriorShapeCells = Clone(source.InteriorShapeCells),
Hardpoints = (source.Hardpoints ?? []).Select(Clone).ToArray(),
BehaviorPayloads = source.BehaviorPayloads ?? [],
MinimumTemperature = source.MinimumTemperature,
MaximumTemperature = source.MaximumTemperature,
ThermalPerformanceCurveKeys = source.ThermalPerformanceCurveKeys ?? [],
HullPrefab = source.HullPrefab,
SimpleCommodityCategory = source.SimpleCommodityCategory,
CompoundCommodityCategory = source.CompoundCommodityCategory,
SpecificHeat = source.SpecificHeat,
Conductivity = source.Conductivity,
HullGridOffset = source.HullGridOffset,
HullArmor = source.HullArmor,
HullDrag = source.HullDrag,
HullCanTow = source.HullCanTow,
DockingMaxSizeX = source.DockingMaxSizeX,
DockingMaxSizeY = source.DockingMaxSizeY,
ActionBarIcon = source.ActionBarIcon,
ThermalResilience = source.ThermalResilience,
AudioStats = source.AudioStats ?? [],
EffectivenessCurveKeys = source.EffectivenessCurveKeys ?? []
};
private static AetheriaItemHardpoint Clone(AetheriaItemHardpoint source) => new()
{
Type = source.Type,
PositionX = source.PositionX,
PositionY = source.PositionY,
ShapeWidth = source.ShapeWidth,
ShapeHeight = source.ShapeHeight,
OccupiedCells = source.OccupiedCells,
ShapeCells = Clone(source.ShapeCells),
Transform = source.Transform,
Rotation = source.Rotation,
Armor = source.Armor
};
private static AetheriaItemHardpoint Hardpoint(
string type,
int x,
int y,
int width,
int height,
double armor) => new()
{
Type = type,
PositionX = x,
PositionY = y,
ShapeWidth = width,
ShapeHeight = height,
OccupiedCells = width * height,
ShapeCells = RectangleCells(0, 0, width, height),
Rotation = "None",
Armor = armor
};
private static AetheriaShapeCell[] RectangleCells(int x, int y, int width, int height) =>
Enumerable.Range(y, height)
.SelectMany(row => Enumerable.Range(x, width)
.Select(column => new AetheriaShapeCell { X = column, Y = row }))
.ToArray();
private static AetheriaShapeCell[] Clone(IEnumerable<AetheriaShapeCell>? cells) =>
(cells ?? []).Select(cell => new AetheriaShapeCell { X = cell.X, Y = cell.Y }).ToArray();
private static string[] Tags(IEnumerable<string>? tags, params string[] added) =>
(tags ?? []).Concat(added).Distinct(StringComparer.Ordinal).ToArray();
private static string ItemKey(string legacyId) => $"aetheria.item_definition:legacy:{legacyId}";
}

View file

@ -249,7 +249,7 @@ internal static class AetheriaDaemonZoneGenerator
var entities = new[]
{
Entity(loadouts, availabilityFactions, "Anchor Station", "station", -50, -30, 0, 0, "player", 760, keys[6], [keys[1], keys[2], keys[3], keys[4], keys[6], keys[7], keys[11]], 2, 900),
Entity(loadouts, availabilityFactions, "Anchor Station", "station", -50, -30, 0, 0, "player", 760, keys[6], [keys[1], keys[2], keys[3], keys[4], keys[6], keys[7], keys[11]], 2, 900, AetheriaDaemonNativeCatalog.DockyardHullItemKey),
Entity(loadouts, availabilityFactions, "Vanguard One", "ship", -40, -30, 0, 0, "player", 540, keys[6], [keys[0], keys[2], keys[4], keys[6], keys[7]]),
Entity(loadouts, availabilityFactions, "Wing Two", "ship", 145, 125, -5, 7, "player", 450, keys[6], [keys[0], keys[1], keys[6], keys[8]]),
Entity(loadouts, availabilityFactions, "Torch Three", "ship", -235, 210, 8, -4, "player", 500, keys[7], [keys[0], keys[1], keys[7]]),
@ -277,6 +277,14 @@ internal static class AetheriaDaemonZoneGenerator
AetheriaRuntimeCatalogSnapshot catalog,
string scenario)
{
// Hold the proof pilot inside the station's fossil 25-unit docking envelope while
// the released client negotiates its provider session. Ymir's stellar field owns
// the trajectory; this is initial world state, not a client-side position lock.
entities[1].Velocity = Vec2(-70, -47);
var playerFactionKey = entities[1].FactionKey;
for (var index = 0; index < entities.Length; index++)
if (index != 6)
entities[index].FactionKey = playerFactionKey;
entities[1].CargoContents = [new AetheriaCargoBayLoadout()];
entities[6].StatGrids =
[
@ -387,10 +395,11 @@ internal static class AetheriaDaemonZoneGenerator
string target,
string[] contactKeys,
int securityLevel = 0,
double securityRadius = 0)
double securityRadius = 0,
string hullItemKey = "")
{
var availabilityFaction = availabilityFactions[faction];
var loadout = loadouts[availabilityFaction].Build(kind, availabilityFaction);
var loadout = loadouts[availabilityFaction].Build(kind, availabilityFaction, hullItemKey);
var entity = AetheriaDaemonGeneratedEntityFactory.Create(
name,
kind,

View file

@ -36,6 +36,7 @@ using var discoveryHost = new AetheriaVerseDiscoveryHost(node);
await EnsureWorldDocumentAsync(node).ConfigureAwait(false);
await EnsureTradeValuePolicyAsync(node, startedAtUtc).ConfigureAwait(false);
await AetheriaDaemonNativeCatalog.EnsureAsync(node).ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
await EnsurePlayableRunDocumentsAsync(node, options, startedAtUtc).ConfigureAwait(false);
await EnsureGameSessionAsync(node, options, startedAtUtc).ConfigureAwait(false);

View file

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Aetheria.State.Daemon.Smoke")]

View file

@ -2,6 +2,7 @@ using Aetheria.State.Documents;
using GameCult.Caching;
using GameCult.Aetheria.State.Verse;
using GameCult.Eve.Surface;
using GameCult.Eve.PluginFields;
using GameCult.Mesh;
using GameCult.Networking;
@ -60,6 +61,7 @@ public static class AetheriaDocumentRegistry
typeof(AetheriaRuntimeStarbridgeScenarioDocument),
typeof(AetheriaRuntimeStarbridgeSessionDocument),
typeof(AetheriaRuntimeStarbridgeSessionSummaryDocument),
typeof(EveFieldsSplatsDocument),
typeof(EveInputCapabilityDocument),
typeof(AetheriaRuntimeDaemonSoaViewDocument),
typeof(AetheriaRuntimeVerseAuthorityPolicyDocument),

View file

@ -8,6 +8,8 @@ namespace GameCult.Aetheria.State.Verse
{
public static class AetheriaRuntimeAssets
{
public const string PresentationHullItemTagPrefix = "presentation-hull-item:";
public static string ResolveEntityPrefabAssetRef(
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeCatalogSnapshot? catalog = null)
@ -15,7 +17,7 @@ namespace GameCult.Aetheria.State.Verse
if (entity == null) return "";
var hull = catalog?.FindItem(entity.HullItemKey ?? "");
if (hull != null && !string.IsNullOrWhiteSpace(hull.HullPrefab))
return HullPrefabAssetKey(hull.ItemKey);
return HullPrefabAssetKey(ResolvePresentationHull(hull, catalog!).ItemKey);
var kind = (entity.Kind ?? "").Trim().ToLowerInvariant();
if (kind.Contains("station")) return "prefab.entity.station";
if (kind.Contains("projectile")) return "prefab.entity.projectile";
@ -79,6 +81,9 @@ namespace GameCult.Aetheria.State.Verse
if (!string.IsNullOrWhiteSpace(item.HullPrefab))
{
var presentationHull = ResolvePresentationHull(item, catalog!);
if (!string.Equals(presentationHull.ItemKey, item.ItemKey, StringComparison.Ordinal))
continue;
Add(entries, MapPrefab(
HullPrefabAssetKey(item.ItemKey),
string.IsNullOrWhiteSpace(item.Name) ? item.ItemKey : item.Name,
@ -110,6 +115,28 @@ namespace GameCult.Aetheria.State.Verse
};
}
private static AetheriaRuntimeCatalogItem ResolvePresentationHull(
AetheriaRuntimeCatalogItem hull,
AetheriaRuntimeCatalogSnapshot catalog)
{
var aliases = (hull.Tags ?? Array.Empty<string>())
.Where(tag => tag != null && tag.StartsWith(PresentationHullItemTagPrefix, StringComparison.Ordinal))
.Select(tag => tag.Substring(PresentationHullItemTagPrefix.Length).Trim())
.Where(itemKey => itemKey.Length > 0)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (aliases.Length == 0)
return hull;
if (aliases.Length != 1)
throw new InvalidOperationException($"Hull '{hull.ItemKey}' advertises multiple presentation hulls.");
var presentationHull = catalog.FindItem(aliases[0]);
if (presentationHull == null || string.IsNullOrWhiteSpace(presentationHull.HullPrefab))
throw new InvalidOperationException(
$"Hull '{hull.ItemKey}' presentation hull '{aliases[0]}' is missing or has no provider prefab.");
return presentationHull;
}
public static AetheriaRuntimeAssetRef ResolveEntityIcon(AetheriaRuntimeViewportObject? obj)
{
if (obj?.Controlled == true)

View file

@ -1766,6 +1766,27 @@ namespace GameCult.Aetheria.State.Verse
return true;
}
if (string.Equals(
Environment.GetEnvironmentVariable("AETHERIA_TRACE_EVE_SNAPSHOTS"),
"1",
StringComparison.Ordinal))
{
var candidates = entities
.Where(candidate => candidate != null && candidate.EntityIndex != actorIndex)
.Select(candidate =>
{
var dx = candidate.PositionX - actor.PositionX;
var dz = candidate.PositionZ - actor.PositionZ;
var bays = candidate.DockingBays ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
var assignments = candidate.DockingBayAssignments ?? Array.Empty<int>();
return $"{candidate.EntityIndex}:distance={Math.Sqrt((dx * dx) + (dz * dz)):0.###}," +
$"bays={bays.Count},assignments=[{string.Join(",", assignments)}]";
});
Console.WriteLine(
$"Dock-nearest rejected actor={actorEntityKey} maxDistance={maxDistance:0.###} " +
$"candidates=[{string.Join(";", candidates)}]");
}
return false;
}
@ -1864,6 +1885,10 @@ namespace GameCult.Aetheria.State.Verse
.ToArray();
assignments[bayIndex] = actorIndex;
target.DockingBayAssignments = assignments.ToArray();
actor.VelocityX = 0;
actor.VelocityY = 0;
actor.HelmStrafe = 0;
actor.HelmForward = 0;
DisarmWeapons(actor);
return true;
}

View file

@ -663,7 +663,10 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted.
records. The Eve command lane is transport, not renderer-owned state.
- `Aetheria.State.Daemon` hosts the CultMesh Verse member and owns long-running
Eve command acceptance for provider-owned surfaces. `--once` runs one daemon
tick for smoke/operator use without keeping the process alive.
tick for smoke/operator use without keeping the process alive. Its public
CultCache registry includes the typed Eve Fields splat document that the
daemon persists, so its own state remains reopenable after a viewport has
been published.
- `AetheriaEveCommandAcceptanceStatus` and the `aetheria.operations` Eve surface
publish Eve request acceptance health, observed depth, applied counts,
failures, and timestamps as typed state. Console logs are notification-only.
@ -1490,6 +1493,29 @@ edges with one worker and exactly two start/transfer/completion event triplets.
GUIDs plus missing `Thruster` equipment required to fill their authored
mounts, so the supplemental catalog cannot overwrite live rows or silently
revive unrelated obsolete content.
- Dockyard content owner: `AetheriaDaemonNativeCatalog` materializes the
daemon-authored `Zenith Dockyard` hull and its matching `Dockyard Berth` as
typed catalog documents. The canonical Zenith and medium bay contribute
manufacturer, presentation, thermal, and ship-size facts; the daemon-owned
hull definition contributes the distinct 24x24 layout and twelve explicit
`DockingBay` hardpoints. Bay count is derived from installed equipment after
ordinary loadout generation. Until it receives a distinct baked mesh, the
Dockyard explicitly aliases the canonical Zenith provider asset; the asset
manifest publishes that existing asset identity instead of inventing an
unbaked per-item prefab. Entity fixtures, docking commands, imported
fossil rows, Eve surfaces, and Unity lowerers cannot append bays or revise
station capacity. Stations without dedicated docking mounts retain one
historical free-space bay path, which excludes equipment authored for a
dedicated `DockingBay` mount. The Anchor Station selects the
dockyard hull through the same generator entry point as every other entity;
the released-client proof gives its pilot daemon-authored initial velocity
that keeps it inside the docking envelope while the client negotiates, and a
successful dock clears velocity and helm state before Ymir excludes the
attached child body. The proof scenario also makes its one-point-hull raider
the sole hostile faction member, so generic focus selection and the asserted
destruction target cannot drift apart as other Ymir bodies move;
smoke coverage proves all twelve mounts fill with matching typed equipment
and that the old free-space path cannot consume the dedicated berth item.
- Generation owner: one daemon loadout generator owns one advancing mutable
CultMath random stream. A readonly struct field is forbidden because it
mutates a defensive copy and restarts every weighted draw. Price, allegiance,