Lower daemon identities and semantic actions in Unity

This commit is contained in:
Meta Vens 2026-07-12 23:28:01 +01:00
commit 1a27b5e041
11 changed files with 122 additions and 34 deletions

View file

@ -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 ||

View file

@ -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<EveUnitySceneProviderSurfaceDocument>? SurfaceDocumentAvailable;
public event Action<EveUnityPlayableWorldAssetManifestDocument>? 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<EveInputCapabilityDocument>(_endpointId, inputCapabilityPointer)
.GetAwaiter().GetResult();
CurrentInputCapability = inputs.LatestAsync().GetAwaiter().GetResult();
_liveWatches.Add(inputs.Watch(document => _liveDocuments.Enqueue(document)));
}
var receipts = _mesh.CollectionAsync<EveCommandReceiptDocument>(_endpointId).GetAwaiter().GetResult();
_liveWatches.Add(receipts.WatchChanges(change =>
{

View file

@ -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<EveEntitySoaViewDocument>? EntityViewAvailable;
public EveUnityCultMeshProviderSelection? Selection { get; private set; }

View file

@ -13,6 +13,11 @@ namespace GameCult.Eve.UnityScene
event Action<EveEntitySoaViewDocument> EntityViewAvailable;
}
public interface IEveUnityInputCapabilityDocumentSource
{
EveInputCapabilityDocument? CurrentInputCapability { get; }
}
/// <summary>Projects portable entity SoA generations into the ordinary Unity scene sink.</summary>
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<EveEntitySoaIdentity>())
.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<string>(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<string, string>()),
new EveUnityPlayableWorldAssetBinding(
assetRef,
"entity",
identity.Kind,
string.IsNullOrWhiteSpace(assetRef) ? "unity-generated-placeholder" : "provider-asset-ref"));
}

View file

@ -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()

View file

@ -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<EveInputActionDocument>())
.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()

View file

@ -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<string, string>(action.Payload ?? new Dictionary<string, string>(), StringComparer.Ordinal)
{
["entityId"] = entityId ?? "",
["actionId"] = action.ActionId ?? ""
};
return CreatePlayableWorldIntent(
playableWorld.ActionCommand,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["entityId"] = entityId ?? "",
["actionId"] = actionId ?? ""
},
action.Operation,
payload,
issuedAt);
}

View file

@ -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()

View file

@ -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; }

View file

@ -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<string, string> { ["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<EveEntitySoaViewDocument>? EntityViewAvailable;
public EveInputCapabilityDocument? CurrentInputCapability { get; }
public void Publish(EveUnitySceneProviderSurfaceDocument document)
{
CurrentDocument = document;

View file

@ -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")