diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index d5dc99b..5b3e278 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.IO; +using System.Linq; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Eve.Surface; @@ -276,7 +277,14 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt); var focusVersion = runtime.ActiveVersion; - var action = runtime.SubmitActionIntent(playerId, "0"); + Assert.That(provider.CurrentInputCapability, Is.Not.Null, "The pilot world did not advertise a typed input capability document."); + Assert.That(provider.CurrentInputCapability.Actions, Is.Not.Empty, "The pilot input capability advertised no available actions."); + var advertisedAction = provider.CurrentInputCapability.Actions + .FirstOrDefault(candidate => string.Equals(candidate.Category, "weapon-group", StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)) + ?? provider.CurrentInputCapability.Actions + .First(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal)); + var action = runtime.SubmitActionIntent(playerId, advertisedAction.ActionId); var actionDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < actionDeadline && (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId || diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index 1b06585..c74bff9 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -32,6 +32,7 @@ namespace GameCult.Eve.UnityScene typeof(EveCommandReceiptDocument), typeof(EveAssetCatalogDocument), typeof(EveEntitySoaViewDocument), + typeof(EveInputCapabilityDocument), typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk) }; @@ -82,6 +83,8 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; private set; } + public EveInputCapabilityDocument? CurrentInputCapability { get; private set; } + public event Action? SurfaceDocumentAvailable; public event Action? AssetManifestDocumentAvailable; @@ -118,6 +121,8 @@ namespace GameCult.Eve.UnityScene PublishSurface(surface); else if (document is EveCommandReceiptDocument receipt) PublishReceipt(receipt); + else if (document is EveInputCapabilityDocument inputCapability) + CurrentInputCapability = inputCapability; } } @@ -248,6 +253,14 @@ namespace GameCult.Eve.UnityScene _liveDocuments.Enqueue(entities.LatestAsync().GetAwaiter().GetResult()); _liveWatches.Add(entities.Watch(view => _liveDocuments.Enqueue(view))); } + var inputCapabilityPointer = lowered.PlayableWorld?.InputCapabilityPointerId ?? ""; + if (!string.IsNullOrWhiteSpace(inputCapabilityPointer)) + { + var inputs = _mesh.DocumentAsync(_endpointId, inputCapabilityPointer) + .GetAwaiter().GetResult(); + CurrentInputCapability = inputs.LatestAsync().GetAwaiter().GetResult(); + _liveWatches.Add(inputs.Watch(document => _liveDocuments.Enqueue(document))); + } var receipts = _mesh.CollectionAsync(_endpointId).GetAwaiter().GetResult(); _liveWatches.Add(receipts.WatchChanges(change => { diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs index 1373276..71c2425 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -17,6 +17,7 @@ namespace GameCult.Eve.UnityScene IEveUnitySceneCommandReceiptSource, IEveUnityProviderRefreshSource, IEveUnityEntitySoaViewDocumentSource, + IEveUnityInputCapabilityDocumentSource, IEveUnityCameraRenderPolicySource, IEveUnityNativeAssetProvider { @@ -35,6 +36,8 @@ namespace GameCult.Eve.UnityScene public EveEntitySoaViewDocument? CurrentEntityView { get; private set; } + public EveInputCapabilityDocument? CurrentInputCapability => _transport?.CurrentInputCapability; + public event Action? EntityViewAvailable; public EveUnityCultMeshProviderSelection? Selection { get; private set; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs index 87b3c84..31ba21c 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs @@ -13,6 +13,11 @@ namespace GameCult.Eve.UnityScene event Action EntityViewAvailable; } + public interface IEveUnityInputCapabilityDocumentSource + { + EveInputCapabilityDocument? CurrentInputCapability { get; } + } + /// Projects portable entity SoA generations into the ordinary Unity scene sink. public sealed class EveUnityEntitySoaPresenter { @@ -29,6 +34,10 @@ namespace GameCult.Eve.UnityScene public void Apply(EveEntitySoaViewDocument document) { using var view = EveUnityEntitySoaView.Open(document); + var identities = (document.Identities ?? Array.Empty()) + .Where(identity => identity != null && identity.EntityIndex >= 0 && !string.IsNullOrWhiteSpace(identity.EntityId)) + .GroupBy(identity => identity.EntityIndex) + .ToDictionary(group => group.Key, group => group.First()); var next = new HashSet(StringComparer.Ordinal); for (var row = 0; row < view.EntityCount; row++) { @@ -39,24 +48,29 @@ namespace GameCult.Eve.UnityScene view.TryReadFloat("render.scale", row, out var scale); view.TryReadUInt32("render.group.id", row, out var groupId); var renderGroup = view.RenderGroups.FirstOrDefault(group => group.GroupId == groupId); - var entityId = $"entity:{entityIndex}"; + if (!identities.TryGetValue(entityIndex, out var identity)) + throw new InvalidOperationException( + $"Eve entity SoA view '{document.ViewId}' does not map entity index {entityIndex} to a logical identity."); + var entityId = identity.EntityId; next.Add(entityId); - var assetRef = renderGroup?.MeshAssetRef ?? ""; + var assetRef = string.IsNullOrWhiteSpace(identity.AssetRef) + ? renderGroup?.MeshAssetRef ?? "" + : identity.AssetRef; _sink.UpsertEntity( new EveUnityPlayableWorldEntity( entityId, entityId, - "entity", - entityId, - "", + identity.Kind, + string.IsNullOrWhiteSpace(identity.Label) ? entityId : identity.Label, + identity.Faction, assetRef, position.x, position.y, position.z, rotationRadians * 57.2957795f, scale > 0f ? scale : renderGroup?.DefaultScale ?? 1f, - selectable: true, - controllable: false, + selectable: identity.Selectable, + controllable: identity.Controllable, focusCommand: "", moveCommand: "", targetCommand: "", @@ -64,7 +78,7 @@ namespace GameCult.Eve.UnityScene props: new Dictionary()), new EveUnityPlayableWorldAssetBinding( assetRef, - "entity", + identity.Kind, string.IsNullOrWhiteSpace(assetRef) ? "unity-generated-placeholder" : "provider-asset-ref")); } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index bf44ee2..e1ab229 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -105,10 +105,10 @@ namespace GameCult.Eve.UnityScene public EveSurfaceCommandRequest SubmitActionIntent( string entityId, - string actionId, + EveInputActionDocument action, DateTimeOffset? issuedAt = null) { - return _connection.SubmitActionIntent(entityId, actionId, issuedAt); + return _connection.SubmitActionIntent(entityId, action, issuedAt); } public void Disconnect() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs index 589dba1..82f8bb2 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using GameCult.Eve.Surface; using UnityEngine; @@ -13,6 +14,7 @@ namespace GameCult.Eve.UnityScene private readonly EveUnityPlayableWorldLiveClient _client; private readonly EveUnityPlayableWorldAssetManifestDocumentSource? _assetManifestSource; private readonly IEveUnityEntitySoaViewDocumentSource? _entityViews; + private readonly IEveUnityInputCapabilityDocumentSource? _inputCapabilities; private readonly EveUnityEntitySoaPresenter _entityPresenter; private bool _entityViewsConnected; private bool _assetManifestConnected; @@ -33,6 +35,7 @@ namespace GameCult.Eve.UnityScene AssetManifests = assetManifests ?? new EveUnityPlayableWorldAssetManifestCache(); _surfaceSource = new EveUnitySceneProviderSurfaceDocumentSource(surfaceDocuments); _entityViews = surfaceDocuments as IEveUnityEntitySoaViewDocumentSource; + _inputCapabilities = surfaceDocuments as IEveUnityInputCapabilityDocumentSource; _entityPresenter = new EveUnityEntitySoaPresenter(sceneSink); _connection = new EveUnitySceneProviderConnection(_surfaceSource, commandSink); _client = new EveUnityPlayableWorldLiveClient( @@ -162,7 +165,14 @@ namespace GameCult.Eve.UnityScene string actionId, DateTimeOffset? issuedAt = null) { - return _client.SubmitActionIntent(entityId, actionId, issuedAt); + var capability = _inputCapabilities?.CurrentInputCapability + ?? throw new InvalidOperationException("The active Eve surface does not publish an input capability document."); + var action = (capability.Actions ?? Array.Empty()) + .FirstOrDefault(candidate => string.Equals(candidate.ActionId, actionId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"The active Eve input capability does not advertise action '{actionId}'."); + if (!string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Eve action '{actionId}' is not currently available."); + return _client.SubmitActionIntent(entityId, action, issuedAt); } public void Disconnect() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs index e82cf56..16efb3d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs @@ -115,17 +115,18 @@ namespace GameCult.Eve.UnityScene public EveSurfaceCommandRequest CreateActionIntent( string entityId, - string actionId, + EveInputActionDocument action, DateTimeOffset? issuedAt = null) { - var playableWorld = RequirePlayableWorld(); + if (action == null) throw new ArgumentNullException(nameof(action)); + var payload = new Dictionary(action.Payload ?? new Dictionary(), StringComparer.Ordinal) + { + ["entityId"] = entityId ?? "", + ["actionId"] = action.ActionId ?? "" + }; return CreatePlayableWorldIntent( - playableWorld.ActionCommand, - new Dictionary(StringComparer.Ordinal) - { - ["entityId"] = entityId ?? "", - ["actionId"] = actionId ?? "" - }, + action.Operation, + payload, issuedAt); } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs index e21292c..de6037c 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs @@ -238,10 +238,10 @@ namespace GameCult.Eve.UnityScene public EveSurfaceCommandRequest SubmitActionIntent( string entityId, - string actionId, + EveInputActionDocument action, DateTimeOffset? issuedAt = null) { - return Submit(_session.CreateActionIntent(entityId, actionId, issuedAt)); + return Submit(_session.CreateActionIntent(entityId, action, issuedAt)); } public void Disconnect() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs index ae13226..d5b5905 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -124,7 +124,8 @@ namespace GameCult.Eve.UnityScene worldRoot.GetProp("entityViewPointerId"), worldRoot.GetProp("entityViewSchema"), worldRoot.GetProp("zoneRenderPointerId"), - worldRoot.GetProp("zoneRenderSchema")); + worldRoot.GetProp("zoneRenderSchema"), + worldRoot.GetProp("inputCapability")); } private static EveUnityPlayableWorldEntity BuildPlayableEntity(EveSurfaceComponent component) @@ -324,7 +325,8 @@ namespace GameCult.Eve.UnityScene string entityViewPointerId = "", string entityViewSchema = "", string zoneRenderPointerId = "", - string zoneRenderSchema = "") + string zoneRenderSchema = "", + string inputCapabilityPointerId = "") { WorldRootId = worldRootId ?? ""; StatePointerId = statePointerId ?? ""; @@ -342,6 +344,7 @@ namespace GameCult.Eve.UnityScene EntityViewSchema = entityViewSchema ?? ""; ZoneRenderPointerId = zoneRenderPointerId ?? ""; ZoneRenderSchema = zoneRenderSchema ?? ""; + InputCapabilityPointerId = inputCapabilityPointerId ?? ""; } public string WorldRootId { get; } @@ -356,6 +359,8 @@ namespace GameCult.Eve.UnityScene public string ZoneRenderSchema { get; } + public string InputCapabilityPointerId { get; } + public string AssetManifest { get; } public string InputProfile { get; } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index 340d752..c0cae75 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -338,11 +338,25 @@ namespace GameCult.Eve.UnityScene.Tests [Test] public void PlayableWorldRuntimeComposesProviderDocumentsAssetsReceiptsAndSceneSink() { - var surfaceDocuments = new FakeProviderSurfaceDocumentSource(new EveUnitySceneProviderSurfaceDocument( - PlayableArpgDocument(), - Advertisement("aetheria.daemon.game"), - "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", - 1)); + var surfaceDocuments = new FakeProviderSurfaceDocumentSource( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + currentInputCapability: new EveInputCapabilityDocument + { + ProviderId = "aetheria", + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "weapon-group.2.fire", + Operation = "aetheria.daemon.commands.FireWeaponGroup", + Payload = new Dictionary { ["weaponGroup"] = "2" } + } + } + }); var assetDocuments = new FakeAssetManifestDocumentSource(new EveUnityPlayableWorldAssetManifestDocument( "cultmesh://aetheria/assets/manifest", new[] @@ -392,6 +406,14 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); Assert.That(moveIntent.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1")); + var actionIntent = runtime.SubmitActionIntent("player-vanguard", "weapon-group.2.fire"); + + Assert.That(commandSink.Submitted.Count, Is.EqualTo(2)); + Assert.That(commandSink.Submitted[1], Is.SameAs(actionIntent)); + Assert.That(actionIntent.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.commands.FireWeaponGroup")); + Assert.That(actionIntent.Payload.GetString("weaponGroup"), Is.EqualTo("2")); + Assert.That(actionIntent.Payload.GetString("actionId"), Is.EqualTo("weapon-group.2.fire")); + surfaceDocuments.Publish(new EveUnitySceneProviderSurfaceDocument( PlayableArpgDocument(includeRaider: false, playerPosition: "14,0,9"), Advertisement("aetheria.daemon.game"), @@ -442,6 +464,10 @@ namespace GameCult.Eve.UnityScene.Tests { new EveEntitySoaColumn { ColumnId = "position", Semantic = "transform.position", BufferId = "hot", ScalarType = "float3", ElementStride = 12, ElementCount = 1 }, new EveEntitySoaColumn { ColumnId = "entity-index", Semantic = "entity.index", BufferId = "hot", ScalarType = "int32", ByteOffset = 12, ElementStride = 4, ElementCount = 1 } + }, + Identities = new[] + { + new EveEntitySoaIdentity { EntityIndex = 41, EntityId = "world:entity:41", Kind = "ship", Label = "Test ship", Controllable = true, AssetRef = "prefab.entity.player" } } }; var surfaceDocuments = new FakeProviderSurfaceDocumentSource( @@ -462,7 +488,7 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(presentation.ActiveEntities, Is.EqualTo(1)); Assert.That(runtime.LastPresentation!.ActiveEntities, Is.EqualTo(1)); - Assert.That(sceneSink.Upserts.Exists(entry => entry.entity.EntityId == "entity:41"), Is.True); + Assert.That(sceneSink.Upserts.Exists(entry => entry.entity.EntityId == "world:entity:41"), Is.True); } [Test] @@ -1592,14 +1618,17 @@ namespace GameCult.Eve.UnityScene.Tests private sealed class FakeProviderSurfaceDocumentSource : IEveUnitySceneProviderSurfaceDocumentSource, - IEveUnityEntitySoaViewDocumentSource + IEveUnityEntitySoaViewDocumentSource, + IEveUnityInputCapabilityDocumentSource { public FakeProviderSurfaceDocumentSource( EveUnitySceneProviderSurfaceDocument currentDocument, - EveEntitySoaViewDocument? currentEntityView = null) + EveEntitySoaViewDocument? currentEntityView = null, + EveInputCapabilityDocument? currentInputCapability = null) { CurrentDocument = currentDocument; CurrentEntityView = currentEntityView; + CurrentInputCapability = currentInputCapability; } public EveUnitySceneProviderSurfaceDocument CurrentDocument { get; private set; } @@ -1610,6 +1639,8 @@ namespace GameCult.Eve.UnityScene.Tests public event Action? EntityViewAvailable; + public EveInputCapabilityDocument? CurrentInputCapability { get; } + public void Publish(EveUnitySceneProviderSurfaceDocument document) { CurrentDocument = document; diff --git a/scripts/run-aetheria-daemon-world-witness.ps1 b/scripts/run-aetheria-daemon-world-witness.ps1 index d1b5185..b56f14f 100644 --- a/scripts/run-aetheria-daemon-world-witness.ps1 +++ b/scripts/run-aetheria-daemon-world-witness.ps1 @@ -1,6 +1,8 @@ param( [string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe", [string] $AetheriaRoot = "E:\Projects\Aetheria", + [string] $EveUnityRoot = "", + [string] $CultLibRoot = "E:\Projects\CultLib", [string] $ClientProject = "ReleaseConsumerProject", [int] $Port = 3076, [string] $OutputDirectory = "artifacts\aetheria-daemon", @@ -11,6 +13,7 @@ param( $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot +$resolvedEveUnityRoot = if ([string]::IsNullOrWhiteSpace($EveUnityRoot)) { $repoRoot } else { $EveUnityRoot } $projectRoot = $ClientProject $outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory } $resultsPath = Join-Path $outputRoot "results.xml" @@ -57,7 +60,7 @@ foreach ($ephemeralPath in @( } $import = Start-Process -FilePath "dotnet" -ArgumentList @( - "run", "--project", $importProject, "--", $AetheriaRoot, $statePath + "run", "--project", $importProject, "-p:EveUnityRoot=$resolvedEveUnityRoot", "-p:CultLibRoot=$CultLibRoot", "--", $AetheriaRoot, $statePath ) -PassThru -WindowStyle Hidden -Wait ` -RedirectStandardOutput (Join-Path $outputRoot "aetheria-import.log") ` -RedirectStandardError (Join-Path $outputRoot "aetheria-import.error.log") @@ -65,7 +68,7 @@ if ($import.ExitCode -ne 0) { throw "Aetheria state import failed with exit code $($import.ExitCode). See $outputRoot\aetheria-import.error.log" } $daemonBuild = Start-Process -FilePath "dotnet" -ArgumentList @( - "build", $daemonProject + "build", $daemonProject, "-p:EveUnityRoot=$resolvedEveUnityRoot", "-p:CultLibRoot=$CultLibRoot" ) -PassThru -WindowStyle Hidden -Wait ` -RedirectStandardOutput $daemonBuildLogPath ` -RedirectStandardError (Join-Path $outputRoot "aetheria-daemon-build.error.log")