From f1b429931228089a6022dcfc595b64c62155c6bf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 17 Jul 2026 01:45:33 +0100 Subject: [PATCH] Publish daemon-owned docked refit controls --- Aetheria.State.Daemon.Smoke/Program.cs | 124 +++++++++++++ Aetheria.State.Verify/Program.cs | 29 +++- .../Tests/DaemonRuntimeDocumentTests.cs | 144 ++++++++++++++-- ...AetheriaRuntimeDaemonGameSurfaceBuilder.cs | 163 ++++++++++++++++++ .../AetheriaRuntimeDaemonOperations.cs | 39 +++++ .../AetheriaRuntimeDaemonOperationsClient.cs | 36 ++++ .../AetheriaRuntimeEveSurfaceCatalog.cs | 1 - docs/aetheria-current-codebase-model.md | 7 +- docs/aetheria-perfect-machine-map.md | 13 ++ 9 files changed, 529 insertions(+), 27 deletions(-) diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 49f8e7558..adedc0e14 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -4,6 +4,8 @@ using Aetheria.State.Documents; using GameCult.Aetheria.State.Verse; using GameCult.Caching; using GameCult.Eve.PluginFields; +using GameCult.Eve.Surface; +using GameCult.Mesh; using System.Globalization; var checks = new AetheriaDaemonYmirSmokeChecks(); @@ -170,6 +172,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks DockingUsesRealBaysAndFossilUndockRules, RefitIsAtomicAndUsesTypedPlacement, CargoTransferUsesSpatialAtomicTransaction, + DockedRefitSurfacePublishesGenericCommands, TractorRampsAndPullsThroughYmirWithoutTeleportingCargo, PickupIsCapacityCheckedExactlyOnceAndExpires, TradePurchaseDerivesAcceptanceFromDaemonState, @@ -215,6 +218,127 @@ internal sealed class AetheriaDaemonYmirSmokeChecks RunCheck(check); } + private static void DockedRefitSurfacePublishesGenericCommands() + { + var hull = HullCatalogItem("surface-hull", 9, 7, 100); + var cargoBay = CatalogItem("surface-cargo-bay"); + cargoBay.InteriorShapeWidth = 6; + cargoBay.InteriorShapeHeight = 4; + var catalog = new AetheriaRuntimeCatalogSnapshot([hull, cargoBay, CatalogItem("ore")], [], []); + var ship = Entity(0, 0, "player"); + ship.Name = "Pilot"; + ship.HullItemKey = hull.ItemKey; + ship.CargoBays = [ItemSlot(cargoBay.ItemKey)]; + ship.CargoContents = [new AetheriaRuntimeCargoBayLoadoutCommit { Items = [ItemSlot("ore", 2, 3)] }]; + var station = Entity(1, 0, "player"); + station.Kind = "station"; + station.Name = "Station"; + station.DockingBayAssignments = [ship.EntityIndex]; + station.CargoBays = [ItemSlot(cargoBay.ItemKey)]; + station.CargoContents = [new AetheriaRuntimeCargoBayLoadoutCommit()]; + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "generic-refit-surface-smoke", + CurrentZoneIndex = 0, + CurrentEntityKey = "zone.0.entity.0", + Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [ship, station] }] + }; + var frame = AetheriaRuntimeDaemonFrameDocument.Create(run, "smoke", "surface", 1, 0.02, 0.02); + var shipKey = run.EntityRecordKey(0, ship.EntityIndex); + var stationKey = run.EntityRecordKey(0, station.EntityIndex); + var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build( + frame, + new AetheriaRuntimeDaemonHealthDocument(), + AetheriaRuntimeDaemonCommandBoundaryDocument.Create("smoke"), + catalog: catalog); + var equipment = Find(surface.Surface.Root, "aetheria.daemon.game.refit.ship.equipment"); + var shipCargo = Find(surface.Surface.Root, "aetheria.daemon.game.refit.ship.cargo.0"); + var stationCargo = Find(surface.Surface.Root, "aetheria.daemon.game.refit.station.cargo.0"); + Require(equipment != null && equipment.Kind == "inventory.grid" && + equipment.Props["columns"] == "9" && equipment.Props["rows"] == "7", + "docked pilot surface must publish authored ship equipment geometry"); + Require(shipCargo != null && stationCargo != null && + shipCargo.Props["targetEntityKey"] == shipKey && + stationCargo.Props["targetEntityKey"] == stationKey, + "docked pilot surface must publish both ship and station cargo targets"); + Require(surface.Commands.Any(value => value.Command == + AetheriaRuntimeDaemonSurfaceCommandCatalog.CommandName(AetheriaRuntimeDaemonCommandKinds.TransferCargoItem)) && + surface.Commands.Any(value => value.Command == + AetheriaRuntimeDaemonSurfaceCommandCatalog.CommandName(AetheriaRuntimeDaemonCommandKinds.EquipItem)) && + surface.Commands.Any(value => value.Command == + AetheriaRuntimeDaemonSurfaceCommandCatalog.CommandName(AetheriaRuntimeDaemonCommandKinds.StoreItem)), + "docked pilot surface must advertise all atomic refit operations"); + + var request = new EveSurfaceCommandRequest( + "aetheria", + AetheriaRuntimeDaemonGameSurfaceBuilder.PilotSurfaceId, + CultMesh.OperationInvocation( + AetheriaRuntimeDaemonSurfaceCommandCatalog.CommandName(AetheriaRuntimeDaemonCommandKinds.TransferCargoItem)), + CultMesh.OperationPayload( + ("entityId", shipKey), + ("originEntityKey", shipKey), + ("originCargoIndex", "0"), + ("destinationEntityKey", stationKey), + ("destinationCargoIndex", "0"), + ("itemKey", "ore"), + ("quantity", "1"), + ("sourceX", "2"), + ("sourceY", "3"), + ("destinationX", "0"), + ("destinationY", "0"), + ("hasDestinationPosition", "true")), + DateTimeOffset.UtcNow, + "generic-smoke"); + Require(AetheriaRuntimeDaemonOperationsClient.TryCreateSurfaceCommandDocument( + request, frame, ".", "generic-smoke", "surface", out var translated) && + translated?.CargoTransfer.OriginCargoIndex == 0 && + translated.CargoTransfer.DestinationEntityKey == stationKey && + translated.CargoTransfer.HasDestinationPosition, + "generic Eve inventory drop must translate into the exact typed daemon cargo command"); + + var unauthorized = AetheriaRuntimeDaemonCommandDocument.Create( + AetheriaRuntimeDaemonCommandKinds.TransferCargoItem, + "generic-smoke", "surface", 1, shipKey); + unauthorized.TextValue = "ore"; + unauthorized.CargoTransfer.OriginEntityKey = stationKey; + unauthorized.CargoTransfer.DestinationEntityKey = stationKey; + unauthorized.CargoTransfer.OriginCargoIndex = 0; + unauthorized.CargoTransfer.DestinationCargoIndex = 0; + var rejected = AetheriaRuntimeDaemonOperations.Execute(run, [unauthorized], + new AetheriaRuntimeDaemonOperationContext { Catalog = catalog }); + Require(rejected.RejectedCommandReasons[unauthorized.CommandId] == + AetheriaRuntimeDaemonRejectionReasons.CargoAccessDenied, + "cargo transfer actor must own one endpoint even when the target is physically nearby"); + + station.DockingBayAssignments = []; + var undocked = AetheriaRuntimeDaemonGameSurfaceBuilder.Build( + frame, + new AetheriaRuntimeDaemonHealthDocument(), + AetheriaRuntimeDaemonCommandBoundaryDocument.Create("smoke"), + catalog: catalog); + Require(Find(undocked.Surface.Root, "aetheria.daemon.game.refit") == null, + "undocked pilot surfaces must not expose station refit controls"); + + static AetheriaRuntimeSurfaceComponent? Find(AetheriaRuntimeSurfaceComponent component, string id) => + component.Id == id + ? component + : component.Children.Select(child => Find(child, id)).FirstOrDefault(value => value != null); + + static AetheriaRuntimeLoadoutItemSlotCommit ItemSlot(string itemKey, int x = 0, int y = 0) => new() + { + X = x, + Y = y, + Item = new AetheriaRuntimeLoadoutItemCommit + { + ItemKey = itemKey, + Quantity = 1, + Quality = 1, + Durability = 1, + Enabled = true + } + }; + } + private static void DockingUsesRealBaysAndFossilUndockRules() { var catalog = new AetheriaRuntimeCatalogSnapshot( diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 7a76a6baf..83a98f616 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -19789,10 +19789,14 @@ static void RequireInventoryDoubleClickTransferRequestAuthority(string root) ? File.ReadAllText(operationClientPath) : throw new InvalidOperationException("Cannot verify inventory transfer authority; AetheriaRuntimeDaemonOperationClient.cs is missing."); - var panelSurfacePath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeInventoryPanelSurfaceBuilder.cs"); + var panelSurfacePath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonGameSurfaceBuilder.cs"); var panelSurface = File.Exists(panelSurfacePath) ? File.ReadAllText(panelSurfacePath) - : throw new InvalidOperationException("Cannot verify inventory transfer authority; AetheriaRuntimeInventoryPanelSurfaceBuilder.cs is missing."); + : throw new InvalidOperationException("Cannot verify inventory transfer authority; AetheriaRuntimeDaemonGameSurfaceBuilder.cs is missing."); + var surfaceCatalogPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeEveSurfaceCatalog.cs"); + var surfaceCatalog = File.Exists(surfaceCatalogPath) + ? File.ReadAllText(surfaceCatalogPath) + : throw new InvalidOperationException("Cannot verify inventory transfer authority; AetheriaRuntimeEveSurfaceCatalog.cs is missing."); var requiredDaemonRequests = new[] { @@ -19817,16 +19821,24 @@ static void RequireInventoryDoubleClickTransferRequestAuthority(string root) var requiredSurfaceSymbols = new[] { - "AetheriaRuntimeInventoryPanelSurfaceBuilder", + "DockedRefitPanel(", "\"inventory.grid\"", "\"inventory.item\"", "\"itemKey\"", - "\"source\"", + "\"sourceKind\"", + "\"sourceEntityKey\"", "\"sourceIndex\"", + "\"targetKind\"", + "\"targetEntityKey\"", + "\"targetIndex\"", + "\"dropCommand.cargo\"", + "\"dropCommand.equipment\"", "\"x\"", "\"y\"", "\"quantity\"", - "AetheriaRuntimeSurfaceCommandTemplate.CultMeshTransport" + "SurfaceCommand(AetheriaRuntimeDaemonCommandKinds.TransferCargoItem)", + "SurfaceCommand(AetheriaRuntimeDaemonCommandKinds.EquipItem)", + "SurfaceCommand(AetheriaRuntimeDaemonCommandKinds.StoreItem)" }; var missingSurfaceSymbols = requiredSurfaceSymbols .Where(symbol => !panelSurface.Contains(symbol, StringComparison.Ordinal)) @@ -19837,6 +19849,13 @@ static void RequireInventoryDoubleClickTransferRequestAuthority(string root) "Inventory panel surface must publish typed item identity and command transport data through CultUI: " + string.Join(", ", missingSurfaceSymbols)); } + if (surfaceCatalog.Contains( + "Surface(AetheriaRuntimeInventoryPanelSurfaceBuilder.SurfaceId", + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The request-local Unity inventory panel is still advertised as a player gameplay surface."); + } var forbiddenUnityInventoryAcceptanceSymbols = new[] { diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 7ac226d40..1939d9ab8 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -518,6 +518,68 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual(AetheriaRuntimeDaemonSoaColumnKinds.Position, portable.Columns.Single().Semantic); } + [Test] + public void DockedPilotSurfacePublishesGenericSpatialRefitControls() + { + var run = RunWithTwoEntities(); + var ship = run.Zones[0].Entities[0]; + var station = run.Zones[0].Entities[1]; + ship.HullItemKey = "test-hull"; + ship.CargoBays = new[] { EquippedItem("test-cargo-bay") }; + station.Kind = "station"; + station.DockingBayAssignments = new[] { ship.EntityIndex }; + station.CargoBays = new[] { EquippedItem("test-cargo-bay") }; + station.CargoContents = new[] { new AetheriaRuntimeCargoBayLoadoutCommit() }; + var hull = CatalogItem("test-hull", Array.Empty()); + hull.InteriorShapeWidth = 9; + hull.InteriorShapeHeight = 7; + var cargoBay = CatalogItem("test-cargo-bay", Array.Empty()); + cargoBay.InteriorShapeWidth = 6; + cargoBay.InteriorShapeHeight = 4; + var catalog = new AetheriaRuntimeCatalogSnapshot( + new[] { hull, cargoBay, CatalogItem("ore", Array.Empty()) }, + Array.Empty(), + Array.Empty()); + var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build( + AetheriaRuntimeDaemonFrameDocument.Create(run, "test-daemon", "session", 42, 1.0, 0.02), + new AetheriaRuntimeDaemonHealthDocument(), + AetheriaRuntimeDaemonCommandBoundaryDocument.Create("test-daemon"), + catalog: catalog); + var shipKey = run.EntityRecordKey(0, ship.EntityIndex); + var stationKey = run.EntityRecordKey(0, station.EntityIndex); + + var panel = FindSurfaceComponent(surface.Surface.Root, "aetheria.daemon.game.refit"); + var equipment = FindSurfaceComponent(surface.Surface.Root, "aetheria.daemon.game.refit.ship.equipment"); + var shipCargo = FindSurfaceComponent(surface.Surface.Root, "aetheria.daemon.game.refit.ship.cargo.0"); + var stationCargo = FindSurfaceComponent(surface.Surface.Root, "aetheria.daemon.game.refit.station.cargo.0"); + Assert.IsNotNull(panel); + Assert.AreEqual("panel.refit", panel.Kind); + Assert.AreEqual("inventory.grid", equipment.Kind); + Assert.AreEqual("9", equipment.Props["columns"]); + Assert.AreEqual("7", equipment.Props["rows"]); + Assert.AreEqual("6", shipCargo.Props["columns"]); + Assert.AreEqual("4", stationCargo.Props["rows"]); + Assert.AreEqual(shipKey, shipCargo.Props["targetEntityKey"]); + Assert.AreEqual(stationKey, stationCargo.Props["targetEntityKey"]); + Assert.AreEqual( + AetheriaRuntimeDaemonSurfaceCommandCatalog.CommandName(AetheriaRuntimeDaemonCommandKinds.TransferCargoItem), + shipCargo.Props["dropCommand.cargo"]); + Assert.IsTrue(surface.Commands.Any(command => + command.Command == AetheriaRuntimeDaemonSurfaceCommandCatalog.CommandName(AetheriaRuntimeDaemonCommandKinds.TransferCargoItem))); + Assert.IsTrue(surface.Commands.Any(command => + command.Command == AetheriaRuntimeDaemonSurfaceCommandCatalog.CommandName(AetheriaRuntimeDaemonCommandKinds.EquipItem))); + Assert.IsTrue(surface.Commands.Any(command => + command.Command == AetheriaRuntimeDaemonSurfaceCommandCatalog.CommandName(AetheriaRuntimeDaemonCommandKinds.StoreItem))); + + station.DockingBayAssignments = Array.Empty(); + var undocked = AetheriaRuntimeDaemonGameSurfaceBuilder.Build( + AetheriaRuntimeDaemonFrameDocument.Create(run, "test-daemon", "session", 43, 1.02, 0.02), + new AetheriaRuntimeDaemonHealthDocument(), + AetheriaRuntimeDaemonCommandBoundaryDocument.Create("test-daemon"), + catalog: catalog); + Assert.IsNull(FindSurfaceComponent(undocked.Surface.Root, "aetheria.daemon.game.refit")); + } + [Test] public void TickRunnerAppliesObservedCommandsAndPublishesFrame() { @@ -3569,39 +3631,47 @@ public class DaemonRuntimeDocumentTests } [Test] - public void DaemonEveSurfaceCommandRejectsUnsupportedArgumentlessOperation() + public void DaemonEveSurfaceCommandTranslatesGenericInventoryDrop() { - var statePath = Path.Combine( - Path.GetTempPath(), - "aetheria-daemon-surface-command-tests", - Path.GetRandomFileName(), - "state.cc"); var frame = AetheriaRuntimeDaemonFrameDocument.Create( - new AetheriaRuntimeRunCheckpointCommit - { - CurrentEntityKey = "entity:surface-player" - }, + RunWithTwoEntities(), "aetheria-daemon", "session-surface-command", 78, 1.5, 0.02); - PublishLatestFrameThroughVerseClient(statePath, frame); var request = new EveSurfaceCommandRequest( "aetheria.daemon", AetheriaRuntimeDaemonGameSurfaceBuilder.SurfaceId, CultMesh.OperationInvocation("aetheria.daemon.commands.TransferCargoItem"), - CultMesh.OperationPayload(), + CultMesh.OperationPayload( + ("entityId", "zone.0.entity.0"), + ("originEntityKey", "zone.0.entity.0"), + ("originCargoIndex", "0"), + ("destinationEntityKey", "zone.0.entity.1"), + ("destinationCargoIndex", "0"), + ("itemKey", "ore"), + ("quantity", "12"), + ("sourceX", "2"), + ("sourceY", "3"), + ("destinationX", "5"), + ("destinationY", "6"), + ("hasDestinationPosition", "true")), DateTimeOffset.UtcNow, "unity-uitoolkit"); - using var client = AetheriaClient - .OpenAsync(statePath, "unity-uitoolkit", startServer: false, pullOnOpen: true) - .GetAwaiter() - .GetResult(); - - Assert.IsFalse(AetheriaRuntimeDaemonSurfaceCommands.TrySubmit(client, request, out var envelope)); - Assert.IsNull(envelope); + Assert.IsTrue(AetheriaRuntimeDaemonOperationsClient.TryCreateSurfaceCommandDocument( + request, frame, ".", "unity-uitoolkit", "session-surface-command", out var command)); + Assert.IsNotNull(command); + Assert.AreEqual(AetheriaRuntimeDaemonCommandKinds.TransferCargoItem, command.Kind); + Assert.AreEqual("zone.0.entity.0", command.ActorEntityKey); + Assert.AreEqual("zone.0.entity.0", command.CargoTransfer.OriginEntityKey); + Assert.AreEqual(0, command.CargoTransfer.OriginCargoIndex); + Assert.AreEqual("zone.0.entity.1", command.CargoTransfer.DestinationEntityKey); + Assert.AreEqual(0, command.CargoTransfer.DestinationCargoIndex); + Assert.AreEqual(5, command.CargoTransfer.DestinationX); + Assert.AreEqual(6, command.CargoTransfer.DestinationY); + Assert.IsTrue(command.CargoTransfer.HasDestinationPosition); } [Test] @@ -4074,6 +4144,31 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual(6, destinationCargo[0].Y); } + [Test] + public void DaemonOperationsRejectsCargoTransferWhenActorOwnsNeitherEndpoint() + { + var run = RunWithTwoEntities(); + var command = AetheriaRuntimeDaemonCommandDocument.Create( + AetheriaRuntimeDaemonCommandKinds.TransferCargoItem, + "codex", + "session-inventory-access", + 25, + "zone.0.entity.0"); + command.TextValue = "ore"; + command.CargoTransfer.OriginEntityKey = "zone.0.entity.1"; + command.CargoTransfer.OriginCargoIndex = 0; + command.CargoTransfer.DestinationEntityKey = "zone.0.entity.1"; + command.CargoTransfer.DestinationCargoIndex = 0; + + var result = AetheriaRuntimeDaemonOperations.Execute(run, new[] { command }); + + Assert.AreEqual(0, result.AppliedCommandIds.Count); + Assert.AreEqual(1, result.RejectedCommandIds.Count); + Assert.AreEqual( + AetheriaRuntimeDaemonRejectionReasons.CargoAccessDenied, + result.RejectedCommandReasons[command.CommandId]); + } + [Test] public void DaemonOperationsRejectsSameCargoTransferWithoutReposition() { @@ -6365,6 +6460,17 @@ public class DaemonRuntimeDocumentTests component.Children.Any(child => ContainsSurfaceKind(child, kind)); } + private static AetheriaRuntimeSurfaceComponent FindSurfaceComponent( + AetheriaRuntimeSurfaceComponent component, + string id) + { + if (string.Equals(component.Id, id, StringComparison.Ordinal)) + return component; + return component.Children + .Select(child => FindSurfaceComponent(child, id)) + .FirstOrDefault(child => child != null); + } + private static bool ContainsSurfaceValueFragment(AetheriaRuntimeSurfaceComponent component, string fragment) { if (component.Props.Values.Any(value => diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs index 4f6e62697..1618b2982 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs @@ -103,6 +103,9 @@ namespace GameCult.Aetheria.State.Verse "state.group", Array.Empty<(string Key, string Value)>(), WeaponStateItems(entity, run, zone))); + var refitPanel = DockedRefitPanel(run, zone, entity, catalog); + if (refitPanel != null) + surfaceChildren.Add(refitPanel); return new AetheriaRuntimeSurfaceDocument( providerId: "aetheria", @@ -124,6 +127,9 @@ namespace GameCult.Aetheria.State.Verse "cultmesh")) .Concat(new[] { + SurfaceCommand(AetheriaRuntimeDaemonCommandKinds.TransferCargoItem), + SurfaceCommand(AetheriaRuntimeDaemonCommandKinds.EquipItem), + SurfaceCommand(AetheriaRuntimeDaemonCommandKinds.StoreItem), new AetheriaRuntimeSurfaceCommandTemplate("aetheria.main_menu.root.continue", "Continue", "cultmesh"), new AetheriaRuntimeSurfaceCommandTemplate("aetheria.main_menu.root.new_game", "New Game", "cultmesh"), new AetheriaRuntimeSurfaceCommandTemplate("aetheria.main_menu.root.show_settings", "Settings", "cultmesh"), @@ -180,6 +186,163 @@ namespace GameCult.Aetheria.State.Verse .ToArray()); } + private static AetheriaRuntimeSurfaceCommandTemplate SurfaceCommand( + AetheriaRuntimeDaemonCommandKinds kind) => + new AetheriaRuntimeSurfaceCommandTemplate( + CommandName(kind), + AetheriaRuntimeDaemonSurfaceCommandCatalog.Label(kind), + "cultmesh"); + + private static AetheriaRuntimeSurfaceComponent? DockedRefitPanel( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeZoneSnapshotCommit zone, + AetheriaRuntimeEntitySnapshotCommit? entity, + AetheriaRuntimeCatalogSnapshot? catalog) + { + if (entity == null || catalog == null) + return null; + + var parent = FindDockParent(zone, entity.EntityIndex); + if (parent == null) + return null; + + var entityKey = run.EntityRecordKey(zone.ZoneIndex, entity.EntityIndex); + var parentKey = run.EntityRecordKey(zone.ZoneIndex, parent.EntityIndex); + var inventories = new List + { + EquipmentGrid("aetheria.daemon.game.refit.ship.equipment", entity, entityKey, catalog) + }; + inventories.AddRange(CargoGrids("aetheria.daemon.game.refit.ship.cargo", entity, entityKey, catalog)); + inventories.AddRange(CargoGrids("aetheria.daemon.game.refit.station.cargo", parent, parentKey, catalog)); + + return Node( + "aetheria.daemon.game.refit", + "panel.refit", + new[] + { + ("title", "Docked Refit"), + ("entityKey", entityKey), + ("stationEntityKey", parentKey), + ("visible", "true"), + ("placement", "overlay"), + ("anchor", "right"), + ("overflow", "scroll") + }, + inventories.ToArray()); + } + + private static AetheriaRuntimeSurfaceComponent EquipmentGrid( + string id, + AetheriaRuntimeEntitySnapshotCommit entity, + string entityKey, + AetheriaRuntimeCatalogSnapshot catalog) + { + var hull = catalog.FindItem(entity.HullItemKey ?? ""); + var children = new List(); + AddInventoryItems(children, id, entity.Equipment, AetheriaRuntimeRefitSourceKinds.Equipment, entityKey, catalog); + AddInventoryItems(children, id, entity.CargoBays, AetheriaRuntimeRefitSourceKinds.CargoBay, entityKey, catalog); + AddInventoryItems(children, id, entity.DockingBays, AetheriaRuntimeRefitSourceKinds.DockingBay, entityKey, catalog); + return Node( + id, + "inventory.grid", + new[] + { + ("title", string.IsNullOrWhiteSpace(entity.Name) ? "Ship Equipment" : entity.Name + " Equipment"), + ("targetKind", AetheriaRuntimeRefitSourceKinds.Equipment), + ("targetEntityKey", entityKey), + ("targetIndex", "-1"), + ("columns", Math.Max(1, hull?.InteriorShapeWidth ?? 1).ToString(CultureInfo.InvariantCulture)), + ("rows", Math.Max(1, hull?.InteriorShapeHeight ?? 1).ToString(CultureInfo.InvariantCulture)), + ("cellSize", "36"), + ("cellGap", "2"), + ("dropCommand.cargo", CommandName(AetheriaRuntimeDaemonCommandKinds.EquipItem)), + ("dropCommand.equipment", CommandName(AetheriaRuntimeDaemonCommandKinds.EquipItem)), + ("dropCommand.cargo-bay", CommandName(AetheriaRuntimeDaemonCommandKinds.EquipItem)), + ("dropCommand.docking-bay", CommandName(AetheriaRuntimeDaemonCommandKinds.EquipItem)) + }, + children.ToArray()); + } + + private static IEnumerable CargoGrids( + string idPrefix, + AetheriaRuntimeEntitySnapshotCommit entity, + string entityKey, + AetheriaRuntimeCatalogSnapshot catalog) + { + var bays = entity.CargoBays ?? Array.Empty(); + var contents = entity.CargoContents ?? Array.Empty(); + for (var bayIndex = 0; bayIndex < bays.Count; bayIndex++) + { + var bay = bays[bayIndex]; + var bayCatalog = catalog.FindItem(bay?.Item?.ItemKey ?? ""); + var id = idPrefix + "." + bayIndex.ToString(CultureInfo.InvariantCulture); + var children = new List(); + var items = bayIndex < contents.Count + ? contents[bayIndex]?.Items ?? Array.Empty() + : Array.Empty(); + AddInventoryItems(children, id, items, AetheriaRuntimeRefitSourceKinds.Cargo, entityKey, catalog, bayIndex); + yield return Node( + id, + "inventory.grid", + new[] + { + ("title", (string.IsNullOrWhiteSpace(entity.Name) ? "Cargo" : entity.Name + " Cargo") + " " + (bayIndex + 1).ToString(CultureInfo.InvariantCulture)), + ("targetKind", AetheriaRuntimeRefitSourceKinds.Cargo), + ("targetEntityKey", entityKey), + ("targetIndex", bayIndex.ToString(CultureInfo.InvariantCulture)), + ("columns", Math.Max(1, bayCatalog?.InteriorShapeWidth ?? 1).ToString(CultureInfo.InvariantCulture)), + ("rows", Math.Max(1, bayCatalog?.InteriorShapeHeight ?? 1).ToString(CultureInfo.InvariantCulture)), + ("cellSize", "36"), + ("cellGap", "2"), + ("dropCommand.cargo", CommandName(AetheriaRuntimeDaemonCommandKinds.TransferCargoItem)), + ("dropCommand.equipment", CommandName(AetheriaRuntimeDaemonCommandKinds.StoreItem)), + ("dropCommand.cargo-bay", CommandName(AetheriaRuntimeDaemonCommandKinds.StoreItem)), + ("dropCommand.docking-bay", CommandName(AetheriaRuntimeDaemonCommandKinds.StoreItem)) + }, + children.ToArray()); + } + } + + private static void AddInventoryItems( + ICollection destination, + string idPrefix, + IReadOnlyList slots, + string sourceKind, + string sourceEntityKey, + AetheriaRuntimeCatalogSnapshot catalog, + int cargoBayIndex = -1) + { + slots ??= Array.Empty(); + for (var slotIndex = 0; slotIndex < slots.Count; slotIndex++) + { + var slot = slots[slotIndex]; + if (slot?.Item == null || string.IsNullOrWhiteSpace(slot.Item.ItemKey)) + continue; + var item = catalog.FindItem(slot.Item.ItemKey); + var sourceIndex = string.Equals(sourceKind, AetheriaRuntimeRefitSourceKinds.Cargo, StringComparison.Ordinal) + ? cargoBayIndex + : slotIndex; + destination.Add(Node( + idPrefix + "." + SurfaceToken(sourceKind) + ".item." + slotIndex.ToString(CultureInfo.InvariantCulture), + "inventory.item", + new[] + { + ("label", string.IsNullOrWhiteSpace(item?.Name) ? slot.Item.ItemKey : item!.Name), + ("itemKey", slot.Item.ItemKey), + ("quantity", Math.Max(1, slot.Item.Quantity).ToString(CultureInfo.InvariantCulture)), + ("sourceKind", sourceKind), + ("sourceEntityKey", sourceEntityKey), + ("sourceIndex", sourceIndex.ToString(CultureInfo.InvariantCulture)), + ("x", slot.X.ToString(CultureInfo.InvariantCulture)), + ("y", slot.Y.ToString(CultureInfo.InvariantCulture)), + ("rotation", slot.Rotation ?? "None"), + ("shapeWidth", Math.Max(1, item?.ShapeWidth ?? 1).ToString(CultureInfo.InvariantCulture)), + ("shapeHeight", Math.Max(1, item?.ShapeHeight ?? 1).ToString(CultureInfo.InvariantCulture)), + ("draggable", "true") + })); + } + } + private static AetheriaRuntimeSurfaceComponent BuildStarbridgeSessionCard( AetheriaRuntimeStarbridgeSessionSummaryDocument starbridge) { diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs index 84fe25163..6e0417ea2 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs @@ -80,6 +80,7 @@ namespace GameCult.Aetheria.State.Verse public const string RefitNoFit = "refit-no-fit"; public const string InvalidCargoDestination = "invalid-cargo-destination"; public const string InvalidCargoSource = "invalid-cargo-source"; + public const string CargoAccessDenied = "cargo-access-denied"; public const string CargoNoFit = "cargo-no-fit"; public const string CargoStackLimit = "cargo-stack-limit"; public const string TradeRequiresDocked = "trade-requires-docked"; @@ -817,6 +818,9 @@ namespace GameCult.Aetheria.State.Verse if (!TryResolveEntity(run, transfer.OriginEntityKey, out _, out _, out var origin) || !TryResolveEntity(run, transfer.DestinationEntityKey, out _, out _, out var destination)) return context.Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidCargoSource); + if (!CanAccessCargoTransfer(run, command.ActorEntityKey, transfer.OriginEntityKey, origin, + transfer.DestinationEntityKey, destination)) + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.CargoAccessDenied); return AetheriaRuntimeRefitTransactions.TryTransferCargo( origin, @@ -835,6 +839,41 @@ namespace GameCult.Aetheria.State.Verse || context.Reject(reason); } + private static bool CanAccessCargoTransfer( + AetheriaRuntimeRunCheckpointCommit run, + string actorEntityKey, + string originEntityKey, + AetheriaRuntimeEntitySnapshotCommit origin, + string destinationEntityKey, + AetheriaRuntimeEntitySnapshotCommit destination) + { + if (!TryResolveEntity(run, actorEntityKey, out var actorZoneIndex, out _, out var actor)) + return false; + var actorIsOrigin = ReferenceEquals(actor, origin); + var actorIsDestination = ReferenceEquals(actor, destination); + if (!actorIsOrigin && !actorIsDestination) + return false; + + var other = actorIsOrigin ? destination : origin; + var otherKey = actorIsOrigin ? destinationEntityKey : originEntityKey; + if (ReferenceEquals(actor, other) || + (TryResolveDockParent(run, actorEntityKey, out _, out var actorParent) && ReferenceEquals(actorParent, other)) || + (TryResolveDockParent(run, otherKey, out _, out var otherParent) && ReferenceEquals(otherParent, actor))) + return true; + + if (!TryResolveEntity(run, otherKey, out var otherZoneIndex, out _, out _) || + actorZoneIndex != otherZoneIndex) + return false; + + var dx = actor.PositionX - other.PositionX; + var dz = actor.PositionZ - other.PositionZ; + var reach = CargoInteractionRadius(actor) + CargoInteractionRadius(other) + 10.0; + return dx * dx + dz * dz <= reach * reach; + } + + private static double CargoInteractionRadius(AetheriaRuntimeEntitySnapshotCommit entity) => + string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase) ? 48.0 : 20.0; + private static bool ApplyEquipItem( AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeDaemonCommandDocument command, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationsClient.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationsClient.cs index 10bb42d1f..f7378fb39 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationsClient.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationsClient.cs @@ -530,6 +530,42 @@ public sealed class AetheriaRuntimeDaemonOperationsClient (int)ReadPayloadDouble(request, "sourceCargoIndex", -1), (int)ReadPayloadDouble(request, "sourceX", int.MinValue), (int)ReadPayloadDouble(request, "sourceY", int.MinValue)), + AetheriaRuntimeDaemonCommandKinds.TransferCargoItem => client.TransferCargoItem( + frame, + ReadPayloadString(request, "originEntityKey", ""), + (int)ReadPayloadDouble(request, "originCargoIndex", -1), + ReadPayloadString(request, "destinationEntityKey", ""), + (int)ReadPayloadDouble(request, "destinationCargoIndex", -1), + ReadPayloadString(request, "itemKey", ""), + (int)ReadPayloadDouble(request, "quantity", 1), + (int)ReadPayloadDouble(request, "sourceX", int.MinValue), + (int)ReadPayloadDouble(request, "sourceY", int.MinValue), + (int)ReadPayloadDouble(request, "destinationX", int.MinValue), + (int)ReadPayloadDouble(request, "destinationY", int.MinValue), + ReadPayloadBool(request, "hasDestinationPosition", false)), + AetheriaRuntimeDaemonCommandKinds.EquipItem => client.EquipItem( + frame, + ReadPayloadString(request, "sourceKind", AetheriaRuntimeRefitSourceKinds.Cargo), + ReadPayloadString(request, "originEntityKey", ""), + (int)ReadPayloadDouble(request, "originIndex", -1), + ReadPayloadString(request, "destinationEntityKey", ""), + ReadPayloadString(request, "itemKey", ""), + (int)ReadPayloadDouble(request, "sourceX", int.MinValue), + (int)ReadPayloadDouble(request, "sourceY", int.MinValue), + (int)ReadPayloadDouble(request, "destinationX", int.MinValue), + (int)ReadPayloadDouble(request, "destinationY", int.MinValue), + ReadPayloadBool(request, "hasDestinationPosition", false)), + AetheriaRuntimeDaemonCommandKinds.StoreItem => client.StoreItem( + frame, + ReadPayloadString(request, "originEntityKey", ""), + (int)ReadPayloadDouble(request, "originIndex", -1), + ReadPayloadString(request, "destinationEntityKey", ""), + (int)ReadPayloadDouble(request, "destinationCargoIndex", -1), + ReadPayloadString(request, "itemKey", ""), + (int)ReadPayloadDouble(request, "destinationX", int.MinValue), + (int)ReadPayloadDouble(request, "destinationY", int.MinValue), + ReadPayloadBool(request, "hasDestinationPosition", false), + ReadPayloadString(request, "sourceKind", AetheriaRuntimeRefitSourceKinds.Equipment)), _ => AetheriaRuntimeDaemonSurfaceCommandCatalog.TrySubmitArgumentless( client, frame, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs index 7a0e83eef..9dc202a21 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs @@ -126,7 +126,6 @@ namespace GameCult.Aetheria.State.Verse Surface(AetheriaRuntimeClientTargetCommands.SurfaceId, "Client Target", "", "operator", "Local client target and Verse discovery surface.", status: "client-contextual"), Surface(AetheriaRuntimeLocalStorySurfaceBuilder.SurfaceId, "Local Story", "", "player", "Local runtime story/menu surface.", status: "client-contextual"), Surface(AetheriaRuntimeZoneDetailsSurfaceBuilder.SurfaceId, "Map Menu", AetheriaRuntimeVerseRecordKeys.MapMenuSurface.ToString(), "player", "Current-zone map details surface.", transport: "cultmesh-record"), - Surface(AetheriaRuntimeInventoryPanelSurfaceBuilder.SurfaceId, "Inventory Panel", AetheriaRuntimeVerseRecordKeys.InventoryPanelSurface.ToString(), "player", "Current inventory panel surface.", transport: "cultmesh-record"), Surface(AetheriaRuntimeInventoryDropdownSurfaceBuilder.SurfaceId, "Inventory Dropdown", AetheriaRuntimeVerseRecordKeys.InventoryDropdownSurface.ToString(), "player", "Inventory navigation dropdown surface.", transport: "cultmesh-record"), Surface(AetheriaRuntimeShipSettingsSurfaceBuilder.SurfaceId, "Current Ship Settings", "", "player", "Current ship settings surface.", status: "contextual"), Surface(AetheriaRuntimeCargoItemDetailsSurfaceBuilder.SurfaceId, "Cargo Item Details", "", "player", "Selected cargo item details surface.", status: "contextual"), diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index d7c62ee5a..7693eef42 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -292,8 +292,11 @@ The current UI direction is Eve/CultUI: - daemon publishes game/editor GUI and TUI surfaces; - compatibility state surfaces exist for operations, player settings, catalog, loadout templates, zone details, trade item details, and stat recipes; -- Unity lowers local surfaces through UI Toolkit for many menu/inventory/trade - panels; +- the daemon's pilot surface publishes docked spatial equipment and cargo grids + with generic Eve drop commands; Unity and browser lowerers own transient drag + state but not fit, access, mutation, or receipts; +- Unity still lowers local surfaces through UI Toolkit for several remaining + menu/trade panels; - Brokkr should be considered just another daemon publishing Unity editor Eve surfaces, not a special Codex socket. diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index dde9ba62e..13b4f9e46 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -2926,6 +2926,19 @@ Docked refit ownership follows the fossil's `Entity.ItemFits`, `TryEquip`, and receipts, and aligned typed bay collections. Reconnect remains a live proof obligation. +The docked pilot surface is the provider-owned control projection for that +transaction. While the current ship has an authoritative docking parent, the +daemon publishes `inventory.grid` and `inventory.item` nodes for the ship +equipment, each ship cargo bay, and each station cargo bay. Those nodes carry +authored grid dimensions, item footprints, source/target identities, and +`TransferCargoItem`, `EquipItem`, or `StoreItem` drop commands. Generic Eve +lowerers own only transient pointer/keyboard drag state and visual prediction. +The old request-local Unity inventory panel is no longer advertised as a +player surface, and cannot become a second refit authority. Cargo transfers +also require the command actor to own one endpoint and reach the other through +docking parentage or physical interaction range, preserving autonomous haul +tasks without permitting remote arbitrary inventory mutation. + Ymir restart ownership is deliberately private and asymmetric: - Owner: `AetheriaYmirPersistenceCoordinator` owns reconstruction durability;