From 0b5284f5afedcce2016f03de3cce7cd71d01594a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 15:59:35 +0100 Subject: [PATCH] Add daemon-owned dockyard station variant --- Aetheria.State.Daemon.Smoke/Program.cs | 136 +++++++++++- .../AetheriaDaemonLoadoutGenerator.cs | 18 +- .../AetheriaDaemonNativeCatalog.cs | 196 ++++++++++++++++++ .../AetheriaDaemonZoneGenerator.cs | 15 +- Aetheria.State.Daemon/Program.cs | 1 + .../Properties/AssemblyInfo.cs | 3 + Aetheria.State/AetheriaDocumentRegistry.cs | 2 + .../Runtime/AetheriaRuntimeAssets.cs | 29 ++- .../AetheriaRuntimeDaemonOperations.cs | 25 +++ docs/aetheria-perfect-machine-map.md | 28 ++- 10 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 Aetheria.State.Daemon/AetheriaDaemonNativeCatalog.cs create mode 100644 Aetheria.State.Daemon/Properties/AssemblyInfo.cs diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index e4e368b9e..9f5bf2a4a 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -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()); 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(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(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 entities, params AetheriaRuntimeCatalogItem[] additionalItems) diff --git a/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs b/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs index f2397265d..8b55c89ac 100644 --- a/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs +++ b/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs @@ -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()) occupied.Add((hardpoint.PositionX + cell.X, hardpoint.PositionY + cell.Y)); var slots = new List(); - if (string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase)) + var hasDedicatedDockingMounts = (hull.Hardpoints ?? Array.Empty()) + .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(); foreach (var hardpoint in (hull.Hardpoints ?? Array.Empty()) @@ -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, diff --git a/Aetheria.State.Daemon/AetheriaDaemonNativeCatalog.cs b/Aetheria.State.Daemon/AetheriaDaemonNativeCatalog.cs new file mode 100644 index 000000000..8cfce1334 --- /dev/null +++ b/Aetheria.State.Daemon/AetheriaDaemonNativeCatalog.cs @@ -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( + AetheriaCatalogKeys.ItemDefinitionFromLegacyId(ZenithHullLegacyId)) + .ReadAsync() + .ConfigureAwait(false) + ?? throw new InvalidDataException("The daemon-native dockyard requires the canonical Zenith station hull."); + var mediumBay = await node.MutableDocument( + AetheriaCatalogKeys.ItemDefinitionFromLegacyId(MediumDockingBayLegacyId)) + .ReadAsync() + .ConfigureAwait(false) + ?? throw new InvalidDataException("The daemon-native dockyard requires the canonical medium docking bay."); + + await node.MutableDocument( + AetheriaCatalogKeys.ItemDefinitionFromLegacyId(DockyardHullLegacyId)) + .ReplaceAsync(CreateDockyardHull(zenith)) + .ConfigureAwait(false); + await node.MutableDocument( + 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? cells) => + (cells ?? []).Select(cell => new AetheriaShapeCell { X = cell.X, Y = cell.Y }).ToArray(); + + private static string[] Tags(IEnumerable? tags, params string[] added) => + (tags ?? []).Concat(added).Distinct(StringComparer.Ordinal).ToArray(); + + private static string ItemKey(string legacyId) => $"aetheria.item_definition:legacy:{legacyId}"; +} diff --git a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs index fe56fa3b1..d251e8e75 100644 --- a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs +++ b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs @@ -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, diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index d635069ec..4e8c5903d 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -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); diff --git a/Aetheria.State.Daemon/Properties/AssemblyInfo.cs b/Aetheria.State.Daemon/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..2f3bbef3f --- /dev/null +++ b/Aetheria.State.Daemon/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Aetheria.State.Daemon.Smoke")] diff --git a/Aetheria.State/AetheriaDocumentRegistry.cs b/Aetheria.State/AetheriaDocumentRegistry.cs index e224af8cb..a89f636c0 100644 --- a/Aetheria.State/AetheriaDocumentRegistry.cs +++ b/Aetheria.State/AetheriaDocumentRegistry.cs @@ -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), diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs index d39978379..eac39825f 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs @@ -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()) + .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) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs index 48e27f134..299a6b729 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs @@ -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(); + var assignments = candidate.DockingBayAssignments ?? Array.Empty(); + 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; } diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 4b40754f9..942c54dfb 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -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,