commit 9341d44d8c65ea6ee186f4ee8cf9327e23c6705f Author: Meta Vens Date: Fri Jul 10 02:27:47 2026 +0100 Establish EveUnity runtime owner repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af0c679 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +artifacts/ +Library/ +Logs/ +Temp/ +obj/ +*.csproj +*.sln + diff --git a/README.md b/README.md new file mode 100644 index 0000000..85cb01b --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# EveUnity + +EveUnity is the Unity lowering client for Eve interactive surfaces. + +It owns Unity projection, scene lifecycle, input capture, camera behavior, +asset resolution hooks, UPM packaging, runtime tests, and frame evidence. It +does not own provider state, simulation, commands, receipts, assets, or plugin +semantics. + +The first package is `org.gamecult.eve.unity-scene`. A generic Unity host can +connect any provider component that implements the EveUnity provider ports, +including Aetheria's provider-owned adapter for its daemon-published 3D ARPG +surface. + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\run-package-tests.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\pack.ps1 +``` + +See [docs/authority-map.md](docs/authority-map.md) for the live boundary. + diff --git a/docs/authority-map.md b/docs/authority-map.md new file mode 100644 index 0000000..fa9eb08 --- /dev/null +++ b/docs/authority-map.md @@ -0,0 +1,52 @@ +# EveUnity Authority Map + +## Objective + +Lower provider-authored Eve surfaces into a playable Unity scene without +importing provider product code into the generic runtime. + +## Owner + +EveUnity owns Unity scene projection, `GameObject` lifecycle, camera and input +drivers, asset-provider hooks, package release, runtime tests, and captures. + +## Inputs + +- `gamecult.eve.provider_advertisement.v1` +- `gamecult.eve.surface.v1` +- provider asset-manifest documents +- provider command receipts +- Unity input and local presentation assets + +## Outputs + +- disposable Unity scene projection +- `gamecult.eve.command.v1` intents sent through the advertised boundary +- runtime capability, test, release, and capture evidence + +## Derived State + +`ActiveWorld`, scene objects, transforms, markers, camera pose, asset caches, +presentation counters, and receipt display state are projections. They are not +world truth. + +## Forbidden Writers + +Unity transforms, input drivers, camera rigs, scene sinks, asset caches, and +pending receipts may not mutate or simulate provider world state. Plugin +projection shells may not implement Sai, Norn, or TeX semantics. + +## Shared Paths + +Initial connect, refresh, reconnect, and terminal-receipt reconciliation all +consume provider surface documents. Movement, focus, target, and action input +all become `gamecult.eve.command.v1` through the provider-advertised command +boundary. + +## Cut Line + +Aetheria retains its daemon, authored surfaces, assets, command acceptance, +receipts, adapter, and provider conformance scenarios. Eve retains contracts +and conformance policy. EveUnity retains only generic Unity projection and its +runtime lifecycle. + diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md new file mode 100644 index 0000000..ba81d98 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -0,0 +1,11 @@ +# Eve Unity Scene Lowering + +Provider-agnostic Unity scene lowering for `gamecult.eve.surface.v1` +interactive worlds. + +Providers implement the surface-document, asset-manifest, command-sink, and +receipt-source ports. `EveUnityPlayableWorldClientBootstrap` wires those ports +to the generic scene host, input driver, camera rig, and `GameObject` sink. + +The package never imports provider product assemblies. + diff --git a/packages/org.gamecult.eve.unity-scene/README.md.meta b/packages/org.gamecult.eve.unity-scene/README.md.meta new file mode 100644 index 0000000..20c6f71 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 74b6947fafbfd0d42a642b7527a32258 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime.meta b/packages/org.gamecult.eve.unity-scene/Runtime.meta new file mode 100644 index 0000000..f5f6f4b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1ed3bb976c01b68498f8da912b8ddf2d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs new file mode 100644 index 0000000..9256609 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public interface IEveUnityGameObjectAssetProvider + { + GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset); + } + + public sealed class EveUnityGameObjectPlayableWorldSceneSink : IEveUnityPlayableWorldSceneSink + { + private readonly Transform _root; + private readonly IEveUnityGameObjectAssetProvider _assetProvider; + private readonly Dictionary _instances = new Dictionary(StringComparer.Ordinal); + + public EveUnityGameObjectPlayableWorldSceneSink( + Transform root, + IEveUnityGameObjectAssetProvider assetProvider) + { + _root = root != null ? root : throw new ArgumentNullException(nameof(root)); + _assetProvider = assetProvider ?? throw new ArgumentNullException(nameof(assetProvider)); + } + + public int ActiveEntityCount => _instances.Count; + + public void ConfigureWorld(EveUnityPlayableWorldProjection world) + { + if (world == null) throw new ArgumentNullException(nameof(world)); + if (!string.IsNullOrWhiteSpace(world.WorldRootId)) + _root.name = world.WorldRootId; + } + + public void UpsertEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + if (asset == null) throw new ArgumentNullException(nameof(asset)); + if (string.IsNullOrWhiteSpace(entity.EntityId)) + return; + + GameObject instance; + if (!_instances.TryGetValue(entity.EntityId, out instance) || instance == null) + { + instance = InstantiateEntity(entity, asset); + _instances[entity.EntityId] = instance; + } + else + { + var marker = instance.GetComponent(); + if (marker != null && !string.Equals(marker.AssetRef, asset.AssetRef, StringComparison.Ordinal)) + { + DestroyInstance(instance); + instance = InstantiateEntity(entity, asset); + _instances[entity.EntityId] = instance; + } + } + + ApplyEntity(instance, entity, asset); + } + + public void RemoveEntity(string entityId) + { + if (string.IsNullOrWhiteSpace(entityId)) + return; + + GameObject instance; + if (!_instances.TryGetValue(entityId, out instance)) + return; + + _instances.Remove(entityId); + if (instance != null) + DestroyInstance(instance); + } + + private GameObject InstantiateEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset) + { + var prefab = _assetProvider.ResolvePrefab(asset); + var instance = prefab != null + ? UnityEngine.Object.Instantiate(prefab, _root) + : GameObject.CreatePrimitive(PrimitiveType.Capsule); + + if (instance.transform.parent != _root) + instance.transform.SetParent(_root, false); + + return instance; + } + + private static void ApplyEntity( + GameObject instance, + EveUnityPlayableWorldEntity entity, + EveUnityPlayableWorldAssetBinding asset) + { + instance.name = string.IsNullOrWhiteSpace(entity.Label) + ? entity.EntityId + : $"{entity.EntityId} ({entity.Label})"; + instance.transform.localPosition = new Vector3(entity.PositionX, entity.PositionY, entity.PositionZ); + instance.transform.localRotation = Quaternion.Euler(0f, entity.RotationY, 0f); + if (entity.Radius > 0f) + instance.transform.localScale = Vector3.one * entity.Radius; + + var marker = instance.GetComponent(); + if (marker == null) + marker = instance.AddComponent(); + + marker.Apply(entity, asset); + } + + private static void DestroyInstance(GameObject instance) + { + if (Application.isPlaying) + UnityEngine.Object.Destroy(instance); + else + UnityEngine.Object.DestroyImmediate(instance); + } + } + + public sealed class EveUnityResourcesAssetProvider : IEveUnityGameObjectAssetProvider + { + public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) + { + if (asset == null) throw new ArgumentNullException(nameof(asset)); + var resourcesPath = EveUnityPlayableWorldAssetManifestEntry.NormalizeResourcesPath(asset.AssetRef); + return string.IsNullOrWhiteSpace(resourcesPath) + ? null + : Resources.Load(resourcesPath); + } + } + + public sealed class EveUnityPlayableWorldEntityMarker : MonoBehaviour + { + public string EntityId = ""; + public string NodeId = ""; + public string EntityKind = ""; + public string Label = ""; + public string Faction = ""; + public string AssetRef = ""; + public string PresentationKind = ""; + public bool Selectable; + public bool Controllable; + + public void Apply(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + if (asset == null) throw new ArgumentNullException(nameof(asset)); + + EntityId = entity.EntityId; + NodeId = entity.NodeId; + EntityKind = entity.EntityKind; + Label = entity.Label; + Faction = entity.Faction; + AssetRef = asset.AssetRef; + PresentationKind = asset.PresentationKind; + Selectable = entity.Selectable; + Controllable = entity.Controllable; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs.meta new file mode 100644 index 0000000..493007b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e924fe3501e6e584392cb405f916c9c9 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldAssetManifest.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldAssetManifest.cs new file mode 100644 index 0000000..d6dd81a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldAssetManifest.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityPlayableWorldAssetManifest + { + private readonly Dictionary _byAssetRef = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _byEntityKind = + new Dictionary(StringComparer.Ordinal); + + public EveUnityPlayableWorldAssetManifest( + string manifestRef, + IReadOnlyList entries) + { + ManifestRef = manifestRef ?? ""; + Entries = entries ?? Array.Empty(); + + foreach (var entry in Entries) + { + if (entry == null) + continue; + if (!string.IsNullOrWhiteSpace(entry.AssetRef)) + _byAssetRef[entry.AssetRef] = entry; + if (!string.IsNullOrWhiteSpace(entry.EntityKind)) + _byEntityKind[entry.EntityKind] = entry; + } + } + + public string ManifestRef { get; } + + public IReadOnlyList Entries { get; } + + public static EveUnityPlayableWorldAssetManifest FromDocument(EveUnityPlayableWorldAssetManifestDocument document) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + + var entries = new List(); + foreach (var entry in document.Entries) + { + if (entry == null) + continue; + + entries.Add(new EveUnityPlayableWorldAssetManifestEntry( + entry.AssetRef, + entry.EntityKind, + entry.ResourcesPath, + entry.PrefabKey, + entry.PresentationKind)); + } + + return new EveUnityPlayableWorldAssetManifest(document.ManifestRef, entries); + } + + public EveUnityPlayableWorldAssetManifestEntry? Find(EveUnityPlayableWorldAssetBinding asset) + { + if (asset == null) throw new ArgumentNullException(nameof(asset)); + + EveUnityPlayableWorldAssetManifestEntry entry; + if (!string.IsNullOrWhiteSpace(asset.AssetRef) && _byAssetRef.TryGetValue(asset.AssetRef, out entry)) + return entry; + if (!string.IsNullOrWhiteSpace(asset.EntityKind) && _byEntityKind.TryGetValue(asset.EntityKind, out entry)) + return entry; + return null; + } + } + + public interface IEveUnityPlayableWorldAssetManifestSource + { + string ManifestRef { get; } + + EveUnityPlayableWorldAssetManifest CurrentManifest { get; } + + event Action ManifestAvailable; + } + + public sealed class EveUnityPlayableWorldAssetManifestCache + { + private readonly Dictionary _manifests = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary> _subscriptions = + new Dictionary>(); + + public int Count => _manifests.Count; + + public void Add(EveUnityPlayableWorldAssetManifest manifest) + { + if (manifest == null) throw new ArgumentNullException(nameof(manifest)); + if (string.IsNullOrWhiteSpace(manifest.ManifestRef)) + throw new ArgumentException("Asset manifest ref is required.", nameof(manifest)); + + _manifests[manifest.ManifestRef] = manifest; + } + + public void Connect(IEveUnityPlayableWorldAssetManifestSource source) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (_subscriptions.ContainsKey(source)) + return; + + Add(source.CurrentManifest); + Action handler = Add; + source.ManifestAvailable += handler; + _subscriptions[source] = handler; + } + + public void Disconnect(IEveUnityPlayableWorldAssetManifestSource source) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + Action handler; + if (!_subscriptions.TryGetValue(source, out handler)) + return; + + source.ManifestAvailable -= handler; + _subscriptions.Remove(source); + } + + public bool TryGet(string manifestRef, out EveUnityPlayableWorldAssetManifest? manifest) + { + if (string.IsNullOrWhiteSpace(manifestRef)) + { + manifest = null; + return false; + } + + return _manifests.TryGetValue(manifestRef, out manifest); + } + + public EveUnityPlayableWorldAssetManifest? GetForWorld(EveUnityPlayableWorldProjection world) + { + if (world == null) throw new ArgumentNullException(nameof(world)); + EveUnityPlayableWorldAssetManifest? manifest; + return TryGet(world.AssetManifest, out manifest) ? manifest : null; + } + + public IEveUnityGameObjectAssetProvider CreateGameObjectAssetProvider( + EveUnityPlayableWorldProjection world, + IEveUnityGameObjectAssetProvider fallback) + { + if (world == null) throw new ArgumentNullException(nameof(world)); + if (fallback == null) throw new ArgumentNullException(nameof(fallback)); + + var manifest = GetForWorld(world); + return manifest == null + ? fallback + : new EveUnityManifestGameObjectAssetProvider(manifest, fallback); + } + } + + public interface IEveUnityPlayableWorldAssetManifestDocumentSource + { + string ManifestRef { get; } + + EveUnityPlayableWorldAssetManifestDocument CurrentDocument { get; } + + event Action DocumentAvailable; + } + + public sealed class EveUnityPlayableWorldAssetManifestDocumentSource : IEveUnityPlayableWorldAssetManifestSource, IDisposable + { + private readonly IEveUnityPlayableWorldAssetManifestDocumentSource _documentSource; + private bool _connected; + + public EveUnityPlayableWorldAssetManifestDocumentSource( + IEveUnityPlayableWorldAssetManifestDocumentSource documentSource) + { + _documentSource = documentSource ?? throw new ArgumentNullException(nameof(documentSource)); + CurrentManifest = EveUnityPlayableWorldAssetManifest.FromDocument(_documentSource.CurrentDocument); + } + + public string ManifestRef => CurrentManifest.ManifestRef; + + public EveUnityPlayableWorldAssetManifest CurrentManifest { get; private set; } + + public event Action? ManifestAvailable; + + public void Connect() + { + if (_connected) + return; + + _documentSource.DocumentAvailable += OnDocumentAvailable; + _connected = true; + } + + public void Disconnect() + { + if (!_connected) + return; + + _documentSource.DocumentAvailable -= OnDocumentAvailable; + _connected = false; + } + + public void Dispose() + { + Disconnect(); + } + + private void OnDocumentAvailable(EveUnityPlayableWorldAssetManifestDocument document) + { + CurrentManifest = EveUnityPlayableWorldAssetManifest.FromDocument(document); + ManifestAvailable?.Invoke(CurrentManifest); + } + } + + public sealed class EveUnityPlayableWorldAssetManifestDocument + { + public const string SchemaId = "gamecult.eve.unity_playable_world_asset_manifest.v1"; + + public EveUnityPlayableWorldAssetManifestDocument( + string manifestRef, + IReadOnlyList entries, + string providerId = "", + string schema = SchemaId) + { + ManifestRef = manifestRef ?? ""; + Entries = entries ?? Array.Empty(); + ProviderId = providerId ?? ""; + Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; + } + + public string Schema { get; } + + public string ManifestRef { get; } + + public string ProviderId { get; } + + public IReadOnlyList Entries { get; } + } + + public sealed class EveUnityPlayableWorldAssetManifestDocumentEntry + { + public EveUnityPlayableWorldAssetManifestDocumentEntry( + string assetRef, + string entityKind, + string resourcesPath, + string prefabKey, + string presentationKind = "provider-asset-ref") + { + AssetRef = assetRef ?? ""; + EntityKind = entityKind ?? ""; + ResourcesPath = resourcesPath ?? ""; + PrefabKey = prefabKey ?? ""; + PresentationKind = presentationKind ?? ""; + } + + public string AssetRef { get; } + + public string EntityKind { get; } + + public string ResourcesPath { get; } + + public string PrefabKey { get; } + + public string PresentationKind { get; } + } + + public sealed class EveUnityPlayableWorldAssetManifestEntry + { + public EveUnityPlayableWorldAssetManifestEntry( + string assetRef, + string entityKind, + string resourcesPath, + string prefabKey, + string presentationKind = "provider-asset-ref") + { + AssetRef = assetRef ?? ""; + EntityKind = entityKind ?? ""; + ResourcesPath = NormalizeResourcesPath(resourcesPath); + PrefabKey = prefabKey ?? ""; + PresentationKind = presentationKind ?? ""; + } + + public string AssetRef { get; } + + public string EntityKind { get; } + + public string ResourcesPath { get; } + + public string PrefabKey { get; } + + public string PresentationKind { get; } + + public static string NormalizeResourcesPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return ""; + + var normalized = path.Trim(); + foreach (var prefix in new[] { "resources://", "resource://", "Resources/" }) + { + if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized.Substring(prefix.Length); + break; + } + } + + if (normalized.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + normalized = normalized.Substring(0, normalized.Length - ".prefab".Length); + + return normalized; + } + } + + public sealed class EveUnityManifestGameObjectAssetProvider : IEveUnityGameObjectAssetProvider + { + private readonly EveUnityPlayableWorldAssetManifest _manifest; + private readonly IEveUnityGameObjectAssetProvider? _fallback; + + public EveUnityManifestGameObjectAssetProvider( + EveUnityPlayableWorldAssetManifest manifest, + IEveUnityGameObjectAssetProvider? fallback = null) + { + _manifest = manifest ?? throw new ArgumentNullException(nameof(manifest)); + _fallback = fallback; + } + + public string ManifestRef => _manifest.ManifestRef; + + public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) + { + if (asset == null) throw new ArgumentNullException(nameof(asset)); + + var entry = _manifest.Find(asset); + if (entry != null && !string.IsNullOrWhiteSpace(entry.ResourcesPath)) + { + var prefab = Resources.Load(entry.ResourcesPath); + if (prefab != null) + return prefab; + } + + return _fallback?.ResolvePrefab(asset); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldAssetManifest.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldAssetManifest.cs.meta new file mode 100644 index 0000000..43c845c --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldAssetManifest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0a1f1e9cabc1b044dbf58bc878a6d09a \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs new file mode 100644 index 0000000..9dc3ce7 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs @@ -0,0 +1,92 @@ +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityPlayableWorldCameraRig : MonoBehaviour + { + [SerializeField] private EveUnityPlayableWorldClientHost? host; + [SerializeField] private Transform? cameraTransform; + [SerializeField] private bool driveInLateUpdate = true; + [SerializeField] private float distance = 10f; + [SerializeField] private float height = 6f; + [SerializeField] private float yawDegrees = 35f; + [SerializeField] private float followDamping = 12f; + + public EveUnityPlayableWorldClientHost? Host + { + get => host; + set => host = value; + } + + public Transform? CameraTransform + { + get => cameraTransform; + set => cameraTransform = value; + } + + public bool ApplyRig(float deltaTime) + { + var resolvedHost = ResolveHost(); + var activeWorld = resolvedHost?.ActiveWorld; + if (resolvedHost == null || activeWorld == null) + return false; + + if (!string.IsNullOrWhiteSpace(activeWorld.CameraRig) && + activeWorld.CameraRig != "arpg.orbital-follow.v1" && + activeWorld.CameraRig != "third-person-orbit") + { + return false; + } + + var camera = cameraTransform != null ? cameraTransform : transform; + var player = FindPlayerMarker(resolvedHost, activeWorld.PlayerEntityId); + if (player == null) + return false; + + var target = player.transform.position; + var orbit = Quaternion.Euler(0f, yawDegrees, 0f) * (Vector3.back * Mathf.Max(0.1f, distance)); + var desiredPosition = target + orbit + (Vector3.up * Mathf.Max(0f, height)); + var t = deltaTime <= 0f + ? 1f + : 1f - Mathf.Exp(-Mathf.Max(0.01f, followDamping) * deltaTime); + + camera.position = Vector3.Lerp(camera.position, desiredPosition, t); + camera.LookAt(target); + return true; + } + + private void LateUpdate() + { + if (driveInLateUpdate) + ApplyRig(Time.deltaTime); + } + + private EveUnityPlayableWorldClientHost? ResolveHost() + { + if (host != null) + return host; + + host = GetComponent(); + return host; + } + + private static EveUnityPlayableWorldEntityMarker? FindPlayerMarker( + EveUnityPlayableWorldClientHost host, + string playerEntityId) + { + if (string.IsNullOrWhiteSpace(playerEntityId)) + return null; + + var markers = host.SceneRoot.GetComponentsInChildren(); + foreach (var marker in markers) + { + if (marker != null && marker.EntityId == playerEntityId) + return marker; + } + + return null; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs.meta new file mode 100644 index 0000000..de23c49 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3e68cf66c50b4951a9d7f7de68f0ad0e diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs new file mode 100644 index 0000000..a78caf9 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -0,0 +1,193 @@ +using System; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityPlayableWorldClientBootstrap : MonoBehaviour + { + [SerializeField] private EveUnityPlayableWorldClientHost? host; + [SerializeField] private MonoBehaviour? provider; + [SerializeField] private MonoBehaviour? providerSurfaceDocuments; + [SerializeField] private MonoBehaviour? commandSink; + [SerializeField] private MonoBehaviour? assetManifestDocuments; + [SerializeField] private MonoBehaviour? receiptSource; + [SerializeField] private MonoBehaviour? fallbackAssetProvider; + [SerializeField] private Transform? sceneRoot; + [SerializeField] private Transform? cameraTransform; + [SerializeField] private bool createSceneRootIfMissing = true; + [SerializeField] private bool attachInputDriver = true; + [SerializeField] private bool attachCameraRig = true; + [SerializeField] private bool connectOnEnable = true; + + public EveUnityPlayableWorldClientHost? Host => host; + + public Transform? SceneRoot => sceneRoot; + + public Transform? CameraTransform => cameraTransform; + + public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; } + + public void ConfigureProvider(MonoBehaviour providerComponent) + { + provider = providerComponent != null ? providerComponent : throw new ArgumentNullException(nameof(providerComponent)); + providerSurfaceDocuments = provider; + commandSink = provider; + assetManifestDocuments = provider; + receiptSource = provider; + } + + public EveUnityPlayableWorldPresentation Mount() + { + var resolvedHost = ResolveHost(); + var resolvedProvider = provider; + var surfaceDocuments = ResolveBehaviour( + providerSurfaceDocuments, + resolvedProvider, + nameof(providerSurfaceDocuments)); + var resolvedCommandSink = ResolveBehaviour( + commandSink, + resolvedProvider, + nameof(commandSink)); + var resolvedAssetDocuments = ResolveOptionalBehaviour( + assetManifestDocuments, + resolvedProvider); + var resolvedReceiptSource = ResolveOptionalBehaviour( + receiptSource, + resolvedProvider); + var resolvedFallbackAssetProvider = ResolveOptionalBehaviour( + fallbackAssetProvider, + null); + + sceneRoot = ResolveSceneRoot(); + resolvedHost.ConnectOnEnable = false; + resolvedHost.Configure( + sceneRoot, + surfaceDocuments, + resolvedCommandSink, + resolvedAssetDocuments, + resolvedReceiptSource, + resolvedFallbackAssetProvider); + + if (attachInputDriver) + ConfigureInputDriver(resolvedHost); + + if (attachCameraRig) + ConfigureCameraRig(resolvedHost); + + LastPresentation = resolvedHost.Connect(); + return LastPresentation; + } + + private void Awake() + { + if (host != null && provider != null) + host.ConnectOnEnable = false; + } + + private void OnEnable() + { + if (connectOnEnable) + Mount(); + } + + private EveUnityPlayableWorldClientHost ResolveHost() + { + if (host != null) + return host; + + host = GetComponent(); + if (host == null) + host = gameObject.AddComponent(); + + return host; + } + + private Transform ResolveSceneRoot() + { + if (sceneRoot != null) + return sceneRoot; + + if (!createSceneRootIfMissing) + return transform; + + var root = new GameObject("Eve Unity Playable World Root"); + root.transform.SetParent(transform, false); + sceneRoot = root.transform; + return sceneRoot; + } + + private Transform ResolveCameraTransform() + { + if (cameraTransform != null) + return cameraTransform; + + var mainCamera = Camera.main; + if (mainCamera != null) + { + cameraTransform = mainCamera.transform; + return cameraTransform; + } + + var cameraObject = new GameObject("Eve Unity Playable World Camera"); + cameraObject.transform.SetParent(transform, false); + cameraObject.AddComponent(); + cameraTransform = cameraObject.transform; + return cameraTransform; + } + + private void ConfigureInputDriver(EveUnityPlayableWorldClientHost resolvedHost) + { + var driver = GetComponent(); + if (driver == null) + driver = gameObject.AddComponent(); + + driver.Host = resolvedHost; + driver.CameraTransform = ResolveCameraTransform(); + } + + private void ConfigureCameraRig(EveUnityPlayableWorldClientHost resolvedHost) + { + var rig = GetComponent(); + if (rig == null) + rig = gameObject.AddComponent(); + + rig.Host = resolvedHost; + rig.CameraTransform = ResolveCameraTransform(); + } + + private MonoBehaviour ResolveBehaviour( + MonoBehaviour? explicitComponent, + MonoBehaviour? providerComponent, + string fieldName) where T : class + { + var resolved = ResolveOptionalBehaviour(explicitComponent, providerComponent); + if (resolved != null) + return resolved; + + throw new InvalidOperationException( + $"Eve Unity playable world bootstrap requires '{fieldName}' or provider to implement {typeof(T).Name}."); + } + + private MonoBehaviour? ResolveOptionalBehaviour( + MonoBehaviour? explicitComponent, + MonoBehaviour? providerComponent) where T : class + { + if (explicitComponent is T) + return explicitComponent; + + if (providerComponent is T) + return providerComponent; + + var components = GetComponents(); + foreach (var component in components) + { + if (component is T) + return component; + } + + return null; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs.meta new file mode 100644 index 0000000..845b68c --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d5e4b8e1e104bb99522dc2b75d7f4c9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs new file mode 100644 index 0000000..30da84a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityPlayableWorldClientHost : MonoBehaviour + { + [SerializeField] private Transform? sceneRoot; + [SerializeField] private MonoBehaviour? providerSurfaceDocuments; + [SerializeField] private MonoBehaviour? commandSink; + [SerializeField] private MonoBehaviour? assetManifestDocuments; + [SerializeField] private MonoBehaviour? receiptSource; + [SerializeField] private MonoBehaviour? fallbackAssetProvider; + [SerializeField] private bool connectOnEnable; + [SerializeField] private bool refreshInUpdate = true; + [SerializeField] private float refreshIntervalSeconds = 0.1f; + + private float _nextRefreshAt; + + public EveUnityPlayableWorldRuntime? Runtime { get; private set; } + + public EveUnityPlayableWorldProjection? ActiveWorld => Runtime?.ActiveWorld; + + public EveUnitySceneProjection? ActiveProjection => Runtime?.ActiveProjection; + + public EveUnityPlayableWorldPresentation? LastPresentation => Runtime?.LastPresentation; + + public EveUnitySceneCommandReceipt? LastReceipt => Runtime?.LastReceipt; + + public long ActiveVersion => Runtime?.ActiveVersion ?? 0; + + public Transform SceneRoot => sceneRoot == null ? transform : sceneRoot; + + public bool ConnectOnEnable + { + get => connectOnEnable; + set => connectOnEnable = value; + } + + public void Configure( + Transform? sceneRoot, + MonoBehaviour providerSurfaceDocuments, + MonoBehaviour commandSink, + MonoBehaviour? assetManifestDocuments = null, + MonoBehaviour? receiptSource = null, + MonoBehaviour? fallbackAssetProvider = null) + { + this.sceneRoot = sceneRoot; + this.providerSurfaceDocuments = providerSurfaceDocuments; + this.commandSink = commandSink; + this.assetManifestDocuments = assetManifestDocuments; + this.receiptSource = receiptSource; + this.fallbackAssetProvider = fallbackAssetProvider; + } + + public EveUnityPlayableWorldPresentation Connect() + { + Disconnect(); + RefreshProviderSources(); + + Runtime = EveUnityPlayableWorldRuntime.CreateForGameObjectScene( + SceneRoot, + Required( + providerSurfaceDocuments, + nameof(providerSurfaceDocuments)), + Required( + commandSink, + nameof(commandSink)), + Optional(assetManifestDocuments), + Optional(receiptSource), + Optional(fallbackAssetProvider)); + + return Runtime.Connect(); + } + + public EveUnityPlayableWorldPresentation Refresh() + { + RefreshProviderSources(); + if (Runtime == null) + return Connect(); + + return Runtime.Refresh(); + } + + public EveSurfaceCommandRequest SubmitMoveIntent( + string entityId, + float targetX, + float targetY, + float targetZ, + DateTimeOffset? issuedAt = null) + { + return RequireRuntime().SubmitMoveIntent(entityId, targetX, targetY, targetZ, issuedAt); + } + + public EveSurfaceCommandRequest SubmitMoveVectorIntent( + string entityId, + float directionX, + float directionY, + float scalarValue = 1f, + DateTimeOffset? issuedAt = null) + { + return RequireRuntime().SubmitMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt); + } + + public EveSurfaceCommandRequest SubmitFocusIntent( + string entityId, + DateTimeOffset? issuedAt = null) + { + return RequireRuntime().SubmitFocusIntent(entityId, issuedAt); + } + + public EveSurfaceCommandRequest SubmitTargetIntent( + string sourceEntityId, + string targetEntityId, + DateTimeOffset? issuedAt = null) + { + return RequireRuntime().SubmitTargetIntent(sourceEntityId, targetEntityId, issuedAt); + } + + public EveSurfaceCommandRequest SubmitActionIntent( + string entityId, + string actionId, + DateTimeOffset? issuedAt = null) + { + return RequireRuntime().SubmitActionIntent(entityId, actionId, issuedAt); + } + + public void Disconnect() + { + Runtime?.Dispose(); + Runtime = null; + } + + private void OnEnable() + { + if (connectOnEnable) + Connect(); + } + + private void Update() + { + if (!refreshInUpdate || Runtime == null) + return; + + if (Time.unscaledTime < _nextRefreshAt) + return; + + _nextRefreshAt = Time.unscaledTime + Math.Max(0.01f, refreshIntervalSeconds); + Refresh(); + } + + private void OnDisable() + { + Disconnect(); + } + + private void OnDestroy() + { + Disconnect(); + } + + private EveUnityPlayableWorldRuntime RequireRuntime() + { + if (Runtime == null) + throw new InvalidOperationException("Eve Unity playable world client host is not connected."); + + return Runtime; + } + + private void RefreshProviderSources() + { + var refreshed = new HashSet(); + RefreshProvider(providerSurfaceDocuments, refreshed); + RefreshProvider(assetManifestDocuments, refreshed); + RefreshProvider(receiptSource, refreshed); + RefreshProvider(commandSink, refreshed); + } + + private static void RefreshProvider(MonoBehaviour? behaviour, ISet refreshed) + { + if (behaviour is IEveUnityProviderRefreshSource refreshSource && refreshed.Add(behaviour)) + refreshSource.Refresh(); + } + + private static T Required(MonoBehaviour? behaviour, string fieldName) where T : class + { + var resolved = behaviour as T; + if (resolved == null) + throw new InvalidOperationException( + $"Eve Unity playable world client host requires '{fieldName}' to implement {typeof(T).Name}."); + + return resolved; + } + + private static T? Optional(MonoBehaviour? behaviour) where T : class + { + return behaviour as T; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs.meta new file mode 100644 index 0000000..83701ad --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: df3f3a1abd3f14541bf6d7ec59343661 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs new file mode 100644 index 0000000..03850e1 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs @@ -0,0 +1,173 @@ +using System; +using GameCult.Eve.Surface; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityPlayableWorldInputDriver : MonoBehaviour + { + [SerializeField] private EveUnityPlayableWorldClientHost? host; + [SerializeField] private Transform? cameraTransform; + [SerializeField] private bool driveInUpdate = true; + [SerializeField] private string horizontalAxis = "Horizontal"; + [SerializeField] private string verticalAxis = "Vertical"; + [SerializeField] private string actionButton = "Fire1"; + [SerializeField] private float deadZone = 0.01f; + [SerializeField] private float commandIntervalSeconds = 0.05f; + [SerializeField] private bool submitZeroOnRelease = true; + + private float _nextCommandAt; + private bool _wasMoving; + + public EveUnityPlayableWorldClientHost? Host + { + get => host; + set => host = value; + } + + public Transform? CameraTransform + { + get => cameraTransform; + set => cameraTransform = value; + } + + public EveSurfaceCommandRequest? SubmitMoveVectorInput( + float horizontal, + float vertical, + DateTimeOffset? issuedAt = null) + { + var resolvedHost = ResolveHost(); + if (resolvedHost == null || resolvedHost.ActiveWorld == null) + return null; + + var movement = EveUnityPlayableWorldMoveVector.FromCameraRelativeInput( + horizontal, + vertical, + cameraTransform, + deadZone); + + if (!movement.HasInput) + { + if (!submitZeroOnRelease || !_wasMoving) + return null; + + _wasMoving = false; + return resolvedHost.SubmitMoveVectorIntent( + resolvedHost.ActiveWorld.PlayerEntityId, + 0f, + 0f, + 0f, + issuedAt); + } + + _wasMoving = true; + return resolvedHost.SubmitMoveVectorIntent( + resolvedHost.ActiveWorld.PlayerEntityId, + movement.DirectionX, + movement.DirectionY, + movement.ScalarValue, + issuedAt); + } + + public EveSurfaceCommandRequest? SubmitPrimaryAction(string actionId = "primary") + { + var resolvedHost = ResolveHost(); + if (resolvedHost?.ActiveWorld == null) + return null; + + return resolvedHost.SubmitActionIntent( + resolvedHost.ActiveWorld.PlayerEntityId, + string.IsNullOrWhiteSpace(actionId) ? "primary" : actionId); + } + + private void Update() + { + if (!driveInUpdate || Time.unscaledTime < _nextCommandAt) + return; + + _nextCommandAt = Time.unscaledTime + Math.Max(0.01f, commandIntervalSeconds); + SubmitMoveVectorInput(Input.GetAxisRaw(horizontalAxis), Input.GetAxisRaw(verticalAxis)); + + if (!string.IsNullOrWhiteSpace(actionButton) && Input.GetButtonDown(actionButton)) + SubmitPrimaryAction(actionButton); + } + + private EveUnityPlayableWorldClientHost? ResolveHost() + { + if (host != null) + return host; + + host = GetComponent(); + return host; + } + } + + public readonly struct EveUnityPlayableWorldMoveVector + { + public EveUnityPlayableWorldMoveVector( + float directionX, + float directionY, + float scalarValue, + bool hasInput) + { + DirectionX = directionX; + DirectionY = directionY; + ScalarValue = scalarValue; + HasInput = hasInput; + } + + public float DirectionX { get; } + + public float DirectionY { get; } + + public float ScalarValue { get; } + + public bool HasInput { get; } + + public static EveUnityPlayableWorldMoveVector FromCameraRelativeInput( + float horizontal, + float vertical, + Transform? cameraTransform, + float deadZone = 0.01f) + { + var input = new Vector2(horizontal, vertical); + var magnitude = Mathf.Clamp01(input.magnitude); + if (magnitude <= Mathf.Max(0f, deadZone)) + return new EveUnityPlayableWorldMoveVector(0f, 0f, 0f, false); + + Vector3 direction; + if (cameraTransform == null) + { + direction = new Vector3(horizontal, 0f, vertical); + } + else + { + var forward = cameraTransform.forward; + forward.y = 0f; + if (forward.sqrMagnitude <= 0.0001f) + forward = Vector3.forward; + forward.Normalize(); + + var right = cameraTransform.right; + right.y = 0f; + if (right.sqrMagnitude <= 0.0001f) + right = Vector3.right; + right.Normalize(); + + direction = (right * horizontal) + (forward * vertical); + } + + if (direction.sqrMagnitude <= 0.0001f) + return new EveUnityPlayableWorldMoveVector(0f, 0f, 0f, false); + + direction.Normalize(); + return new EveUnityPlayableWorldMoveVector( + direction.x, + direction.z, + magnitude, + true); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs.meta new file mode 100644 index 0000000..0110ff1 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7ad1be678e6e4d6d9c72769358e0a233 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs new file mode 100644 index 0000000..6a50694 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -0,0 +1,225 @@ +using System; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityPlayableWorldLiveClient : IDisposable + { + private readonly EveUnitySceneProviderConnection _connection; + private readonly EveUnityPlayableWorldPresenter _presenter; + private readonly IEveUnitySceneCommandReceiptSource? _receiptSource; + private readonly bool _refreshOnTerminalReceipt; + private bool _connected; + private bool _receiptConnected; + + public EveUnityPlayableWorldLiveClient( + EveUnitySceneProviderConnection connection, + EveUnityPlayableWorldPresenter presenter, + IEveUnitySceneCommandReceiptSource? receiptSource = null, + bool refreshOnTerminalReceipt = true) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _presenter = presenter ?? throw new ArgumentNullException(nameof(presenter)); + _receiptSource = receiptSource; + _refreshOnTerminalReceipt = refreshOnTerminalReceipt; + } + + public EveUnitySceneProjection? ActiveProjection => _connection.ActiveProjection; + + public EveUnityPlayableWorldProjection? ActiveWorld => ActiveProjection?.PlayableWorld; + + public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; } + + public EveUnitySceneCommandReceipt? LastReceipt { get; private set; } + + public long ActiveVersion => _connection.ActiveVersion; + + public string SourcePointer => _connection.SourcePointer; + + public event Action? ReceiptAvailable; + + public EveUnityPlayableWorldPresentation Connect() + { + EnsureSubscribed(); + EnsureReceiptSubscribed(); + _connection.Connect(); + return RequirePresentation(); + } + + public EveUnityPlayableWorldPresentation Refresh() + { + EnsureSubscribed(); + _connection.Refresh(); + return RequirePresentation(); + } + + public EveSurfaceCommandRequest SubmitMoveIntent( + string entityId, + float targetX, + float targetY, + float targetZ, + DateTimeOffset? issuedAt = null) + { + return _connection.SubmitMoveIntent(entityId, targetX, targetY, targetZ, issuedAt); + } + + public EveSurfaceCommandRequest SubmitMoveVectorIntent( + string entityId, + float directionX, + float directionY, + float scalarValue = 1f, + DateTimeOffset? issuedAt = null) + { + return _connection.SubmitMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt); + } + + public EveSurfaceCommandRequest SubmitFocusIntent( + string entityId, + DateTimeOffset? issuedAt = null) + { + return _connection.SubmitFocusIntent(entityId, issuedAt); + } + + public EveSurfaceCommandRequest SubmitTargetIntent( + string sourceEntityId, + string targetEntityId, + DateTimeOffset? issuedAt = null) + { + return _connection.SubmitTargetIntent(sourceEntityId, targetEntityId, issuedAt); + } + + public EveSurfaceCommandRequest SubmitActionIntent( + string entityId, + string actionId, + DateTimeOffset? issuedAt = null) + { + return _connection.SubmitActionIntent(entityId, actionId, issuedAt); + } + + public void Disconnect() + { + if (_connected) + { + _connection.ProjectionUpdated -= OnProjectionUpdated; + _connected = false; + } + + if (_receiptConnected && _receiptSource != null) + { + _receiptSource.ReceiptAvailable -= OnReceiptAvailable; + _receiptConnected = false; + } + + _connection.Disconnect(); + } + + public void Dispose() + { + Disconnect(); + _connection.Dispose(); + } + + private void EnsureSubscribed() + { + if (_connected) + return; + + _connection.ProjectionUpdated += OnProjectionUpdated; + _connected = true; + } + + private void EnsureReceiptSubscribed() + { + if (_receiptSource == null || _receiptConnected) + return; + + _receiptSource.ReceiptAvailable += OnReceiptAvailable; + _receiptConnected = true; + } + + private void OnProjectionUpdated(EveUnitySceneProjection projection) + { + LastPresentation = _presenter.Apply(projection); + } + + private void OnReceiptAvailable(EveUnitySceneCommandReceipt receipt) + { + LastReceipt = receipt ?? throw new ArgumentNullException(nameof(receipt)); + ReceiptAvailable?.Invoke(receipt); + + if (_refreshOnTerminalReceipt && receipt.ShouldRefreshProviderSurface) + Refresh(); + } + + private EveUnityPlayableWorldPresentation RequirePresentation() + { + if (LastPresentation == null) + throw new InvalidOperationException("The active Unity scene surface has not produced a playable world presentation."); + return LastPresentation; + } + } + + public interface IEveUnitySceneCommandReceiptSource + { + event Action ReceiptAvailable; + } + + public sealed class EveUnitySceneCommandReceipt + { + public EveUnitySceneCommandReceipt( + string receiptId, + string command, + string commandId, + string state, + string ownerRepo, + string authority, + string schema = "gamecult.eve.command_receipt.v1", + string providerId = "", + string surfaceId = "", + string message = "", + DateTimeOffset? issuedAtUtc = null) + { + ReceiptId = receiptId ?? ""; + Command = command ?? ""; + CommandId = commandId ?? ""; + State = state ?? ""; + OwnerRepo = ownerRepo ?? ""; + Authority = authority ?? ""; + Schema = string.IsNullOrWhiteSpace(schema) ? "gamecult.eve.command_receipt.v1" : schema; + ProviderId = providerId ?? ""; + SurfaceId = surfaceId ?? ""; + Message = message ?? ""; + IssuedAtUtc = issuedAtUtc; + } + + public string Schema { get; } + + public string ReceiptId { get; } + + public string Command { get; } + + public string CommandId { get; } + + public string State { get; } + + public string OwnerRepo { get; } + + public string Authority { get; } + + public string ProviderId { get; } + + public string SurfaceId { get; } + + public string Message { get; } + + public DateTimeOffset? IssuedAtUtc { get; } + + public bool IsProviderOwned => !string.IsNullOrWhiteSpace(OwnerRepo) && !string.Equals(OwnerRepo, "EveUnity", StringComparison.Ordinal); + + public bool ShouldRefreshProviderSurface => + string.Equals(State, "accepted", StringComparison.OrdinalIgnoreCase) || + string.Equals(State, "reconciled", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs.meta new file mode 100644 index 0000000..ef9f199 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b46f9972e945ca4daa29473de0bfb9b \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs new file mode 100644 index 0000000..b77695b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public interface IEveUnityPlayableWorldSceneSink + { + void ConfigureWorld(EveUnityPlayableWorldProjection world); + + void UpsertEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset); + + void RemoveEntity(string entityId); + } + + public interface IEveUnityPlayableWorldAssetResolver + { + EveUnityPlayableWorldAssetBinding Resolve(EveUnityPlayableWorldEntity entity); + } + + public sealed class EveUnityPlayableWorldPresenter + { + private readonly IEveUnityPlayableWorldSceneSink _sceneSink; + private readonly IEveUnityPlayableWorldAssetResolver _assetResolver; + private readonly HashSet _knownEntityIds = new HashSet(StringComparer.Ordinal); + + public EveUnityPlayableWorldPresenter( + IEveUnityPlayableWorldSceneSink sceneSink, + IEveUnityPlayableWorldAssetResolver assetResolver) + { + _sceneSink = sceneSink ?? throw new ArgumentNullException(nameof(sceneSink)); + _assetResolver = assetResolver ?? throw new ArgumentNullException(nameof(assetResolver)); + } + + public EveUnityPlayableWorldPresentation Apply(EveUnitySceneProjection projection) + { + if (projection == null) throw new ArgumentNullException(nameof(projection)); + if (projection.PlayableWorld == null) + throw new InvalidOperationException("Unity playable world presentation requires a playableWorld projection."); + + return Apply(projection.PlayableWorld); + } + + public EveUnityPlayableWorldPresentation Apply(EveUnityPlayableWorldProjection world) + { + if (world == null) throw new ArgumentNullException(nameof(world)); + + var nextEntityIds = new HashSet(StringComparer.Ordinal); + _sceneSink.ConfigureWorld(world); + + var upserted = 0; + foreach (var entity in world.Entities) + { + if (string.IsNullOrWhiteSpace(entity.EntityId)) + continue; + + nextEntityIds.Add(entity.EntityId); + _sceneSink.UpsertEntity(entity, _assetResolver.Resolve(entity)); + upserted++; + } + + var removed = 0; + var previousEntityIds = new List(_knownEntityIds); + foreach (var entityId in previousEntityIds) + { + if (nextEntityIds.Contains(entityId)) + continue; + + _sceneSink.RemoveEntity(entityId); + _knownEntityIds.Remove(entityId); + removed++; + } + + foreach (var entityId in nextEntityIds) + _knownEntityIds.Add(entityId); + + return new EveUnityPlayableWorldPresentation( + world.WorldRootId, + world.PlayerEntityId, + world.InputProfile, + world.CameraRig, + upserted, + removed, + _knownEntityIds.Count); + } + } + + public sealed class EveUnityPlayableWorldAssetBinding + { + public EveUnityPlayableWorldAssetBinding( + string assetRef, + string entityKind, + string presentationKind) + { + AssetRef = assetRef ?? ""; + EntityKind = entityKind ?? ""; + PresentationKind = presentationKind ?? ""; + } + + public string AssetRef { get; } + + public string EntityKind { get; } + + public string PresentationKind { get; } + } + + public sealed class EveUnityPlayableWorldPresentation + { + public EveUnityPlayableWorldPresentation( + string worldRootId, + string playerEntityId, + string inputProfile, + string cameraRig, + int upsertedEntities, + int removedEntities, + int activeEntities) + { + WorldRootId = worldRootId ?? ""; + PlayerEntityId = playerEntityId ?? ""; + InputProfile = inputProfile ?? ""; + CameraRig = cameraRig ?? ""; + UpsertedEntities = upsertedEntities; + RemovedEntities = removedEntities; + ActiveEntities = activeEntities; + } + + public string WorldRootId { get; } + + public string PlayerEntityId { get; } + + public string InputProfile { get; } + + public string CameraRig { get; } + + public int UpsertedEntities { get; } + + public int RemovedEntities { get; } + + public int ActiveEntities { get; } + } + + public sealed class EveUnityAssetRefResolver : IEveUnityPlayableWorldAssetResolver + { + public EveUnityPlayableWorldAssetBinding Resolve(EveUnityPlayableWorldEntity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + return new EveUnityPlayableWorldAssetBinding( + entity.AssetRef, + entity.EntityKind, + string.IsNullOrWhiteSpace(entity.AssetRef) ? "unity-generated-placeholder" : "provider-asset-ref"); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs.meta new file mode 100644 index 0000000..97b6682 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1df1c82e51d1ee04c96247cdff61101a \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs new file mode 100644 index 0000000..3dd56e4 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs @@ -0,0 +1,207 @@ +using System; +using GameCult.Eve.Surface; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityPlayableWorldRuntime : IDisposable + { + private readonly EveUnitySceneProviderSurfaceDocumentSource _surfaceSource; + private readonly EveUnitySceneProviderConnection _connection; + private readonly EveUnityPlayableWorldLiveClient _client; + private readonly EveUnityPlayableWorldAssetManifestDocumentSource? _assetManifestSource; + private bool _assetManifestConnected; + + public EveUnityPlayableWorldRuntime( + IEveUnitySceneProviderSurfaceDocumentSource surfaceDocuments, + IEveUnitySceneCommandSink commandSink, + IEveUnityPlayableWorldSceneSink sceneSink, + IEveUnityPlayableWorldAssetManifestDocumentSource? assetManifestDocuments = null, + IEveUnitySceneCommandReceiptSource? receiptSource = null, + IEveUnityPlayableWorldAssetResolver? assetResolver = null, + EveUnityPlayableWorldAssetManifestCache? assetManifests = null) + { + if (surfaceDocuments == null) throw new ArgumentNullException(nameof(surfaceDocuments)); + if (commandSink == null) throw new ArgumentNullException(nameof(commandSink)); + if (sceneSink == null) throw new ArgumentNullException(nameof(sceneSink)); + + AssetManifests = assetManifests ?? new EveUnityPlayableWorldAssetManifestCache(); + _surfaceSource = new EveUnitySceneProviderSurfaceDocumentSource(surfaceDocuments); + _connection = new EveUnitySceneProviderConnection(_surfaceSource, commandSink); + _client = new EveUnityPlayableWorldLiveClient( + _connection, + new EveUnityPlayableWorldPresenter(sceneSink, assetResolver ?? new EveUnityAssetRefResolver()), + receiptSource); + + if (assetManifestDocuments != null) + _assetManifestSource = new EveUnityPlayableWorldAssetManifestDocumentSource(assetManifestDocuments); + } + + public EveUnityPlayableWorldAssetManifestCache AssetManifests { get; } + + public EveUnityPlayableWorldProjection? ActiveWorld => _client.ActiveWorld; + + public EveUnitySceneProjection? ActiveProjection => _client.ActiveProjection; + + public EveUnityPlayableWorldPresentation? LastPresentation => _client.LastPresentation; + + public EveUnitySceneCommandReceipt? LastReceipt => _client.LastReceipt; + + public long ActiveVersion => _client.ActiveVersion; + + public string SourcePointer => _client.SourcePointer; + + public event Action? ReceiptAvailable + { + add { _client.ReceiptAvailable += value; } + remove { _client.ReceiptAvailable -= value; } + } + + public static EveUnityPlayableWorldRuntime CreateForGameObjectScene( + Transform root, + IEveUnitySceneProviderSurfaceDocumentSource surfaceDocuments, + IEveUnitySceneCommandSink commandSink, + IEveUnityPlayableWorldAssetManifestDocumentSource? assetManifestDocuments = null, + IEveUnitySceneCommandReceiptSource? receiptSource = null, + IEveUnityGameObjectAssetProvider? fallbackAssetProvider = null) + { + EveUnityPlayableWorldRuntime? runtime = null; + var assetManifests = new EveUnityPlayableWorldAssetManifestCache(); + var liveAssetProvider = new EveUnityLivePlayableWorldAssetProvider( + assetManifests, + () => runtime?.ActiveWorld, + fallbackAssetProvider ?? new EveUnityResourcesAssetProvider()); + var sceneSink = new EveUnityGameObjectPlayableWorldSceneSink(root, liveAssetProvider); + + runtime = new EveUnityPlayableWorldRuntime( + surfaceDocuments, + commandSink, + sceneSink, + assetManifestDocuments, + receiptSource, + new EveUnityAssetRefResolver(), + assetManifests); + return runtime; + } + + public EveUnityPlayableWorldPresentation Connect() + { + EnsureAssetManifestConnected(); + return _client.Connect(); + } + + public EveUnityPlayableWorldPresentation Refresh() + { + EnsureAssetManifestConnected(); + return _client.Refresh(); + } + + public EveSurfaceCommandRequest SubmitMoveIntent( + string entityId, + float targetX, + float targetY, + float targetZ, + DateTimeOffset? issuedAt = null) + { + return _client.SubmitMoveIntent(entityId, targetX, targetY, targetZ, issuedAt); + } + + public EveSurfaceCommandRequest SubmitMoveVectorIntent( + string entityId, + float directionX, + float directionY, + float scalarValue = 1f, + DateTimeOffset? issuedAt = null) + { + return _client.SubmitMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt); + } + + public EveSurfaceCommandRequest SubmitFocusIntent( + string entityId, + DateTimeOffset? issuedAt = null) + { + return _client.SubmitFocusIntent(entityId, issuedAt); + } + + public EveSurfaceCommandRequest SubmitTargetIntent( + string sourceEntityId, + string targetEntityId, + DateTimeOffset? issuedAt = null) + { + return _client.SubmitTargetIntent(sourceEntityId, targetEntityId, issuedAt); + } + + public EveSurfaceCommandRequest SubmitActionIntent( + string entityId, + string actionId, + DateTimeOffset? issuedAt = null) + { + return _client.SubmitActionIntent(entityId, actionId, issuedAt); + } + + public void Disconnect() + { + _client.Disconnect(); + + if (_assetManifestConnected && _assetManifestSource != null) + { + AssetManifests.Disconnect(_assetManifestSource); + _assetManifestSource.Disconnect(); + _assetManifestConnected = false; + } + } + + public void Dispose() + { + Disconnect(); + _client.Dispose(); + _assetManifestSource?.Dispose(); + } + + private void EnsureAssetManifestConnected() + { + if (_assetManifestConnected || _assetManifestSource == null) + return; + + _assetManifestSource.Connect(); + AssetManifests.Connect(_assetManifestSource); + _assetManifestConnected = true; + } + } + + public sealed class EveUnityLivePlayableWorldAssetProvider : IEveUnityGameObjectAssetProvider + { + private readonly EveUnityPlayableWorldAssetManifestCache _assetManifests; + private readonly Func _activeWorld; + private readonly IEveUnityGameObjectAssetProvider _fallback; + + public EveUnityLivePlayableWorldAssetProvider( + EveUnityPlayableWorldAssetManifestCache assetManifests, + Func activeWorld, + IEveUnityGameObjectAssetProvider fallback) + { + _assetManifests = assetManifests ?? throw new ArgumentNullException(nameof(assetManifests)); + _activeWorld = activeWorld ?? throw new ArgumentNullException(nameof(activeWorld)); + _fallback = fallback ?? throw new ArgumentNullException(nameof(fallback)); + } + + public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) + { + if (asset == null) throw new ArgumentNullException(nameof(asset)); + + var world = _activeWorld(); + var manifest = world == null ? null : _assetManifests.GetForWorld(world); + if (manifest != null) + { + var provider = new EveUnityManifestGameObjectAssetProvider(manifest, _fallback); + var prefab = provider.ResolvePrefab(asset); + if (prefab != null) + return prefab; + } + + return _fallback.ResolvePrefab(asset); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs.meta new file mode 100644 index 0000000..57fd452 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9323e6fa0b61837499fa84b30e597e92 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs new file mode 100644 index 0000000..06f8937 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs @@ -0,0 +1,7 @@ +namespace GameCult.Eve.UnityScene +{ + public interface IEveUnityProviderRefreshSource + { + void Refresh(); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs.meta new file mode 100644 index 0000000..29cdeb8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: babddb726f9747e43a7789eb6b584fad \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs new file mode 100644 index 0000000..e82cf56 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnitySceneClientSession + { + public const string RuntimeClientId = "unity-scene"; + + private readonly EveUnitySceneSurfaceLowerer _lowerer; + private EveUnitySceneProviderSurfaceSnapshot? _activeSnapshot; + + public EveUnitySceneClientSession() + : this(new EveUnitySceneSurfaceLowerer()) + { + } + + public EveUnitySceneClientSession(EveUnitySceneSurfaceLowerer lowerer) + { + _lowerer = lowerer ?? throw new ArgumentNullException(nameof(lowerer)); + } + + public EveUnitySceneProjection? ActiveProjection { get; private set; } + + public string ActiveSourcePointer => _activeSnapshot?.SourcePointer ?? ""; + + public long ActiveVersion => _activeSnapshot?.Version ?? 0; + + public EveUnitySceneProjection Connect(EveUnitySceneProviderSurfaceSnapshot snapshot) + { + if (snapshot == null) throw new ArgumentNullException(nameof(snapshot)); + + var projection = _lowerer.Lower(snapshot.Document, snapshot.AdvertisedSurface); + _activeSnapshot = snapshot; + ActiveProjection = projection; + return projection; + } + + public EveUnitySceneProjection ApplySnapshot(EveUnitySceneProviderSurfaceSnapshot snapshot) + { + return Connect(snapshot); + } + + public EveSurfaceCommandRequest CreateMoveIntent( + string entityId, + float targetX, + float targetY, + float targetZ, + DateTimeOffset? issuedAt = null) + { + var playableWorld = RequirePlayableWorld(); + return CreatePlayableWorldIntent( + playableWorld.MovementCommand, + new Dictionary(StringComparer.Ordinal) + { + ["entityId"] = entityId ?? "", + ["targetPosition"] = FormatVector3(targetX, targetY, targetZ) + }, + issuedAt); + } + + public EveSurfaceCommandRequest CreateMoveVectorIntent( + string entityId, + float directionX, + float directionY, + float scalarValue = 1f, + DateTimeOffset? issuedAt = null) + { + var playableWorld = RequirePlayableWorld(); + return CreatePlayableWorldIntent( + playableWorld.MovementCommand, + new Dictionary(StringComparer.Ordinal) + { + ["entityId"] = entityId ?? "", + ["directionX"] = FormatFloat(directionX), + ["directionY"] = FormatFloat(directionY), + ["scalarValue"] = FormatFloat(scalarValue) + }, + issuedAt); + } + + public EveSurfaceCommandRequest CreateFocusIntent( + string entityId, + DateTimeOffset? issuedAt = null) + { + var playableWorld = RequirePlayableWorld(); + return CreatePlayableWorldIntent( + playableWorld.FocusCommand, + new Dictionary(StringComparer.Ordinal) + { + ["entityId"] = entityId ?? "" + }, + issuedAt); + } + + public EveSurfaceCommandRequest CreateTargetIntent( + string sourceEntityId, + string targetEntityId, + DateTimeOffset? issuedAt = null) + { + var playableWorld = RequirePlayableWorld(); + return CreatePlayableWorldIntent( + playableWorld.TargetCommand, + new Dictionary(StringComparer.Ordinal) + { + ["sourceEntityId"] = sourceEntityId ?? "", + ["targetEntityId"] = targetEntityId ?? "" + }, + issuedAt); + } + + public EveSurfaceCommandRequest CreateActionIntent( + string entityId, + string actionId, + DateTimeOffset? issuedAt = null) + { + var playableWorld = RequirePlayableWorld(); + return CreatePlayableWorldIntent( + playableWorld.ActionCommand, + new Dictionary(StringComparer.Ordinal) + { + ["entityId"] = entityId ?? "", + ["actionId"] = actionId ?? "" + }, + issuedAt); + } + + public EveSurfaceCommandRequest CreatePlayableWorldIntent( + string commandId, + IReadOnlyDictionary? payload = null, + DateTimeOffset? issuedAt = null) + { + if (string.IsNullOrWhiteSpace(commandId)) + throw new InvalidOperationException("Playable world command id is missing from the active surface."); + + var snapshot = RequireSnapshot(); + var projection = RequireProjection(); + var envelopePayload = new Dictionary(StringComparer.Ordinal) + { + ["commandId"] = commandId + }; + + if (payload != null) + { + foreach (var entry in payload) + envelopePayload[entry.Key] = entry.Value; + } + + return _lowerer.CreateCommandIntent( + snapshot.Document, + snapshot.AdvertisedSurface, + projection.CommandBoundary, + envelopePayload, + issuedAt); + } + + private EveUnityPlayableWorldProjection RequirePlayableWorld() + { + var projection = RequireProjection(); + if (projection.PlayableWorld == null) + throw new InvalidOperationException("The active Unity scene surface does not expose a playable world projection."); + return projection.PlayableWorld; + } + + private EveUnitySceneProjection RequireProjection() + { + if (ActiveProjection == null) + throw new InvalidOperationException("No provider surface snapshot has been connected to the Unity scene client session."); + return ActiveProjection; + } + + private EveUnitySceneProviderSurfaceSnapshot RequireSnapshot() + { + if (_activeSnapshot == null) + throw new InvalidOperationException("No provider surface snapshot has been connected to the Unity scene client session."); + return _activeSnapshot; + } + + private static string FormatVector3(float x, float y, float z) + { + return string.Join( + ",", + x.ToString("R", CultureInfo.InvariantCulture), + y.ToString("R", CultureInfo.InvariantCulture), + z.ToString("R", CultureInfo.InvariantCulture)); + } + + private static string FormatFloat(float value) + { + return value.ToString("R", CultureInfo.InvariantCulture); + } + } + + public sealed class EveUnitySceneProviderSurfaceSnapshot + { + public EveUnitySceneProviderSurfaceSnapshot( + EveSurfaceDocument document, + EveUnitySceneProviderSurfaceAdvertisement advertisedSurface, + string sourcePointer, + long version = 0) + { + Document = document ?? throw new ArgumentNullException(nameof(document)); + AdvertisedSurface = advertisedSurface ?? throw new ArgumentNullException(nameof(advertisedSurface)); + SourcePointer = sourcePointer ?? ""; + Version = version; + } + + public EveSurfaceDocument Document { get; } + + public EveUnitySceneProviderSurfaceAdvertisement AdvertisedSurface { get; } + + public string SourcePointer { get; } + + public long Version { get; } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs.meta new file mode 100644 index 0000000..5919fc3 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2b8fac8eb072f8419afa594928840ef \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs new file mode 100644 index 0000000..44afc89 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs @@ -0,0 +1,290 @@ +using System; +using GameCult.Eve.Surface; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public interface IEveUnitySceneLiveProviderTransport + { + string TransportKind { get; } + + string SurfacePointer { get; } + + string AssetManifestPointer { get; } + + EveUnitySceneProviderSurfaceDocument CurrentSurfaceDocument { get; } + + EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; } + + event Action SurfaceDocumentAvailable; + + event Action AssetManifestDocumentAvailable; + + event Action CommandReceiptAvailable; + + void Connect(); + + void Disconnect(); + + void Refresh(); + + void SubmitCommand(EveSurfaceCommandRequest request); + } + + public abstract class EveUnitySceneLiveProviderTransportBehaviour : + MonoBehaviour, + IEveUnitySceneLiveProviderTransport + { + public abstract string TransportKind { get; } + + public abstract string SurfacePointer { get; } + + public abstract string AssetManifestPointer { get; } + + public abstract EveUnitySceneProviderSurfaceDocument CurrentSurfaceDocument { get; } + + public abstract EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; } + + public abstract event Action SurfaceDocumentAvailable; + + public abstract event Action AssetManifestDocumentAvailable; + + public abstract event Action CommandReceiptAvailable; + + public abstract void Connect(); + + public abstract void Disconnect(); + + public abstract void Refresh(); + + public abstract void SubmitCommand(EveSurfaceCommandRequest request); + } + + public sealed class EveUnitySceneLiveProviderBridgeComponent : + MonoBehaviour, + IEveUnitySceneProviderSurfaceDocumentSource, + IEveUnitySceneProviderSurfaceDocumentConnection, + IEveUnityPlayableWorldAssetManifestDocumentSource, + IEveUnitySceneCommandSink, + IEveUnitySceneCommandReceiptSource, + IEveUnityProviderRefreshSource + { + [SerializeField] private MonoBehaviour? transportBehaviour; + + private EveUnitySceneLiveProviderBridge? _bridge; + private IEveUnitySceneLiveProviderTransport? _configuredTransport; + + public string TransportKind => Bridge.TransportKind; + + public string SurfacePointer => Bridge.SurfacePointer; + + public string AssetManifestPointer => Bridge.AssetManifestPointer; + + public string SinkKind => Bridge.SinkKind; + + public string ManifestRef => Bridge.ManifestRef; + + public EveUnitySceneProviderSurfaceDocument CurrentSurfaceDocument => Bridge.CurrentSurfaceDocument; + + public EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument => Bridge.CurrentAssetManifestDocument; + + EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument => + Bridge.CurrentSurfaceDocument; + + EveUnityPlayableWorldAssetManifestDocument IEveUnityPlayableWorldAssetManifestDocumentSource.CurrentDocument => + Bridge.CurrentAssetManifestDocument; + + public event Action? DocumentAvailable + { + add { Bridge.DocumentAvailable += value; } + remove { Bridge.DocumentAvailable -= value; } + } + + public event Action? AssetManifestDocumentAvailable + { + add { Bridge.AssetManifestDocumentAvailable += value; } + remove { Bridge.AssetManifestDocumentAvailable -= value; } + } + + event Action IEveUnityPlayableWorldAssetManifestDocumentSource.DocumentAvailable + { + add { Bridge.AssetManifestDocumentAvailable += value; } + remove { Bridge.AssetManifestDocumentAvailable -= value; } + } + + public event Action? ReceiptAvailable + { + add { Bridge.ReceiptAvailable += value; } + remove { Bridge.ReceiptAvailable -= value; } + } + + public void Configure(IEveUnitySceneLiveProviderTransport transport) + { + if (transport == null) throw new ArgumentNullException(nameof(transport)); + _bridge?.Dispose(); + _configuredTransport = transport; + _bridge = new EveUnitySceneLiveProviderBridge(transport); + } + + public void Connect() + { + Bridge.Connect(); + } + + public void Disconnect() + { + _bridge?.Disconnect(); + } + + public void Refresh() + { + Bridge.Connect(); + Bridge.Refresh(); + } + + public void Submit(EveSurfaceCommandRequest request) + { + Bridge.Submit(request); + } + + private void OnDisable() + { + Disconnect(); + } + + private void OnDestroy() + { + _bridge?.Dispose(); + _bridge = null; + } + + private EveUnitySceneLiveProviderBridge Bridge + { + get + { + if (_bridge != null) + return _bridge; + + var transport = _configuredTransport ?? transportBehaviour as IEveUnitySceneLiveProviderTransport; + if (transport == null) + throw new InvalidOperationException( + "Eve Unity scene live provider bridge requires a transport implementing IEveUnitySceneLiveProviderTransport."); + + _bridge = new EveUnitySceneLiveProviderBridge(transport); + return _bridge; + } + } + } + + public sealed class EveUnitySceneLiveProviderBridge : + IEveUnitySceneProviderSurfaceDocumentSource, + IEveUnitySceneProviderSurfaceDocumentConnection, + IEveUnityPlayableWorldAssetManifestDocumentSource, + IEveUnitySceneCommandSink, + IEveUnitySceneCommandReceiptSource, + IEveUnityProviderRefreshSource, + IDisposable + { + private readonly IEveUnitySceneLiveProviderTransport _transport; + private bool _connected; + + public EveUnitySceneLiveProviderBridge(IEveUnitySceneLiveProviderTransport transport) + { + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + CurrentSurfaceDocument = _transport.CurrentSurfaceDocument; + CurrentAssetManifestDocument = _transport.CurrentAssetManifestDocument; + } + + public string TransportKind => _transport.TransportKind; + + public string SurfacePointer => _transport.SurfacePointer; + + public string AssetManifestPointer => _transport.AssetManifestPointer; + + public string SinkKind => _transport.TransportKind; + + public string ManifestRef => CurrentAssetManifestDocument.ManifestRef; + + public EveUnitySceneProviderSurfaceDocument CurrentSurfaceDocument { get; private set; } + + public EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; private set; } + + EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument => + CurrentSurfaceDocument; + + EveUnityPlayableWorldAssetManifestDocument IEveUnityPlayableWorldAssetManifestDocumentSource.CurrentDocument => + CurrentAssetManifestDocument; + + public event Action? DocumentAvailable; + + public event Action? AssetManifestDocumentAvailable; + + event Action IEveUnityPlayableWorldAssetManifestDocumentSource.DocumentAvailable + { + add { AssetManifestDocumentAvailable += value; } + remove { AssetManifestDocumentAvailable -= value; } + } + + public event Action? ReceiptAvailable; + + public void Connect() + { + if (_connected) + return; + + _transport.SurfaceDocumentAvailable += OnSurfaceDocumentAvailable; + _transport.AssetManifestDocumentAvailable += OnAssetManifestDocumentAvailable; + _transport.CommandReceiptAvailable += OnCommandReceiptAvailable; + _transport.Connect(); + _connected = true; + } + + public void Disconnect() + { + if (!_connected) + return; + + _transport.SurfaceDocumentAvailable -= OnSurfaceDocumentAvailable; + _transport.AssetManifestDocumentAvailable -= OnAssetManifestDocumentAvailable; + _transport.CommandReceiptAvailable -= OnCommandReceiptAvailable; + _transport.Disconnect(); + _connected = false; + } + + public void Refresh() + { + _transport.Refresh(); + } + + public void Submit(EveSurfaceCommandRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + _transport.SubmitCommand(request); + } + + public void Dispose() + { + Disconnect(); + } + + private void OnSurfaceDocumentAvailable(EveUnitySceneProviderSurfaceDocument document) + { + CurrentSurfaceDocument = document ?? throw new ArgumentNullException(nameof(document)); + DocumentAvailable?.Invoke(CurrentSurfaceDocument); + } + + private void OnAssetManifestDocumentAvailable(EveUnityPlayableWorldAssetManifestDocument document) + { + CurrentAssetManifestDocument = document ?? throw new ArgumentNullException(nameof(document)); + AssetManifestDocumentAvailable?.Invoke(CurrentAssetManifestDocument); + } + + private void OnCommandReceiptAvailable(EveUnitySceneCommandReceipt receipt) + { + if (receipt == null) throw new ArgumentNullException(nameof(receipt)); + ReceiptAvailable?.Invoke(receipt); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs.meta new file mode 100644 index 0000000..d7a6df6 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7e11af135f8f48c48a2e0d88f8f9d04c diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs new file mode 100644 index 0000000..e21292c --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs @@ -0,0 +1,291 @@ +using System; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public interface IEveUnitySceneProviderSurfaceSource + { + string ProviderId { get; } + + string SurfaceId { get; } + + string SourcePointer { get; } + + EveUnitySceneProviderSurfaceSnapshot CurrentSnapshot { get; } + + event Action SnapshotAvailable; + + void Connect(); + + void Refresh(); + + void Disconnect(); + } + + public interface IEveUnitySceneCommandSink + { + string SinkKind { get; } + + void Submit(EveSurfaceCommandRequest request); + } + + public interface IEveUnitySceneProviderSurfaceDocumentSource + { + EveUnitySceneProviderSurfaceDocument CurrentDocument { get; } + + event Action DocumentAvailable; + } + + public interface IEveUnitySceneProviderSurfaceDocumentConnection + { + void Connect(); + + void Disconnect(); + } + + public sealed class EveUnitySceneProviderSurfaceDocument + { + public EveUnitySceneProviderSurfaceDocument( + EveSurfaceDocument surfaceDocument, + EveUnitySceneProviderSurfaceAdvertisement advertisedSurface, + string sourcePointer, + long version = 0) + { + SurfaceDocument = surfaceDocument ?? throw new ArgumentNullException(nameof(surfaceDocument)); + AdvertisedSurface = advertisedSurface ?? throw new ArgumentNullException(nameof(advertisedSurface)); + SourcePointer = sourcePointer ?? ""; + Version = version == 0 ? surfaceDocument.Version : version; + } + + public EveSurfaceDocument SurfaceDocument { get; } + + public EveUnitySceneProviderSurfaceAdvertisement AdvertisedSurface { get; } + + public string SourcePointer { get; } + + public long Version { get; } + + public EveUnitySceneProviderSurfaceSnapshot ToSnapshot() + { + return new EveUnitySceneProviderSurfaceSnapshot( + SurfaceDocument, + AdvertisedSurface, + SourcePointer, + Version); + } + } + + public sealed class EveUnitySceneProviderSurfaceDocumentSource : IEveUnitySceneProviderSurfaceSource, IDisposable + { + private readonly IEveUnitySceneProviderSurfaceDocumentSource _documentSource; + private readonly IEveUnitySceneProviderSurfaceDocumentConnection? _documentConnection; + private readonly IEveUnityProviderRefreshSource? _refreshSource; + private bool _connected; + + public EveUnitySceneProviderSurfaceDocumentSource( + IEveUnitySceneProviderSurfaceDocumentSource documentSource) + { + _documentSource = documentSource ?? throw new ArgumentNullException(nameof(documentSource)); + _documentConnection = documentSource as IEveUnitySceneProviderSurfaceDocumentConnection; + _refreshSource = documentSource as IEveUnityProviderRefreshSource; + CurrentSnapshot = _documentSource.CurrentDocument.ToSnapshot(); + } + + public string ProviderId => CurrentSnapshot.Document.ProviderId; + + public string SurfaceId => CurrentSnapshot.AdvertisedSurface.SurfaceId; + + public string SourcePointer => CurrentSnapshot.SourcePointer; + + public EveUnitySceneProviderSurfaceSnapshot CurrentSnapshot { get; private set; } + + public event Action? SnapshotAvailable; + + public void Connect() + { + if (_connected) + return; + + _documentSource.DocumentAvailable += OnDocumentAvailable; + _documentConnection?.Connect(); + _connected = true; + } + + public void Refresh() + { + _refreshSource?.Refresh(); + } + + public void Disconnect() + { + if (!_connected) + return; + + _documentConnection?.Disconnect(); + _documentSource.DocumentAvailable -= OnDocumentAvailable; + _connected = false; + } + + public void Dispose() + { + Disconnect(); + } + + private void OnDocumentAvailable(EveUnitySceneProviderSurfaceDocument document) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + CurrentSnapshot = document.ToSnapshot(); + SnapshotAvailable?.Invoke(CurrentSnapshot); + } + } + + public sealed class EveUnitySceneProviderConnection : IDisposable + { + private readonly IEveUnitySceneProviderSurfaceSource _surfaceSource; + private readonly IEveUnitySceneCommandSink _commandSink; + private readonly EveUnitySceneClientSession _session; + private bool _connected; + + public EveUnitySceneProviderConnection( + IEveUnitySceneProviderSurfaceSource surfaceSource, + IEveUnitySceneCommandSink commandSink) + : this(surfaceSource, commandSink, new EveUnitySceneClientSession()) + { + } + + public EveUnitySceneProviderConnection( + IEveUnitySceneProviderSurfaceSource surfaceSource, + IEveUnitySceneCommandSink commandSink, + EveUnitySceneClientSession session) + { + _surfaceSource = surfaceSource ?? throw new ArgumentNullException(nameof(surfaceSource)); + _commandSink = commandSink ?? throw new ArgumentNullException(nameof(commandSink)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + } + + public string ProviderId => _surfaceSource.ProviderId; + + public string SurfaceId => _surfaceSource.SurfaceId; + + public string SourcePointer => _surfaceSource.SourcePointer; + + public EveUnitySceneProjection? ActiveProjection => _session.ActiveProjection; + + public long ActiveVersion => _session.ActiveVersion; + + public event Action? ProjectionUpdated; + + public EveUnitySceneProjection Connect() + { + if (!_connected) + { + _surfaceSource.SnapshotAvailable += OnSnapshotAvailable; + _surfaceSource.Connect(); + _connected = true; + } + + if (HasAppliedCurrentSnapshot()) + return _session.ActiveProjection!; + + return ApplySnapshot(_surfaceSource.CurrentSnapshot); + } + + public EveUnitySceneProjection Refresh() + { + _surfaceSource.Refresh(); + if (HasAppliedCurrentSnapshot()) + return _session.ActiveProjection!; + + return ApplySnapshot(_surfaceSource.CurrentSnapshot); + } + + public EveSurfaceCommandRequest SubmitMoveIntent( + string entityId, + float targetX, + float targetY, + float targetZ, + DateTimeOffset? issuedAt = null) + { + return Submit(_session.CreateMoveIntent(entityId, targetX, targetY, targetZ, issuedAt)); + } + + public EveSurfaceCommandRequest SubmitMoveVectorIntent( + string entityId, + float directionX, + float directionY, + float scalarValue = 1f, + DateTimeOffset? issuedAt = null) + { + return Submit(_session.CreateMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt)); + } + + public EveSurfaceCommandRequest SubmitFocusIntent( + string entityId, + DateTimeOffset? issuedAt = null) + { + return Submit(_session.CreateFocusIntent(entityId, issuedAt)); + } + + public EveSurfaceCommandRequest SubmitTargetIntent( + string sourceEntityId, + string targetEntityId, + DateTimeOffset? issuedAt = null) + { + return Submit(_session.CreateTargetIntent(sourceEntityId, targetEntityId, issuedAt)); + } + + public EveSurfaceCommandRequest SubmitActionIntent( + string entityId, + string actionId, + DateTimeOffset? issuedAt = null) + { + return Submit(_session.CreateActionIntent(entityId, actionId, issuedAt)); + } + + public void Disconnect() + { + if (!_connected) + return; + + _surfaceSource.SnapshotAvailable -= OnSnapshotAvailable; + _surfaceSource.Disconnect(); + _connected = false; + } + + public void Dispose() + { + Disconnect(); + } + + private EveSurfaceCommandRequest Submit(EveSurfaceCommandRequest request) + { + _commandSink.Submit(request); + return request; + } + + private EveUnitySceneProjection ApplySnapshot(EveUnitySceneProviderSurfaceSnapshot snapshot) + { + var projection = _session.ApplySnapshot(snapshot); + ProjectionUpdated?.Invoke(projection); + return projection; + } + + private bool HasAppliedCurrentSnapshot() + { + var projection = _session.ActiveProjection; + if (projection == null) + return false; + + var snapshot = _surfaceSource.CurrentSnapshot; + return _session.ActiveVersion == snapshot.Version && + string.Equals(_session.ActiveSourcePointer, snapshot.SourcePointer, StringComparison.Ordinal); + } + + private void OnSnapshotAvailable(EveUnitySceneProviderSurfaceSnapshot snapshot) + { + ApplySnapshot(snapshot); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs.meta new file mode 100644 index 0000000..d594d36 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9e93e937d561d4941b5080a454091f7a \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs new file mode 100644 index 0000000..6c02fef --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using GameCult.Mesh; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnitySceneSurfaceLowerer + { + private static readonly SaiVisualNovelUnitySceneProjectionAdapter SaiVisualNovelAdapter = new SaiVisualNovelUnitySceneProjectionAdapter(); + private static readonly NornGraphUnitySceneProjectionAdapter NornGraphAdapter = new NornGraphUnitySceneProjectionAdapter(); + private static readonly TeXMathUnitySceneProjectionAdapter TeXMathAdapter = new TeXMathUnitySceneProjectionAdapter(); + + public EveUnitySceneProjection Lower( + EveSurfaceDocument document, + EveUnitySceneProviderSurfaceAdvertisement advertisedSurface) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (advertisedSurface == null) throw new ArgumentNullException(nameof(advertisedSurface)); + if (!string.Equals(document.Surface.Id, advertisedSurface.SurfaceId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Active surface '{document.Surface.Id}' does not match advertised Unity scene surface '{advertisedSurface.SurfaceId}'."); + } + + return new EveUnitySceneProjection( + document.ProviderId, + document.Surface.Id, + advertisedSurface.WorldInteraction.ProjectionKind, + advertisedSurface.WorldInteraction.CommandBoundary, + advertisedSurface.WorldInteraction.ReceiptSchema, + advertisedSurface.WorldInteraction.Ownership, + BuildPlayableWorld(document.Surface.Root), + BuildSceneGraph(document.Surface.Root)); + } + + public EveSurfaceCommandRequest CreateCommandIntent( + EveSurfaceDocument document, + EveUnitySceneProviderSurfaceAdvertisement advertisedSurface, + string command, + IReadOnlyDictionary? payload = null, + DateTimeOffset? issuedAt = null) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (advertisedSurface == null) throw new ArgumentNullException(nameof(advertisedSurface)); + if (string.IsNullOrWhiteSpace(command)) throw new ArgumentException("Command is required.", nameof(command)); + + var projection = Lower(document, advertisedSurface); + return new EveSurfaceCommandRequest( + projection.ProviderId, + projection.SurfaceId, + ResolveOperation(document, command), + CultMesh.OperationPayload(payload ?? new Dictionary(StringComparer.Ordinal)), + issuedAt ?? DateTimeOffset.UtcNow, + "unity-scene", + projection.CommandBoundary, + projection.ReceiptSchema); + } + + private static CultMeshOperationInvocationDescriptor ResolveOperation( + EveSurfaceDocument document, + string command) + { + foreach (var template in document.Commands) + { + if (string.Equals(template.Command, command, StringComparison.Ordinal)) + return CultMesh.OperationInvocation(template.Operation); + } + + return CultMesh.OperationInvocation(command); + } + + private static EveUnitySceneNode BuildSceneGraph(EveSurfaceComponent component) + { + var children = new List(component.Children.Count); + foreach (var child in component.Children) + { + children.Add(BuildSceneGraph(child)); + } + + return new EveUnitySceneNode( + component.Id, + component.Kind, + SceneObjectKind(component.Kind), + component.Props, + component.Layout, + component.Style, + component.StateBindings.Count, + component.EmbeddedDocuments.Count, + BuildEmbeddedDocumentSlots(component.EmbeddedDocuments), + BuildPluginProjection(component), + children); + } + + private static EveUnityPlayableWorldProjection? BuildPlayableWorld(EveSurfaceComponent root) + { + var worldRoot = FindFirst(root, component => string.Equals(component.Kind, "world.scene3d", StringComparison.Ordinal)); + if (worldRoot == null) + return null; + + var entities = new List(); + foreach (var component in Flatten(worldRoot)) + { + if (string.Equals(component.Kind, "world.entity3d", StringComparison.Ordinal)) + entities.Add(BuildPlayableEntity(component)); + } + + return new EveUnityPlayableWorldProjection( + worldRoot.Id, + worldRoot.GetProp("statePointerId", worldRoot.GetProp("worldStatePointerId")), + worldRoot.GetProp("assetManifest", worldRoot.GetProp("assetManifestUri")), + worldRoot.GetProp("inputProfile"), + worldRoot.GetProp("cameraRig"), + worldRoot.GetProp("playerEntityId"), + worldRoot.GetProp("movementCommand"), + worldRoot.GetProp("focusCommand"), + worldRoot.GetProp("targetCommand"), + worldRoot.GetProp("actionCommand"), + entities); + } + + private static EveUnityPlayableWorldEntity BuildPlayableEntity(EveSurfaceComponent component) + { + var position = ParseVector3(component.GetProp("position")); + return new EveUnityPlayableWorldEntity( + component.Id, + component.GetProp("entityId", component.Id), + component.GetProp("entityKind", component.GetProp("kind")), + component.GetProp("label", component.GetProp("name")), + component.GetProp("faction"), + component.GetProp("assetRef", component.GetProp("meshRef", component.GetProp("prefabRef"))), + position.x, + position.y, + position.z, + ParseFloat(component.GetProp("rotationY", component.GetProp("yaw")), 0f), + ParseFloat(component.GetProp("radius"), 0f), + ParseBoolean(component.GetProp("selectable")), + ParseBoolean(component.GetProp("controllable")), + component.GetProp("focusCommand"), + component.GetProp("moveCommand"), + component.GetProp("targetCommand"), + component.GetProp("actionCommand")); + } + + private static EveSurfaceComponent? FindFirst( + EveSurfaceComponent component, + Predicate predicate) + { + if (predicate(component)) + return component; + foreach (var child in component.Children) + { + var found = FindFirst(child, predicate); + if (found != null) + return found; + } + return null; + } + + private static IEnumerable Flatten(EveSurfaceComponent component) + { + yield return component; + foreach (var child in component.Children) + { + foreach (var nested in Flatten(child)) + yield return nested; + } + } + + private static (float x, float y, float z) ParseVector3(string value) + { + var parts = (value ?? "").Split(','); + return ( + parts.Length > 0 ? ParseFloat(parts[0], 0f) : 0f, + parts.Length > 1 ? ParseFloat(parts[1], 0f) : 0f, + parts.Length > 2 ? ParseFloat(parts[2], 0f) : 0f); + } + + private static float ParseFloat(string value, float fallback) + { + return float.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsed) + ? parsed + : fallback; + } + + private static bool ParseBoolean(string value) + { + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "1", StringComparison.Ordinal); + } + + private static EveUnityScenePluginProjection? BuildPluginProjection(EveSurfaceComponent component) + { + if (SaiVisualNovelAdapter.CanProject(component)) + return SaiVisualNovelAdapter.Project(component); + if (NornGraphAdapter.CanProject(component)) + return NornGraphAdapter.Project(component); + if (TeXMathAdapter.CanProject(component)) + return TeXMathAdapter.Project(component); + return null; + } + + private static IReadOnlyList BuildEmbeddedDocumentSlots( + IReadOnlyList embeddedDocuments) + { + var slots = new List(embeddedDocuments.Count); + foreach (var slot in embeddedDocuments) + { + slots.Add(new EveUnitySceneEmbeddedDocumentSlot( + slot.SlotId, + slot.DocumentId, + slot.SchemaId, + slot.PresentationKind)); + } + return slots; + } + + private static string SceneObjectKind(string componentKind) + { + if (string.IsNullOrWhiteSpace(componentKind)) + return "empty"; + if (string.Equals(componentKind, "world.scene3d", StringComparison.Ordinal)) + return "playable-world-root"; + if (string.Equals(componentKind, "world.entity3d", StringComparison.Ordinal)) + return "playable-world-entity"; + if (string.Equals(componentKind, "field.vector3d", StringComparison.Ordinal) || + string.Equals(componentKind, "field.scalar3d", StringComparison.Ordinal)) + return "world-field-3d"; + if (string.Equals(componentKind, "vn.stage", StringComparison.Ordinal)) + return "sai-vn-scene-stage"; + if (string.Equals(componentKind, "panel.dialogue", StringComparison.Ordinal) || + string.Equals(componentKind, "text.dialogue", StringComparison.Ordinal)) + return "sai-vn-scene-dialogue"; + if (string.Equals(componentKind, "rail.actions", StringComparison.Ordinal)) + return "sai-vn-scene-action-rail"; + if (componentKind.StartsWith("control.", StringComparison.Ordinal)) + return "command-control"; + if (string.Equals(componentKind, "embed.norn", StringComparison.Ordinal)) + return "norn-graph-scene-projection"; + if (string.Equals(componentKind, "embed.tex", StringComparison.Ordinal)) + return "tex-math-scene-projection"; + if (componentKind.StartsWith("embed.", StringComparison.Ordinal)) + return "plugin-placeholder"; + if (string.Equals(componentKind, "surface.slot", StringComparison.Ordinal)) + return "embedded-surface-slot"; + if (componentKind.StartsWith("world.", StringComparison.Ordinal) || + componentKind.StartsWith("field.", StringComparison.Ordinal)) + return "world-projection-node"; + if (componentKind.StartsWith("text.", StringComparison.Ordinal) || string.Equals(componentKind, "label", StringComparison.Ordinal)) + return "scene-label"; + return "scene-node"; + } + } + + public sealed class EveUnitySceneProjection + { + public EveUnitySceneProjection( + string providerId, + string surfaceId, + string projectionKind, + string commandBoundary, + string receiptSchema, + string ownership, + EveUnityPlayableWorldProjection? playableWorld, + EveUnitySceneNode root) + { + ProviderId = providerId ?? ""; + SurfaceId = surfaceId ?? ""; + ProjectionKind = projectionKind ?? ""; + CommandBoundary = commandBoundary ?? ""; + ReceiptSchema = receiptSchema ?? ""; + Ownership = ownership ?? ""; + PlayableWorld = playableWorld; + Root = root ?? throw new ArgumentNullException(nameof(root)); + } + + public string ProviderId { get; } + + public string SurfaceId { get; } + + public string ProjectionKind { get; } + + public string CommandBoundary { get; } + + public string ReceiptSchema { get; } + + public string Ownership { get; } + + public EveUnityPlayableWorldProjection? PlayableWorld { get; } + + public EveUnitySceneNode Root { get; } + } + + public sealed class EveUnityPlayableWorldProjection + { + public EveUnityPlayableWorldProjection( + string worldRootId, + string statePointerId, + string assetManifest, + string inputProfile, + string cameraRig, + string playerEntityId, + string movementCommand, + string focusCommand, + string targetCommand, + string actionCommand, + IReadOnlyList entities) + { + WorldRootId = worldRootId ?? ""; + StatePointerId = statePointerId ?? ""; + AssetManifest = assetManifest ?? ""; + InputProfile = inputProfile ?? ""; + CameraRig = cameraRig ?? ""; + PlayerEntityId = playerEntityId ?? ""; + MovementCommand = movementCommand ?? ""; + FocusCommand = focusCommand ?? ""; + TargetCommand = targetCommand ?? ""; + ActionCommand = actionCommand ?? ""; + Entities = entities ?? Array.Empty(); + } + + public string WorldRootId { get; } + + public string StatePointerId { get; } + + public string AssetManifest { get; } + + public string InputProfile { get; } + + public string CameraRig { get; } + + public string PlayerEntityId { get; } + + public string MovementCommand { get; } + + public string FocusCommand { get; } + + public string TargetCommand { get; } + + public string ActionCommand { get; } + + public int EntityCount => Entities.Count; + + public IReadOnlyList Entities { get; } + } + + public sealed class EveUnityPlayableWorldEntity + { + public EveUnityPlayableWorldEntity( + string nodeId, + string entityId, + string entityKind, + string label, + string faction, + string assetRef, + float positionX, + float positionY, + float positionZ, + float rotationY, + float radius, + bool selectable, + bool controllable, + string focusCommand, + string moveCommand, + string targetCommand, + string actionCommand) + { + NodeId = nodeId ?? ""; + EntityId = entityId ?? ""; + EntityKind = entityKind ?? ""; + Label = label ?? ""; + Faction = faction ?? ""; + AssetRef = assetRef ?? ""; + PositionX = positionX; + PositionY = positionY; + PositionZ = positionZ; + RotationY = rotationY; + Radius = radius; + Selectable = selectable; + Controllable = controllable; + FocusCommand = focusCommand ?? ""; + MoveCommand = moveCommand ?? ""; + TargetCommand = targetCommand ?? ""; + ActionCommand = actionCommand ?? ""; + } + + public string NodeId { get; } + + public string EntityId { get; } + + public string EntityKind { get; } + + public string Label { get; } + + public string Faction { get; } + + public string AssetRef { get; } + + public float PositionX { get; } + + public float PositionY { get; } + + public float PositionZ { get; } + + public float RotationY { get; } + + public float Radius { get; } + + public bool Selectable { get; } + + public bool Controllable { get; } + + public string FocusCommand { get; } + + public string MoveCommand { get; } + + public string TargetCommand { get; } + + public string ActionCommand { get; } + } + + public sealed class EveUnitySceneNode + { + public EveUnitySceneNode( + string id, + string componentKind, + string sceneObjectKind, + IReadOnlyDictionary props, + IReadOnlyDictionary layout, + IReadOnlyDictionary style, + int stateBindingCount, + int embeddedDocumentCount, + IReadOnlyList embeddedDocuments, + EveUnityScenePluginProjection? pluginProjection, + IReadOnlyList children) + { + Id = id ?? ""; + ComponentKind = componentKind ?? ""; + SceneObjectKind = sceneObjectKind ?? ""; + Props = props ?? new Dictionary(StringComparer.Ordinal); + Layout = layout ?? new Dictionary(StringComparer.Ordinal); + Style = style ?? new Dictionary(StringComparer.Ordinal); + StateBindingCount = stateBindingCount; + EmbeddedDocumentCount = embeddedDocumentCount; + EmbeddedDocuments = embeddedDocuments ?? Array.Empty(); + PluginProjection = pluginProjection; + Children = children ?? Array.Empty(); + } + + public string Id { get; } + + public string ComponentKind { get; } + + public string SceneObjectKind { get; } + + public IReadOnlyDictionary Props { get; } + + public IReadOnlyDictionary Layout { get; } + + public IReadOnlyDictionary Style { get; } + + public int StateBindingCount { get; } + + public int EmbeddedDocumentCount { get; } + + public IReadOnlyList EmbeddedDocuments { get; } + + public EveUnityScenePluginProjection? PluginProjection { get; } + + public IReadOnlyList Children { get; } + } + + public sealed class EveUnityScenePluginProjection + { + public EveUnityScenePluginProjection( + string pluginId, + string projectionKind, + string abiSchema, + string commandBoundary, + IReadOnlyList capabilities, + string command, + string documentId, + string semanticOwner) + { + PluginId = pluginId ?? ""; + ProjectionKind = projectionKind ?? ""; + AbiSchema = abiSchema ?? ""; + CommandBoundary = commandBoundary ?? ""; + Capabilities = capabilities ?? Array.Empty(); + Command = command ?? ""; + DocumentId = documentId ?? ""; + SemanticOwner = semanticOwner ?? ""; + } + + public string PluginId { get; } + + public string ProjectionKind { get; } + + public string AbiSchema { get; } + + public string CommandBoundary { get; } + + public IReadOnlyList Capabilities { get; } + + public string Command { get; } + + public string DocumentId { get; } + + public string SemanticOwner { get; } + } + + public sealed class EveUnitySceneEmbeddedDocumentSlot + { + public EveUnitySceneEmbeddedDocumentSlot( + string slotId, + string documentId, + string schemaId, + string presentationKind) + { + SlotId = slotId ?? ""; + DocumentId = documentId ?? ""; + SchemaId = schemaId ?? ""; + PresentationKind = presentationKind ?? ""; + } + + public string SlotId { get; } + + public string DocumentId { get; } + + public string SchemaId { get; } + + public string PresentationKind { get; } + } + + public sealed class EveUnitySceneProviderSurfaceAdvertisement + { + public EveUnitySceneProviderSurfaceAdvertisement( + string surfaceId, + string surfaceKind, + EveUnitySceneWorldInteraction worldInteraction) + { + SurfaceId = surfaceId ?? ""; + SurfaceKind = surfaceKind ?? ""; + WorldInteraction = worldInteraction ?? throw new ArgumentNullException(nameof(worldInteraction)); + } + + public string SurfaceId { get; } + + public string SurfaceKind { get; } + + public EveUnitySceneWorldInteraction WorldInteraction { get; } + } + + public sealed class EveUnitySceneWorldInteraction + { + public EveUnitySceneWorldInteraction( + string projectionKind, + string commandBoundary, + string receiptSchema, + string ownership) + { + ProjectionKind = projectionKind ?? ""; + CommandBoundary = commandBoundary ?? ""; + ReceiptSchema = receiptSchema ?? ""; + Ownership = ownership ?? ""; + } + + public string ProjectionKind { get; } + + public string CommandBoundary { get; } + + public string ReceiptSchema { get; } + + public string Ownership { get; } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs.meta new file mode 100644 index 0000000..0b26f1f --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 32422132e752b9a4f8a45b2a5cb01217 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef b/packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef new file mode 100644 index 0000000..395e095 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef @@ -0,0 +1,16 @@ +{ + "name": "GameCult.Eve.UnityScene", + "rootNamespace": "GameCult.Eve.UnityScene", + "references": [ + "GameCult.Eve.Surface" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef.meta b/packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef.meta new file mode 100644 index 0000000..dcd39b3 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a5b5eeaa1ac2b24409cf4718616ebbe3 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs new file mode 100644 index 0000000..4c6f4c8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class NornGraphUnitySceneProjectionAdapter + { + public string PluginId => "norn.graph"; + + public IReadOnlyList Capabilities { get; } = new[] + { + "embed.norn" + }; + + public bool CanProject(EveSurfaceComponent component) + { + return component != null && string.Equals(component.Kind, "embed.norn", StringComparison.Ordinal); + } + + public EveUnityScenePluginProjection Project(EveSurfaceComponent component) + { + if (component == null) throw new ArgumentNullException(nameof(component)); + if (!CanProject(component)) + { + throw new InvalidOperationException( + $"Norn scene projection adapter cannot project component kind '{component.Kind}'."); + } + + return new EveUnityScenePluginProjection( + PluginId, + "norn-scene-embedded-graph-shell", + "gamecult.eve.plugin_abi.v1", + "sidecar-advertised-plugin-abi", + Capabilities, + component.GetProp( + "interaction.nodeAction", + component.GetProp("focusCommand", component.GetProp("command", "graph.focus"))), + component.GetProp("graph.document", component.GetProp("documentId", "")), + "Norn"); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs.meta new file mode 100644 index 0000000..15f16c2 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bbe1659cc5c50ff4e9ab6f2ef0ed82c3 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs new file mode 100644 index 0000000..4d4d654 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class SaiVisualNovelUnitySceneProjectionAdapter + { + public string PluginId => "sai.vn"; + + public IReadOnlyList Capabilities { get; } = new[] + { + "vn.stage", + "story.choose", + "story.continue", + "story.jump" + }; + + public bool CanProject(EveSurfaceComponent component) + { + if (component == null) + return false; + + switch (component.Kind) + { + case "vn.stage": + case "panel.dialogue": + case "text.dialogue": + case "rail.actions": + return true; + case "control.button": + return StoryCommand(component).StartsWith("story.", StringComparison.Ordinal); + default: + return false; + } + } + + public EveUnityScenePluginProjection Project(EveSurfaceComponent component) + { + if (component == null) throw new ArgumentNullException(nameof(component)); + if (!CanProject(component)) + { + throw new InvalidOperationException( + $"Sai scene projection adapter cannot project component kind '{component.Kind}'."); + } + + return new EveUnityScenePluginProjection( + PluginId, + ProjectionKind(component), + "gamecult.eve.plugin_abi.v1", + "sidecar-advertised-plugin-abi", + Capabilities, + StoryCommand(component), + component.GetProp("storyId", component.GetProp("currentPath", "")), + "Sai"); + } + + private static string ProjectionKind(EveSurfaceComponent component) + { + switch (component.Kind) + { + case "vn.stage": + return "sai-vn-scene-stage-shell"; + case "panel.dialogue": + case "text.dialogue": + return "sai-vn-scene-dialogue-shell"; + case "rail.actions": + return "sai-vn-scene-action-rail-shell"; + case "control.button": + return "sai-vn-scene-story-command-shell"; + default: + return "sai-vn-scene-projection-shell"; + } + } + + private static string StoryCommand(EveSurfaceComponent component) + { + return component.GetProp( + "command", + component.GetProp( + "action.command", + component.GetProp("action", component.GetProp("type", "")))); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs.meta new file mode 100644 index 0000000..989da6e --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc79e8d0b375f3d41ad7809e2129ca6c \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs new file mode 100644 index 0000000..c1632b4 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class TeXMathUnitySceneProjectionAdapter + { + public string PluginId => "tex.math"; + + public IReadOnlyList Capabilities { get; } = new[] + { + "embed.tex", + "tex.inline", + "tex.block", + "tex.scene-placement" + }; + + public bool CanProject(EveSurfaceComponent component) + { + return component != null && string.Equals(component.Kind, "embed.tex", StringComparison.Ordinal); + } + + public EveUnityScenePluginProjection Project(EveSurfaceComponent component) + { + if (component == null) throw new ArgumentNullException(nameof(component)); + if (!CanProject(component)) + { + throw new InvalidOperationException( + $"TeX scene projection adapter cannot project component kind '{component.Kind}'."); + } + + return new EveUnityScenePluginProjection( + PluginId, + ProjectionKind(component), + "gamecult.eve.plugin_abi.v1", + "sidecar-advertised-plugin-abi", + Capabilities, + "", + component.GetProp("sourceUri", component.GetProp("source", "")), + "EvePlugins"); + } + + private static string ProjectionKind(EveSurfaceComponent component) + { + var display = component.GetProp("display", "inline"); + if (string.Equals(display, "block", StringComparison.Ordinal)) + return "tex-math-scene-block-fallback-shell"; + if (string.Equals(display, "page", StringComparison.Ordinal)) + return "tex-math-scene-page-fallback-shell"; + return "tex-math-scene-inline-fallback-shell"; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs.meta new file mode 100644 index 0000000..dd5310c --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0184a5155baa9774f9ed133ad871b735 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Tests.meta b/packages/org.gamecult.eve.unity-scene/Tests.meta new file mode 100644 index 0000000..cffd388 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b37c64ab7abc96a468018e86c041d431 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor.meta new file mode 100644 index 0000000..532aea7 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ac7339c6ff81f6f48864df2308ebeed2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs new file mode 100644 index 0000000..65b01d3 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -0,0 +1,1681 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using GameCult.Mesh; +using NUnit.Framework; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnitySceneSurfaceLowererTests + { + [Test] + public void LowerCarriesProviderAdvertisedWorldBoundary() + { + var lowerer = new EveUnitySceneSurfaceLowerer(); + var projection = lowerer.Lower(Document("aetheria.daemon.game"), Advertisement("aetheria.daemon.game")); + + Assert.That(projection.ProviderId, Is.EqualTo("aetheria")); + Assert.That(projection.SurfaceId, Is.EqualTo("aetheria.daemon.game")); + Assert.That(projection.ProjectionKind, Is.EqualTo("provider-authored-world-surface")); + Assert.That(projection.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(projection.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1")); + Assert.That(projection.Ownership, Is.EqualTo("provider-owns-world-state-assets-command-acceptance-and-receipts")); + Assert.That(projection.Root.Id, Is.EqualTo("aetheria.daemon.game.root")); + Assert.That(projection.Root.SceneObjectKind, Is.EqualTo("scene-node")); + } + + [Test] + public void LowerBuildsProviderAgnosticSceneGraphFromSurfaceTree() + { + var lowerer = new EveUnitySceneSurfaceLowerer(); + var projection = lowerer.Lower(Document("aetheria.daemon.game"), Advertisement("aetheria.daemon.game")); + + Assert.That(projection.Root.Children.Count, Is.EqualTo(3)); + Assert.That(projection.Root.Children[0].SceneObjectKind, Is.EqualTo("world-projection-node")); + Assert.That(projection.Root.Children[0].Props["binding"], Is.EqualTo("cultmesh://aetheria/world/entities")); + Assert.That(projection.Root.Children[0].StateBindingCount, Is.EqualTo(1)); + Assert.That(projection.Root.Children[1].SceneObjectKind, Is.EqualTo("command-control")); + Assert.That(projection.Root.Children[1].Props["command"], Is.EqualTo("aetheria.daemon.focus")); + var nornNode = projection.Root.Children[2]; + Assert.That(nornNode.SceneObjectKind, Is.EqualTo("norn-graph-scene-projection")); + Assert.That(nornNode.EmbeddedDocumentCount, Is.EqualTo(1)); + Assert.That(nornNode.EmbeddedDocuments.Count, Is.EqualTo(1)); + Assert.That(nornNode.EmbeddedDocuments[0].SlotId, Is.EqualTo("norn.map")); + Assert.That(nornNode.EmbeddedDocuments[0].DocumentId, Is.EqualTo("cultmesh://aetheria/norn/map")); + Assert.That(nornNode.EmbeddedDocuments[0].SchemaId, Is.EqualTo("gamecult.eve.surface.v1")); + Assert.That(nornNode.EmbeddedDocuments[0].PresentationKind, Is.EqualTo("scene-overlay")); + Assert.That(nornNode.PluginProjection, Is.Not.Null); + Assert.That(nornNode.PluginProjection!.PluginId, Is.EqualTo("norn.graph")); + Assert.That(nornNode.PluginProjection.ProjectionKind, Is.EqualTo("norn-scene-embedded-graph-shell")); + Assert.That(nornNode.PluginProjection.AbiSchema, Is.EqualTo("gamecult.eve.plugin_abi.v1")); + Assert.That(nornNode.PluginProjection.CommandBoundary, Is.EqualTo("sidecar-advertised-plugin-abi")); + Assert.That(nornNode.PluginProjection.Capabilities, Does.Contain("embed.norn")); + Assert.That(nornNode.PluginProjection.Command, Is.EqualTo("graph.focus")); + Assert.That(nornNode.PluginProjection.DocumentId, Is.EqualTo("cultmesh://aetheria/norn/map")); + Assert.That(nornNode.PluginProjection.SemanticOwner, Is.EqualTo("Norn")); + } + + [Test] + public void LowerExtractsPlayableArpgWorldFromGenericScene3dSurface() + { + var lowerer = new EveUnitySceneSurfaceLowerer(); + var projection = lowerer.Lower(PlayableArpgDocument(), Advertisement("aetheria.daemon.game")); + + Assert.That(projection.Root.Children[0].SceneObjectKind, Is.EqualTo("playable-world-root")); + Assert.That(projection.Root.Children[0].Children[0].SceneObjectKind, Is.EqualTo("playable-world-entity")); + Assert.That(projection.Root.Children[0].Children[3].SceneObjectKind, Is.EqualTo("world-field-3d")); + Assert.That(projection.PlayableWorld, Is.Not.Null); + Assert.That(projection.PlayableWorld!.WorldRootId, Is.EqualTo("aetheria.daemon.game.playable")); + Assert.That(projection.PlayableWorld.StatePointerId, Is.EqualTo("cultmesh://aetheria/run/current")); + Assert.That(projection.PlayableWorld.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + Assert.That(projection.PlayableWorld.InputProfile, Is.EqualTo("arpg-third-person")); + Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("third-person-orbit")); + Assert.That(projection.PlayableWorld.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(projection.PlayableWorld.MovementCommand, Is.EqualTo("aetheria.daemon.move_intent")); + Assert.That(projection.PlayableWorld.FocusCommand, Is.EqualTo("aetheria.daemon.focus")); + Assert.That(projection.PlayableWorld.TargetCommand, Is.EqualTo("aetheria.daemon.target")); + Assert.That(projection.PlayableWorld.EntityCount, Is.EqualTo(3)); + + var player = FindEntity(projection.PlayableWorld, "player-vanguard"); + Assert.That(player.EntityKind, Is.EqualTo("player")); + Assert.That(player.AssetRef, Is.EqualTo("cultmesh://aetheria/assets/map/entity/player")); + Assert.That(player.PositionX, Is.EqualTo(0f)); + Assert.That(player.PositionY, Is.EqualTo(0f)); + Assert.That(player.PositionZ, Is.EqualTo(0f)); + Assert.That(player.Controllable, Is.True); + Assert.That(player.MoveCommand, Is.EqualTo("aetheria.daemon.move_intent")); + + var raider = FindEntity(projection.PlayableWorld, "raider-scout"); + Assert.That(raider.EntityKind, Is.EqualTo("enemy")); + Assert.That(raider.PositionX, Is.EqualTo(9f)); + Assert.That(raider.PositionZ, Is.EqualTo(14f)); + Assert.That(raider.TargetCommand, Is.EqualTo("aetheria.daemon.target")); + } + + [Test] + public void GenericClientSessionConsumesAetheriaPlayableWorldSnapshotWithoutAetheriaTypes() + { + var session = new EveUnitySceneClientSession(); + var projection = session.Connect(new EveUnitySceneProviderSurfaceSnapshot( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 42)); + + Assert.That(session.ActiveSourcePointer, Is.EqualTo("cultmesh://aetheria/eve/surfaces/aetheria.daemon.game")); + Assert.That(session.ActiveProjection, Is.SameAs(projection)); + Assert.That(projection.ProviderId, Is.EqualTo("aetheria")); + Assert.That(projection.PlayableWorld, Is.Not.Null); + Assert.That(projection.PlayableWorld!.InputProfile, Is.EqualTo("arpg-third-person")); + Assert.That(projection.PlayableWorld.EntityCount, Is.EqualTo(3)); + + var moveIntent = session.CreateMoveIntent( + "player-vanguard", + 12f, + 0f, + 8f, + DateTimeOffset.Parse("2026-07-09T00:00:00Z")); + + Assert.That(moveIntent.Schema, Is.EqualTo(EveSurfaceCommandRequest.SchemaId)); + Assert.That(moveIntent.ProviderId, Is.EqualTo("aetheria")); + Assert.That(moveIntent.SurfaceId, Is.EqualTo("aetheria.daemon.game")); + Assert.That(moveIntent.ClientId, Is.EqualTo("unity-scene")); + Assert.That(moveIntent.Command, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveIntent.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1")); + + var focusIntent = session.CreateFocusIntent("anchor-station"); + Assert.That(focusIntent.Command, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(focusIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + + var moveVectorIntent = session.CreateMoveVectorIntent( + "player-vanguard", + 0.25f, + 0.75f, + 0.8f, + DateTimeOffset.Parse("2026-07-09T00:00:01Z")); + + Assert.That(moveVectorIntent.ProviderId, Is.EqualTo("aetheria")); + Assert.That(moveVectorIntent.SurfaceId, Is.EqualTo("aetheria.daemon.game")); + Assert.That(moveVectorIntent.Command, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveVectorIntent.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.move_intent")); + Assert.That(moveVectorIntent.Payload.GetString("entityId"), Is.EqualTo("player-vanguard")); + Assert.That(moveVectorIntent.Payload.GetString("directionX"), Is.EqualTo("0.25")); + Assert.That(moveVectorIntent.Payload.GetString("directionY"), Is.EqualTo("0.75")); + Assert.That(moveVectorIntent.Payload.GetString("scalarValue"), Is.EqualTo("0.8")); + } + + [Test] + public void GenericProviderConnectionAppliesLiveSnapshotsAndSubmitsCommandsThroughSink() + { + var source = new FakeProviderSurfaceSource( + "aetheria", + "aetheria.daemon.game", + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + new EveUnitySceneProviderSurfaceSnapshot( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1)); + var sink = new FakeCommandSink("cultmesh-command-sink"); + using var connection = new EveUnitySceneProviderConnection(source, sink); + + var projectionUpdates = 0; + connection.ProjectionUpdated += _ => projectionUpdates++; + + var initialProjection = connection.Connect(); + + Assert.That(initialProjection.ProviderId, Is.EqualTo("aetheria")); + Assert.That(connection.ProviderId, Is.EqualTo("aetheria")); + Assert.That(connection.SurfaceId, Is.EqualTo("aetheria.daemon.game")); + Assert.That(connection.SourcePointer, Is.EqualTo("cultmesh://aetheria/eve/surfaces/aetheria.daemon.game")); + Assert.That(connection.ActiveVersion, Is.EqualTo(1)); + Assert.That(projectionUpdates, Is.EqualTo(1)); + + source.Publish(new EveUnitySceneProviderSurfaceSnapshot( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 2)); + + Assert.That(connection.ActiveVersion, Is.EqualTo(2)); + Assert.That(connection.ActiveProjection, Is.Not.Null); + Assert.That(connection.ActiveProjection!.PlayableWorld, Is.Not.Null); + Assert.That(connection.ActiveProjection.PlayableWorld!.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(projectionUpdates, Is.EqualTo(2)); + + var moveIntent = connection.SubmitMoveIntent( + "player-vanguard", + 20f, + 0f, + 15f, + DateTimeOffset.Parse("2026-07-09T00:00:00Z")); + + Assert.That(sink.SinkKind, Is.EqualTo("cultmesh-command-sink")); + Assert.That(sink.Submitted.Count, Is.EqualTo(1)); + Assert.That(sink.Submitted[0], Is.SameAs(moveIntent)); + Assert.That(moveIntent.Schema, Is.EqualTo(EveSurfaceCommandRequest.SchemaId)); + Assert.That(moveIntent.ProviderId, Is.EqualTo("aetheria")); + Assert.That(moveIntent.SurfaceId, Is.EqualTo("aetheria.daemon.game")); + Assert.That(moveIntent.Command, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveIntent.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1")); + + connection.Disconnect(); + source.Publish(new EveUnitySceneProviderSurfaceSnapshot( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 3)); + + Assert.That(connection.ActiveVersion, Is.EqualTo(2)); + Assert.That(projectionUpdates, Is.EqualTo(2)); + } + + [Test] + public void ProviderSurfaceDocumentSourceFeedsPlayableClientWithoutAetheriaTypes() + { + var documentSource = new FakeProviderSurfaceDocumentSource(new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1)); + using var surfaceSource = new EveUnitySceneProviderSurfaceDocumentSource(documentSource); + var commandSink = new FakeCommandSink("cultmesh-command-sink"); + var sceneSink = new FakePlayableWorldSceneSink(); + using var connection = new EveUnitySceneProviderConnection(surfaceSource, commandSink); + using var client = new EveUnityPlayableWorldLiveClient( + connection, + new EveUnityPlayableWorldPresenter(sceneSink, new EveUnityAssetRefResolver())); + + var initialPresentation = client.Connect(); + + Assert.That(surfaceSource.ProviderId, Is.EqualTo("aetheria")); + Assert.That(surfaceSource.SurfaceId, Is.EqualTo("aetheria.daemon.game")); + Assert.That(surfaceSource.SourcePointer, Is.EqualTo("cultmesh://aetheria/eve/surfaces/aetheria.daemon.game")); + Assert.That(initialPresentation.ActiveEntities, Is.EqualTo(3)); + Assert.That(client.ActiveWorld, Is.Not.Null); + Assert.That(client.ActiveWorld!.StatePointerId, Is.EqualTo("cultmesh://aetheria/run/current")); + Assert.That(client.ActiveWorld.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + + documentSource.Publish(new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(includeRaider: false, playerPosition: "11,0,5"), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 2)); + + Assert.That(client.ActiveVersion, Is.EqualTo(2)); + Assert.That(client.LastPresentation, Is.Not.Null); + Assert.That(client.LastPresentation!.ActiveEntities, Is.EqualTo(2)); + Assert.That(sceneSink.RemovedEntityIds, Does.Contain("raider-scout")); + Assert.That(sceneSink.Upserts[3].entity.EntityId, Is.EqualTo("player-vanguard")); + Assert.That(sceneSink.Upserts[3].entity.PositionX, Is.EqualTo(11f)); + Assert.That(sceneSink.Upserts[3].entity.PositionZ, Is.EqualTo(5f)); + + var moveIntent = client.SubmitMoveIntent("player-vanguard", 12f, 0f, 8f); + + Assert.That(commandSink.Submitted.Count, Is.EqualTo(1)); + Assert.That(commandSink.Submitted[0], Is.SameAs(moveIntent)); + Assert.That(moveIntent.ProviderId, Is.EqualTo("aetheria")); + Assert.That(moveIntent.SurfaceId, Is.EqualTo("aetheria.daemon.game")); + Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveIntent.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1")); + } + + [Test] + public void PlayableWorldRuntimeComposesProviderDocumentsAssetsReceiptsAndSceneSink() + { + var surfaceDocuments = new FakeProviderSurfaceDocumentSource(new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1)); + var assetDocuments = new FakeAssetManifestDocumentSource(new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + new[] + { + new EveUnityPlayableWorldAssetManifestDocumentEntry( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "Resources/Aetheria/Entities/Vanguard.prefab", + "aetheria.vanguard"), + new EveUnityPlayableWorldAssetManifestDocumentEntry( + "cultmesh://aetheria/assets/map/entity/ship", + "enemy", + "Resources/Aetheria/Entities/Raider.prefab", + "aetheria.raider") + }, + "aetheria")); + var commandSink = new FakeCommandSink("cultmesh-command-sink"); + var receiptSource = new FakeCommandReceiptSource(); + var sceneSink = new FakePlayableWorldSceneSink(); + + using var runtime = new EveUnityPlayableWorldRuntime( + surfaceDocuments, + commandSink, + sceneSink, + assetDocuments, + receiptSource); + + var initialPresentation = runtime.Connect(); + + Assert.That(initialPresentation.ActiveEntities, Is.EqualTo(3)); + Assert.That(runtime.ActiveWorld, Is.Not.Null); + Assert.That(runtime.ActiveWorld!.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + Assert.That(runtime.AssetManifests.GetForWorld(runtime.ActiveWorld), Is.Not.Null); + Assert.That(sceneSink.Upserts.Count, Is.EqualTo(3)); + + var moveIntent = runtime.SubmitMoveIntent( + "player-vanguard", + 14f, + 0f, + 9f, + DateTimeOffset.Parse("2026-07-09T00:00:00Z")); + + Assert.That(commandSink.Submitted.Count, Is.EqualTo(1)); + Assert.That(commandSink.Submitted[0], Is.SameAs(moveIntent)); + Assert.That(moveIntent.ProviderId, Is.EqualTo("aetheria")); + Assert.That(moveIntent.Command, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveIntent.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1")); + + surfaceDocuments.Publish(new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(includeRaider: false, playerPosition: "14,0,9"), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 2)); + + Assert.That(runtime.ActiveVersion, Is.EqualTo(2)); + Assert.That(runtime.LastPresentation, Is.Not.Null); + Assert.That(runtime.LastPresentation!.ActiveEntities, Is.EqualTo(2)); + Assert.That(sceneSink.RemovedEntityIds, Does.Contain("raider-scout")); + Assert.That(sceneSink.Upserts[3].entity.EntityId, Is.EqualTo("player-vanguard")); + Assert.That(sceneSink.Upserts[3].entity.PositionX, Is.EqualTo(14f)); + Assert.That(sceneSink.Upserts[3].entity.PositionZ, Is.EqualTo(9f)); + + receiptSource.Publish(new EveUnitySceneCommandReceipt( + "aetheria.daemon.move_intent.accepted", + "aetheria.daemon.move_intent", + "aetheria.daemon.move_intent", + "accepted", + "Aetheria", + "provider-owned-daemon")); + + Assert.That(runtime.LastReceipt, Is.Not.Null); + Assert.That(runtime.LastReceipt!.IsProviderOwned, Is.True); + Assert.That(runtime.LastReceipt.ShouldRefreshProviderSurface, Is.True); + } + + [Test] + public void LiveProviderBridgeFeedsPlayableWorldRuntimeThroughTransportPorts() + { + var transport = new FakeLiveProviderTransport( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + new[] + { + new EveUnityPlayableWorldAssetManifestDocumentEntry( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "Resources/Aetheria/Entities/Vanguard.prefab", + "aetheria.vanguard") + }, + "aetheria")); + using var bridge = new EveUnitySceneLiveProviderBridge(transport); + var sceneSink = new FakePlayableWorldSceneSink(); + + using var runtime = new EveUnityPlayableWorldRuntime( + bridge, + bridge, + sceneSink, + bridge, + bridge); + + var initialPresentation = runtime.Connect(); + + Assert.That(transport.ConnectCount, Is.EqualTo(1)); + Assert.That(bridge.TransportKind, Is.EqualTo("cultmesh-cultnet-provider-transport")); + Assert.That(bridge.SurfacePointer, Is.EqualTo("cultmesh://aetheria/eve/surfaces/aetheria.daemon.game")); + Assert.That(bridge.AssetManifestPointer, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + Assert.That(initialPresentation.ActiveEntities, Is.EqualTo(3)); + Assert.That(runtime.ActiveWorld, Is.Not.Null); + Assert.That(runtime.AssetManifests.GetForWorld(runtime.ActiveWorld!), Is.Not.Null); + + var moveIntent = runtime.SubmitMoveVectorIntent( + "player-vanguard", + 0.5f, + 1f, + 0.75f, + DateTimeOffset.Parse("2026-07-09T00:00:00Z")); + + Assert.That(transport.Submitted.Count, Is.EqualTo(1)); + Assert.That(transport.Submitted[0], Is.SameAs(moveIntent)); + Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + + transport.PublishSurface(new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(includeRaider: false, playerPosition: "6,0,2"), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 2)); + + Assert.That(runtime.ActiveVersion, Is.EqualTo(2)); + Assert.That(runtime.LastPresentation, Is.Not.Null); + Assert.That(runtime.LastPresentation!.ActiveEntities, Is.EqualTo(2)); + Assert.That(sceneSink.RemovedEntityIds, Does.Contain("raider-scout")); + + transport.PublishReceipt(new EveUnitySceneCommandReceipt( + "aetheria.daemon.move_intent.accepted", + "aetheria.daemon.commands", + "aetheria.daemon.move_intent", + "accepted", + "Aetheria", + "provider-owned-daemon")); + + Assert.That(runtime.LastReceipt, Is.Not.Null); + Assert.That(runtime.LastReceipt!.IsProviderOwned, Is.True); + Assert.That(transport.RefreshCount, Is.EqualTo(1)); + } + + [Test] + public void PlayableWorldClientHostMountsInterfaceProviderWithoutProviderTypes() + { + var rootObject = new GameObject("generic-eve-world-root"); + var hostObject = new GameObject("generic-eve-client"); + hostObject.SetActive(false); + + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + new[] + { + new EveUnityPlayableWorldAssetManifestDocumentEntry( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "", + "aetheria.vanguard") + }, + "aetheria")); + + var host = hostObject.AddComponent(); + host.Configure(rootObject.transform, provider, provider, provider, provider); + + var presentation = host.Connect(); + var moveIntent = host.SubmitMoveIntent("player-vanguard", 3f, 0f, 4f); + + Assert.That(presentation.ActiveEntities, Is.EqualTo(3)); + Assert.That(host.ActiveWorld, Is.Not.Null); + Assert.That(host.ActiveWorld!.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(host.ActiveWorld.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + Assert.That(rootObject.transform.childCount, Is.EqualTo(3)); + Assert.That(provider.RefreshCount, Is.EqualTo(1)); + Assert.That(provider.Submitted.Count, Is.EqualTo(1)); + Assert.That(provider.Submitted[0], Is.SameAs(moveIntent)); + Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + UnityEngine.Object.DestroyImmediate(rootObject); + } + } + + [Test] + public void PlayableWorldClientBootstrapWiresGenericUnityClientFromProviderInterfaces() + { + var hostObject = new GameObject("generic-eve-client"); + hostObject.SetActive(false); + + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + + bootstrap.Mount(); + + Assert.That(bootstrap.Host, Is.Not.Null); + Assert.That(bootstrap.Host!.ActiveWorld, Is.Not.Null); + Assert.That(bootstrap.Host.ActiveWorld!.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(bootstrap.LastPresentation, Is.Not.Null); + Assert.That(bootstrap.LastPresentation!.ActiveEntities, Is.EqualTo(3)); + Assert.That(bootstrap.SceneRoot, Is.Not.Null); + Assert.That(bootstrap.SceneRoot!.childCount, Is.EqualTo(3)); + Assert.That(hostObject.GetComponent(), Is.Not.Null); + Assert.That(hostObject.GetComponent(), Is.Not.Null); + Assert.That(bootstrap.CameraTransform, Is.Not.Null); + Assert.That(provider.RefreshCount, Is.EqualTo(1)); + + var input = hostObject.GetComponent(); + var moveVector = input.SubmitMoveVectorInput(0f, 1f); + + Assert.That(moveVector, Is.Not.Null); + Assert.That(provider.Submitted.Count, Is.EqualTo(1)); + Assert.That(provider.Submitted[0], Is.SameAs(moveVector)); + Assert.That(moveVector!.ProviderId, Is.EqualTo("aetheria")); + Assert.That(moveVector.SurfaceId, Is.EqualTo("aetheria.daemon.game")); + Assert.That(moveVector.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveVector.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.move_intent")); + Assert.That(moveVector.Payload.GetString("entityId"), Is.EqualTo("player-vanguard")); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + + [Test] + public void PlayableWorldClientHostSubmitsProviderOwnedMoveVector() + { + var rootObject = new GameObject("generic-eve-world-root"); + var hostObject = new GameObject("generic-eve-client"); + hostObject.SetActive(false); + + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + + var host = hostObject.AddComponent(); + host.Configure(rootObject.transform, provider, provider, provider, provider); + host.Connect(); + + var moveVector = host.SubmitMoveVectorIntent("player-vanguard", 0.6f, -0.8f, 1f); + + Assert.That(provider.Submitted.Count, Is.EqualTo(1)); + Assert.That(provider.Submitted[0], Is.SameAs(moveVector)); + Assert.That(moveVector.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveVector.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.move_intent")); + Assert.That(moveVector.Payload.GetString("entityId"), Is.EqualTo("player-vanguard")); + Assert.That(moveVector.Payload.GetString("directionX"), Is.EqualTo("0.6")); + Assert.That(moveVector.Payload.GetString("directionY"), Is.EqualTo("-0.8")); + Assert.That(moveVector.Payload.GetString("scalarValue"), Is.EqualTo("1")); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + UnityEngine.Object.DestroyImmediate(rootObject); + } + } + + [Test] + public void PlayableWorldInputDriverBuildsCameraRelativeMoveVectorWithoutProviderTypes() + { + var rootObject = new GameObject("generic-eve-world-root"); + var hostObject = new GameObject("generic-eve-client"); + var cameraObject = new GameObject("generic-eve-camera"); + hostObject.SetActive(false); + + try + { + cameraObject.transform.rotation = Quaternion.Euler(0f, 90f, 0f); + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + + var host = hostObject.AddComponent(); + host.Configure(rootObject.transform, provider, provider, provider, provider); + host.Connect(); + + var driver = hostObject.AddComponent(); + driver.Host = host; + driver.CameraTransform = cameraObject.transform; + var request = driver.SubmitMoveVectorInput(0f, 1f); + + Assert.That(request, Is.Not.Null); + Assert.That(provider.Submitted.Count, Is.EqualTo(1)); + Assert.That(request!.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.move_intent")); + Assert.That(request.Payload.GetDouble("directionX", 0), Is.EqualTo(1f).Within(0.0001f)); + Assert.That(request.Payload.GetDouble("directionY", 0), Is.EqualTo(0f).Within(0.0001f)); + Assert.That(request.Payload.GetDouble("scalarValue", 0), Is.EqualTo(1f).Within(0.0001f)); + + var movement = EveUnityPlayableWorldMoveVector.FromCameraRelativeInput(1f, 1f, null); + Assert.That(movement.HasInput, Is.True); + Assert.That(movement.ScalarValue, Is.EqualTo(1f).Within(0.0001f)); + } + finally + { + UnityEngine.Object.DestroyImmediate(cameraObject); + UnityEngine.Object.DestroyImmediate(hostObject); + UnityEngine.Object.DestroyImmediate(rootObject); + } + } + + [Test] + public void PlayableWorldCameraRigFollowsAdvertisedPlayerEntityWithoutProviderTypes() + { + var rootObject = new GameObject("generic-eve-world-root"); + var hostObject = new GameObject("generic-eve-client"); + var cameraObject = new GameObject("generic-eve-camera"); + hostObject.SetActive(false); + + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + + var host = hostObject.AddComponent(); + host.Configure(rootObject.transform, provider, provider, provider, provider); + host.Connect(); + + var rig = hostObject.AddComponent(); + rig.Host = host; + rig.CameraTransform = cameraObject.transform; + + Assert.That(rig.ApplyRig(0f), Is.True); + Assert.That(cameraObject.transform.position.y, Is.GreaterThan(0f)); + Assert.That(Vector3.Distance(cameraObject.transform.position, Vector3.zero), Is.GreaterThan(1f)); + } + finally + { + UnityEngine.Object.DestroyImmediate(cameraObject); + UnityEngine.Object.DestroyImmediate(hostObject); + UnityEngine.Object.DestroyImmediate(rootObject); + } + } + + [Test] + public void PlayableWorldPresenterInstantiatesUpdatesAndDespawnsProviderEntities() + { + var lowerer = new EveUnitySceneSurfaceLowerer(); + var sink = new FakePlayableWorldSceneSink(); + var presenter = new EveUnityPlayableWorldPresenter(sink, new EveUnityAssetRefResolver()); + + var firstProjection = lowerer.Lower(PlayableArpgDocument(), Advertisement("aetheria.daemon.game")); + var firstPresentation = presenter.Apply(firstProjection); + + Assert.That(firstPresentation.WorldRootId, Is.EqualTo("aetheria.daemon.game.playable")); + Assert.That(firstPresentation.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(firstPresentation.InputProfile, Is.EqualTo("arpg-third-person")); + Assert.That(firstPresentation.CameraRig, Is.EqualTo("third-person-orbit")); + Assert.That(firstPresentation.UpsertedEntities, Is.EqualTo(3)); + Assert.That(firstPresentation.RemovedEntities, Is.EqualTo(0)); + Assert.That(firstPresentation.ActiveEntities, Is.EqualTo(3)); + Assert.That(sink.ConfiguredWorlds.Count, Is.EqualTo(1)); + Assert.That(sink.Upserts.Count, Is.EqualTo(3)); + Assert.That(sink.Upserts[0].entity.EntityId, Is.EqualTo("player-vanguard")); + Assert.That(sink.Upserts[0].entity.PositionX, Is.EqualTo(0f)); + Assert.That(sink.Upserts[0].asset.AssetRef, Is.EqualTo("cultmesh://aetheria/assets/map/entity/player")); + Assert.That(sink.Upserts[0].asset.PresentationKind, Is.EqualTo("provider-asset-ref")); + + var secondProjection = lowerer.Lower( + PlayableArpgDocument(includeRaider: false, playerPosition: "5,0,2"), + Advertisement("aetheria.daemon.game")); + var secondPresentation = presenter.Apply(secondProjection); + + Assert.That(secondPresentation.UpsertedEntities, Is.EqualTo(2)); + Assert.That(secondPresentation.RemovedEntities, Is.EqualTo(1)); + Assert.That(secondPresentation.ActiveEntities, Is.EqualTo(2)); + Assert.That(sink.ConfiguredWorlds.Count, Is.EqualTo(2)); + Assert.That(sink.RemovedEntityIds.Count, Is.EqualTo(1)); + Assert.That(sink.RemovedEntityIds[0], Is.EqualTo("raider-scout")); + Assert.That(sink.Upserts[3].entity.EntityId, Is.EqualTo("player-vanguard")); + Assert.That(sink.Upserts[3].entity.PositionX, Is.EqualTo(5f)); + Assert.That(sink.Upserts[3].entity.PositionZ, Is.EqualTo(2f)); + } + + [Test] + public void LivePlayableWorldClientPresentsProviderSnapshotsAndKeepsCommandsProviderOwned() + { + var source = new FakeProviderSurfaceSource( + "aetheria", + "aetheria.daemon.game", + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + new EveUnitySceneProviderSurfaceSnapshot( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1)); + var commandSink = new FakeCommandSink("cultmesh-command-sink"); + var sceneSink = new FakePlayableWorldSceneSink(); + using var connection = new EveUnitySceneProviderConnection(source, commandSink); + using var client = new EveUnityPlayableWorldLiveClient( + connection, + new EveUnityPlayableWorldPresenter(sceneSink, new EveUnityAssetRefResolver())); + + var initialPresentation = client.Connect(); + + Assert.That(initialPresentation.ActiveEntities, Is.EqualTo(3)); + Assert.That(initialPresentation.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(client.ActiveWorld, Is.Not.Null); + Assert.That(client.ActiveWorld!.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + Assert.That(sceneSink.ConfiguredWorlds.Count, Is.EqualTo(1)); + Assert.That(sceneSink.Upserts.Count, Is.EqualTo(3)); + + source.Publish(new EveUnitySceneProviderSurfaceSnapshot( + PlayableArpgDocument(includeRaider: false, playerPosition: "7,0,4"), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 2)); + + Assert.That(client.ActiveVersion, Is.EqualTo(2)); + Assert.That(client.LastPresentation, Is.Not.Null); + Assert.That(client.LastPresentation!.ActiveEntities, Is.EqualTo(2)); + Assert.That(client.LastPresentation.RemovedEntities, Is.EqualTo(1)); + Assert.That(sceneSink.ConfiguredWorlds.Count, Is.EqualTo(2)); + Assert.That(sceneSink.RemovedEntityIds[0], Is.EqualTo("raider-scout")); + Assert.That(sceneSink.Upserts[3].entity.EntityId, Is.EqualTo("player-vanguard")); + Assert.That(sceneSink.Upserts[3].entity.PositionX, Is.EqualTo(7f)); + Assert.That(sceneSink.Upserts[3].entity.PositionZ, Is.EqualTo(4f)); + + var moveIntent = client.SubmitMoveIntent( + "player-vanguard", + 12f, + 0f, + 8f, + DateTimeOffset.Parse("2026-07-09T00:00:00Z")); + + Assert.That(commandSink.Submitted.Count, Is.EqualTo(1)); + Assert.That(commandSink.Submitted[0], Is.SameAs(moveIntent)); + Assert.That(moveIntent.ProviderId, Is.EqualTo("aetheria")); + Assert.That(moveIntent.SurfaceId, Is.EqualTo("aetheria.daemon.game")); + Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(moveIntent.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1")); + + client.Disconnect(); + source.Publish(new EveUnitySceneProviderSurfaceSnapshot( + PlayableArpgDocument(playerPosition: "99,0,99"), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 3)); + + Assert.That(client.ActiveVersion, Is.EqualTo(2)); + Assert.That(sceneSink.ConfiguredWorlds.Count, Is.EqualTo(2)); + } + + [Test] + public void LivePlayableWorldClientRefreshesFromProviderSnapshotAfterReceiptWithoutOwningMovement() + { + var source = new FakeProviderSurfaceSource( + "aetheria", + "aetheria.daemon.game", + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + new EveUnitySceneProviderSurfaceSnapshot( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1)); + var commandSink = new FakeCommandSink("cultmesh-command-sink"); + var receiptSource = new FakeCommandReceiptSource(); + var sceneSink = new FakePlayableWorldSceneSink(); + using var connection = new EveUnitySceneProviderConnection(source, commandSink); + using var client = new EveUnityPlayableWorldLiveClient( + connection, + new EveUnityPlayableWorldPresenter(sceneSink, new EveUnityAssetRefResolver()), + receiptSource); + + client.Connect(); + client.SubmitMoveIntent("player-vanguard", 30f, 0f, 30f); + + Assert.That(sceneSink.Upserts[0].entity.PositionX, Is.EqualTo(0f)); + Assert.That(sceneSink.ConfiguredWorlds.Count, Is.EqualTo(1)); + + receiptSource.Publish(new EveUnitySceneCommandReceipt( + "aetheria.daemon.move_intent.pending", + "aetheria.daemon.commands", + "aetheria.daemon.move_intent", + "pending", + "Aetheria", + "AetheriaRuntimeDaemonCommandBoundaryDocument", + "aetheria.eve_command_acceptance_status.v1", + "aetheria", + "aetheria.daemon.game")); + + Assert.That(client.LastReceipt, Is.Not.Null); + Assert.That(client.LastReceipt!.State, Is.EqualTo("pending")); + Assert.That(client.LastReceipt.IsProviderOwned, Is.True); + Assert.That(sceneSink.ConfiguredWorlds.Count, Is.EqualTo(1)); + + source.Stage(new EveUnitySceneProviderSurfaceSnapshot( + PlayableArpgDocument(includeRaider: false, playerPosition: "30,0,30"), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 2)); + + receiptSource.Publish(new EveUnitySceneCommandReceipt( + "aetheria.daemon.move_intent.reconciled", + "aetheria.daemon.commands", + "aetheria.daemon.move_intent", + "reconciled", + "Aetheria", + "AetheriaRuntimeDaemonCommandBoundaryDocument", + "aetheria.eve_command_acceptance_status.v1", + "aetheria", + "aetheria.daemon.game")); + + Assert.That(client.LastReceipt.State, Is.EqualTo("reconciled")); + Assert.That(client.ActiveVersion, Is.EqualTo(2)); + Assert.That(client.LastPresentation, Is.Not.Null); + Assert.That(client.LastPresentation!.ActiveEntities, Is.EqualTo(2)); + Assert.That(sceneSink.ConfiguredWorlds.Count, Is.EqualTo(2)); + Assert.That(sceneSink.Upserts[3].entity.EntityId, Is.EqualTo("player-vanguard")); + Assert.That(sceneSink.Upserts[3].entity.PositionX, Is.EqualTo(30f)); + Assert.That(sceneSink.Upserts[3].entity.PositionZ, Is.EqualTo(30f)); + } + + [Test] + public void AssetManifestMapsProviderAssetRefsToUnityLoadKeysWithoutAetheriaTypes() + { + var manifest = new EveUnityPlayableWorldAssetManifest( + "cultmesh://aetheria/assets/manifest", + new[] + { + new EveUnityPlayableWorldAssetManifestEntry( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "resources://Aetheria/Entities/Vanguard.prefab", + "aetheria.vanguard"), + new EveUnityPlayableWorldAssetManifestEntry( + "", + "enemy", + "Resources/Aetheria/Entities/Raider.prefab", + "aetheria.raider") + }); + + var player = manifest.Find(new EveUnityPlayableWorldAssetBinding( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "provider-asset-ref")); + Assert.That(player, Is.Not.Null); + Assert.That(player!.ResourcesPath, Is.EqualTo("Aetheria/Entities/Vanguard")); + Assert.That(player.PrefabKey, Is.EqualTo("aetheria.vanguard")); + + var enemy = manifest.Find(new EveUnityPlayableWorldAssetBinding( + "cultmesh://aetheria/assets/map/entity/missing-raider", + "enemy", + "provider-asset-ref")); + Assert.That(enemy, Is.Not.Null); + Assert.That(enemy!.ResourcesPath, Is.EqualTo("Aetheria/Entities/Raider")); + Assert.That(enemy.PrefabKey, Is.EqualTo("aetheria.raider")); + } + + [Test] + public void AssetManifestCacheTracksPlayableWorldManifestPointerAndLiveUpdates() + { + var lowerer = new EveUnitySceneSurfaceLowerer(); + var projection = lowerer.Lower(PlayableArpgDocument(), Advertisement("aetheria.daemon.game")); + Assert.That(projection.PlayableWorld, Is.Not.Null); + + var source = new FakeAssetManifestSource(new EveUnityPlayableWorldAssetManifest( + "cultmesh://aetheria/assets/manifest", + new[] + { + new EveUnityPlayableWorldAssetManifestEntry( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "resources://Aetheria/Entities/Vanguard.prefab", + "aetheria.vanguard") + })); + var cache = new EveUnityPlayableWorldAssetManifestCache(); + cache.Connect(source); + + var manifest = cache.GetForWorld(projection.PlayableWorld!); + Assert.That(manifest, Is.Not.Null); + Assert.That(manifest!.ManifestRef, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + Assert.That(cache.Count, Is.EqualTo(1)); + var player = manifest.Find(new EveUnityPlayableWorldAssetBinding( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "provider-asset-ref")); + Assert.That(player, Is.Not.Null); + Assert.That(player!.ResourcesPath, Is.EqualTo("Aetheria/Entities/Vanguard")); + + source.Publish(new EveUnityPlayableWorldAssetManifest( + "cultmesh://aetheria/assets/manifest", + new[] + { + new EveUnityPlayableWorldAssetManifestEntry( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "resources://Aetheria/Entities/VanguardMk2.prefab", + "aetheria.vanguard.mk2") + })); + + var updated = cache.GetForWorld(projection.PlayableWorld); + Assert.That(updated, Is.Not.Null); + var updatedPlayer = updated!.Find(new EveUnityPlayableWorldAssetBinding( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "provider-asset-ref")); + Assert.That(updatedPlayer, Is.Not.Null); + Assert.That(updatedPlayer!.ResourcesPath, Is.EqualTo("Aetheria/Entities/VanguardMk2")); + + cache.Disconnect(source); + source.Publish(new EveUnityPlayableWorldAssetManifest( + "cultmesh://aetheria/assets/manifest", + new[] + { + new EveUnityPlayableWorldAssetManifestEntry( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "resources://Aetheria/Entities/VanguardIgnored.prefab", + "aetheria.vanguard.ignored") + })); + + var afterDisconnect = cache.GetForWorld(projection.PlayableWorld); + var afterDisconnectPlayer = afterDisconnect!.Find(new EveUnityPlayableWorldAssetBinding( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "provider-asset-ref")); + Assert.That(afterDisconnectPlayer!.ResourcesPath, Is.EqualTo("Aetheria/Entities/VanguardMk2")); + } + + [Test] + public void AssetManifestDocumentSourceFeedsCacheWithoutChangingSceneLowering() + { + var lowerer = new EveUnitySceneSurfaceLowerer(); + var projection = lowerer.Lower(PlayableArpgDocument(), Advertisement("aetheria.daemon.game")); + Assert.That(projection.PlayableWorld, Is.Not.Null); + + var documentSource = new FakeAssetManifestDocumentSource(new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + new[] + { + new EveUnityPlayableWorldAssetManifestDocumentEntry( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "resources://Aetheria/Entities/Vanguard.prefab", + "aetheria.vanguard") + }, + "aetheria")); + using var manifestSource = new EveUnityPlayableWorldAssetManifestDocumentSource(documentSource); + var cache = new EveUnityPlayableWorldAssetManifestCache(); + manifestSource.Connect(); + cache.Connect(manifestSource); + + var manifest = cache.GetForWorld(projection.PlayableWorld!); + Assert.That(manifest, Is.Not.Null); + Assert.That(manifest!.ManifestRef, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + var player = manifest.Find(new EveUnityPlayableWorldAssetBinding( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "provider-asset-ref")); + Assert.That(player, Is.Not.Null); + Assert.That(player!.ResourcesPath, Is.EqualTo("Aetheria/Entities/Vanguard")); + Assert.That(player.PrefabKey, Is.EqualTo("aetheria.vanguard")); + + documentSource.Publish(new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + new[] + { + new EveUnityPlayableWorldAssetManifestDocumentEntry( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "Resources/Aetheria/Entities/VanguardAuthority.prefab", + "aetheria.vanguard.authority") + }, + "aetheria")); + + var updated = cache.GetForWorld(projection.PlayableWorld); + var updatedPlayer = updated!.Find(new EveUnityPlayableWorldAssetBinding( + "cultmesh://aetheria/assets/map/entity/player", + "player", + "provider-asset-ref")); + Assert.That(updatedPlayer, Is.Not.Null); + Assert.That(updatedPlayer!.ResourcesPath, Is.EqualTo("Aetheria/Entities/VanguardAuthority")); + Assert.That(updatedPlayer.PrefabKey, Is.EqualTo("aetheria.vanguard.authority")); + } + + [Test] + public void SaiVisualNovelLowersThroughRuntimeProjectionAdapterWithoutOwningStoryState() + { + var lowerer = new EveUnitySceneSurfaceLowerer(); + var projection = lowerer.Lower(SaiDocument(), Advertisement("sai.visual_novel.surface")); + + Assert.That(projection.ProviderId, Is.EqualTo("gamecult.home.vn")); + Assert.That(projection.SurfaceId, Is.EqualTo("sai.visual_novel.surface")); + Assert.That(projection.Root.SceneObjectKind, Is.EqualTo("sai-vn-scene-stage")); + Assert.That(projection.Root.PluginProjection, Is.Not.Null); + Assert.That(projection.Root.PluginProjection!.PluginId, Is.EqualTo("sai.vn")); + Assert.That(projection.Root.PluginProjection.ProjectionKind, Is.EqualTo("sai-vn-scene-stage-shell")); + Assert.That(projection.Root.PluginProjection.AbiSchema, Is.EqualTo("gamecult.eve.plugin_abi.v1")); + Assert.That(projection.Root.PluginProjection.CommandBoundary, Is.EqualTo("sidecar-advertised-plugin-abi")); + Assert.That(projection.Root.PluginProjection.Capabilities, Does.Contain("vn.stage")); + Assert.That(projection.Root.PluginProjection.Capabilities, Does.Contain("story.choose")); + Assert.That(projection.Root.PluginProjection.DocumentId, Is.EqualTo("gamecult-compound")); + Assert.That(projection.Root.PluginProjection.SemanticOwner, Is.EqualTo("Sai")); + + var dialogue = projection.Root.Children[0]; + Assert.That(dialogue.SceneObjectKind, Is.EqualTo("sai-vn-scene-dialogue")); + Assert.That(dialogue.PluginProjection!.ProjectionKind, Is.EqualTo("sai-vn-scene-dialogue-shell")); + Assert.That(dialogue.PluginProjection.SemanticOwner, Is.EqualTo("Sai")); + + var choice = projection.Root.Children[1].Children[0]; + Assert.That(choice.SceneObjectKind, Is.EqualTo("command-control")); + Assert.That(choice.PluginProjection, Is.Not.Null); + Assert.That(choice.PluginProjection!.PluginId, Is.EqualTo("sai.vn")); + Assert.That(choice.PluginProjection.ProjectionKind, Is.EqualTo("sai-vn-scene-story-command-shell")); + Assert.That(choice.PluginProjection.Command, Is.EqualTo("story.choose")); + Assert.That(choice.PluginProjection.SemanticOwner, Is.EqualTo("Sai")); + + var tex = projection.Root.Children[2]; + Assert.That(tex.SceneObjectKind, Is.EqualTo("tex-math-scene-projection")); + Assert.That(tex.PluginProjection, Is.Not.Null); + Assert.That(tex.PluginProjection!.PluginId, Is.EqualTo("tex.math")); + Assert.That(tex.PluginProjection.ProjectionKind, Is.EqualTo("tex-math-scene-block-fallback-shell")); + Assert.That(tex.PluginProjection.Capabilities, Does.Contain("embed.tex")); + Assert.That(tex.PluginProjection.Capabilities, Does.Contain("tex.scene-placement")); + Assert.That(tex.PluginProjection.DocumentId, Is.EqualTo("\\\\mathrm{votes}(p)=1+\\\\lfloor\\\\log_b(1+p)\\\\rfloor")); + Assert.That(tex.PluginProjection.SemanticOwner, Is.EqualTo("EvePlugins")); + } + + [Test] + public void CommandIntentCarriesAdvertisedBoundaryWithoutOwningReceipts() + { + var lowerer = new EveUnitySceneSurfaceLowerer(); + var intent = lowerer.CreateCommandIntent( + Document("aetheria.daemon.game"), + Advertisement("aetheria.daemon.game"), + "aetheria.daemon.commands", + new Dictionary(StringComparer.Ordinal) + { + ["commandId"] = "aetheria.daemon.focus" + }, + DateTimeOffset.Parse("2026-07-09T00:00:00Z")); + + Assert.That(intent.Schema, Is.EqualTo(EveSurfaceCommandRequest.SchemaId)); + Assert.That(intent.ProviderId, Is.EqualTo("aetheria")); + Assert.That(intent.SurfaceId, Is.EqualTo("aetheria.daemon.game")); + Assert.That(intent.Command, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(intent.ClientId, Is.EqualTo("unity-scene")); + Assert.That(intent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); + Assert.That(intent.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1")); + } + + private static EveSurfaceDocument Document(string surfaceId) + { + return new EveSurfaceDocument( + "aetheria", + "game.provider", + "Aetheria world", + 1, + "2026-07-09T00:00:00Z", + new EveSurfaceTree( + surfaceId, + new EveSurfaceComponent( + $"{surfaceId}.root", + "surface", + new Dictionary(StringComparer.Ordinal), + new[] + { + new EveSurfaceComponent( + $"{surfaceId}.entities", + "world.entities", + new Dictionary(StringComparer.Ordinal) + { + ["binding"] = "cultmesh://aetheria/world/entities" + }, + Array.Empty(), + new[] + { + new CultMeshStateBindingDescriptor("entities", "cultmesh://aetheria/world/entities") + }), + new EveSurfaceComponent( + $"{surfaceId}.focus", + "control.button", + new Dictionary(StringComparer.Ordinal) + { + ["command"] = "aetheria.daemon.focus" + }, + Array.Empty()), + new EveSurfaceComponent( + $"{surfaceId}.norn", + "embed.norn", + new Dictionary(StringComparer.Ordinal) + { + ["graph.document"] = "cultmesh://aetheria/norn/map", + ["interaction.nodeAction"] = "graph.focus" + }, + Array.Empty(), + Array.Empty(), + new[] + { + new EveEmbeddedDocumentSlot( + "norn.map", + "cultmesh://aetheria/norn/map", + "gamecult.eve.surface.v1", + "scene-overlay") + }) + }), + Array.Empty()), + Array.Empty()); + } + + private static EveSurfaceDocument PlayableArpgDocument( + bool includeRaider = true, + string playerPosition = "0,0,0") + { + var playableChildren = new List + { + PlayableEntity( + "aetheria.daemon.game.entity.player", + "player-vanguard", + "player", + "Vanguard", + "player", + "cultmesh://aetheria/assets/map/entity/player", + playerPosition, + "35", + "1.15", + true, + true, + "aetheria.daemon.focus", + "aetheria.daemon.move_intent", + "", + "") + }; + + if (includeRaider) + { + playableChildren.Add(PlayableEntity( + "aetheria.daemon.game.entity.raider", + "raider-scout", + "enemy", + "Raider Scout", + "raider", + "cultmesh://aetheria/assets/map/entity/ship", + "9,0,14", + "220", + "0.9", + true, + false, + "", + "", + "aetheria.daemon.target", + "")); + } + + playableChildren.Add(PlayableEntity( + "aetheria.daemon.game.entity.station", + "anchor-station", + "station", + "Anchor Station", + "neutral", + "cultmesh://aetheria/assets/map/entity/station", + "-18,0,6", + "0", + "3.4", + true, + false, + "aetheria.daemon.focus", + "", + "", + "")); + + playableChildren.Add(new EveSurfaceComponent( + "aetheria.daemon.game.flow", + "field.vector3d", + new Dictionary(StringComparer.Ordinal) + { + ["fieldId"] = "aetheria.zone.flow", + ["bind"] = "aetheria.daemon.soaView.flowField3d", + ["visualizer"] = "particles", + ["bounds"] = "-32,0,-32,32,24,32" + }, + Array.Empty())); + + return new EveSurfaceDocument( + "aetheria", + "game.runtime", + "Aetheria daemon game", + 1, + "2026-07-09T00:00:00Z", + new EveSurfaceTree( + "aetheria.daemon.game", + new EveSurfaceComponent( + "aetheria.daemon.game.root", + "surface", + new Dictionary(StringComparer.Ordinal), + new[] + { + new EveSurfaceComponent( + "aetheria.daemon.game.playable", + "world.scene3d", + new Dictionary(StringComparer.Ordinal) + { + ["statePointerId"] = "cultmesh://aetheria/run/current", + ["assetManifest"] = "cultmesh://aetheria/assets/manifest", + ["inputProfile"] = "arpg-third-person", + ["cameraRig"] = "third-person-orbit", + ["playerEntityId"] = "player-vanguard", + ["movementCommand"] = "aetheria.daemon.move_intent", + ["focusCommand"] = "aetheria.daemon.focus", + ["targetCommand"] = "aetheria.daemon.target", + ["actionCommand"] = "aetheria.daemon.use_equipment" + }, + playableChildren, + new[] + { + new CultMeshStateBindingDescriptor("run", "cultmesh://aetheria/run/current"), + new CultMeshStateBindingDescriptor("assets", "cultmesh://aetheria/assets/manifest") + }) + }), + Array.Empty()), + new[] + { + new EveCommandTemplate(CultMesh.OperationBinding("aetheria.daemon.commands", "Aetheria daemon commands")) + }); + } + + private static EveSurfaceComponent PlayableEntity( + string nodeId, + string entityId, + string entityKind, + string label, + string faction, + string assetRef, + string position, + string rotationY, + string radius, + bool selectable, + bool controllable, + string focusCommand, + string moveCommand, + string targetCommand, + string actionCommand) + { + return new EveSurfaceComponent( + nodeId, + "world.entity3d", + new Dictionary(StringComparer.Ordinal) + { + ["entityId"] = entityId, + ["entityKind"] = entityKind, + ["label"] = label, + ["faction"] = faction, + ["assetRef"] = assetRef, + ["position"] = position, + ["rotationY"] = rotationY, + ["radius"] = radius, + ["selectable"] = selectable ? "true" : "false", + ["controllable"] = controllable ? "true" : "false", + ["focusCommand"] = focusCommand, + ["moveCommand"] = moveCommand, + ["targetCommand"] = targetCommand, + ["actionCommand"] = actionCommand + }, + Array.Empty()); + } + + private static EveUnityPlayableWorldEntity FindEntity( + EveUnityPlayableWorldProjection playableWorld, + string entityId) + { + foreach (var entity in playableWorld.Entities) + { + if (string.Equals(entity.EntityId, entityId, StringComparison.Ordinal)) + return entity; + } + throw new AssertionException($"Playable entity not found: {entityId}"); + } + + private static EveSurfaceDocument SaiDocument() + { + return new EveSurfaceDocument( + "gamecult.home.vn", + "sai.visual_novel", + "GameCult Compound VN", + 1, + "2026-07-09T00:00:00Z", + new EveSurfaceTree( + "sai.visual_novel.surface", + new EveSurfaceComponent( + "sai.root", + "vn.stage", + new Dictionary(StringComparer.Ordinal) + { + ["storyId"] = "gamecult-compound", + ["currentPath"] = "hub" + }, + new[] + { + new EveSurfaceComponent( + "sai.dialogue", + "panel.dialogue", + new Dictionary(StringComparer.Ordinal) + { + ["speaker"] = "Void", + ["text"] = "Pick a door." + }, + Array.Empty()), + new EveSurfaceComponent( + "sai.choices", + "rail.actions", + new Dictionary(StringComparer.Ordinal), + new[] + { + new EveSurfaceComponent( + "sai.choice.eve", + "control.button", + new Dictionary(StringComparer.Ordinal) + { + ["label"] = "What is Eve?", + ["action.command"] = "story.choose", + ["targetPath"] = "eve" + }, + Array.Empty()) + }), + new EveSurfaceComponent( + "sai.tex.log-power", + "embed.tex", + new Dictionary(StringComparer.Ordinal) + { + ["label"] = "Bifrost voting weight", + ["source"] = "\\\\mathrm{votes}(p)=1+\\\\lfloor\\\\log_b(1+p)\\\\rfloor", + ["format"] = "latex", + ["display"] = "block" + }, + Array.Empty()) + }), + Array.Empty()), + new[] + { + new EveCommandTemplate(CultMesh.OperationBinding("story.choose", "Choose")) + }); + } + + private static EveUnitySceneProviderSurfaceAdvertisement Advertisement(string surfaceId) + { + return new EveUnitySceneProviderSurfaceAdvertisement( + surfaceId, + "interactive-world", + new EveUnitySceneWorldInteraction( + "provider-authored-world-surface", + "aetheria.daemon.commands", + "aetheria.eve_command_acceptance_status.v1", + "provider-owns-world-state-assets-command-acceptance-and-receipts")); + } + + private sealed class FakeProviderSurfaceSource : IEveUnitySceneProviderSurfaceSource + { + public FakeProviderSurfaceSource( + string providerId, + string surfaceId, + string sourcePointer, + EveUnitySceneProviderSurfaceSnapshot currentSnapshot) + { + ProviderId = providerId; + SurfaceId = surfaceId; + SourcePointer = sourcePointer; + CurrentSnapshot = currentSnapshot; + } + + public string ProviderId { get; } + + public string SurfaceId { get; } + + public string SourcePointer { get; } + + public EveUnitySceneProviderSurfaceSnapshot CurrentSnapshot { get; private set; } + + public event Action? SnapshotAvailable; + + public void Connect() + { + } + + public void Refresh() + { + } + + public void Disconnect() + { + } + + public void Stage(EveUnitySceneProviderSurfaceSnapshot snapshot) + { + CurrentSnapshot = snapshot; + } + + public void Publish(EveUnitySceneProviderSurfaceSnapshot snapshot) + { + CurrentSnapshot = snapshot; + SnapshotAvailable?.Invoke(snapshot); + } + } + + private sealed class FakeProviderSurfaceDocumentSource : IEveUnitySceneProviderSurfaceDocumentSource + { + public FakeProviderSurfaceDocumentSource(EveUnitySceneProviderSurfaceDocument currentDocument) + { + CurrentDocument = currentDocument; + } + + public EveUnitySceneProviderSurfaceDocument CurrentDocument { get; private set; } + + public event Action? DocumentAvailable; + + public void Publish(EveUnitySceneProviderSurfaceDocument document) + { + CurrentDocument = document; + DocumentAvailable?.Invoke(document); + } + } + + private sealed class FakeCommandReceiptSource : IEveUnitySceneCommandReceiptSource + { + public event Action? ReceiptAvailable; + + public void Publish(EveUnitySceneCommandReceipt receipt) + { + ReceiptAvailable?.Invoke(receipt); + } + } + + private sealed class FakeCommandSink : IEveUnitySceneCommandSink + { + public FakeCommandSink(string sinkKind) + { + SinkKind = sinkKind; + } + + public string SinkKind { get; } + + public List Submitted { get; } = new List(); + + public void Submit(EveSurfaceCommandRequest request) + { + Submitted.Add(request); + } + } + + private sealed class FakePlayableWorldSceneSink : IEveUnityPlayableWorldSceneSink + { + public List ConfiguredWorlds { get; } = new List(); + + public List<(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset)> Upserts { get; } = + new List<(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset)>(); + + public List RemovedEntityIds { get; } = new List(); + + public void ConfigureWorld(EveUnityPlayableWorldProjection world) + { + ConfiguredWorlds.Add(world); + } + + public void UpsertEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset) + { + Upserts.Add((entity, asset)); + } + + public void RemoveEntity(string entityId) + { + RemovedEntityIds.Add(entityId); + } + } + + private sealed class FakeAssetManifestSource : IEveUnityPlayableWorldAssetManifestSource + { + public FakeAssetManifestSource(EveUnityPlayableWorldAssetManifest currentManifest) + { + CurrentManifest = currentManifest; + } + + public string ManifestRef => CurrentManifest.ManifestRef; + + public EveUnityPlayableWorldAssetManifest CurrentManifest { get; private set; } + + public event Action? ManifestAvailable; + + public void Publish(EveUnityPlayableWorldAssetManifest manifest) + { + CurrentManifest = manifest; + ManifestAvailable?.Invoke(manifest); + } + } + + private sealed class FakeAssetManifestDocumentSource : IEveUnityPlayableWorldAssetManifestDocumentSource + { + public FakeAssetManifestDocumentSource(EveUnityPlayableWorldAssetManifestDocument currentDocument) + { + CurrentDocument = currentDocument; + } + + public string ManifestRef => CurrentDocument.ManifestRef; + + public EveUnityPlayableWorldAssetManifestDocument CurrentDocument { get; private set; } + + public event Action? DocumentAvailable; + + public void Publish(EveUnityPlayableWorldAssetManifestDocument document) + { + CurrentDocument = document; + DocumentAvailable?.Invoke(document); + } + } + + private sealed class FakeLiveProviderTransport : IEveUnitySceneLiveProviderTransport + { + public FakeLiveProviderTransport( + EveUnitySceneProviderSurfaceDocument surfaceDocument, + EveUnityPlayableWorldAssetManifestDocument assetManifestDocument) + { + CurrentSurfaceDocument = surfaceDocument; + CurrentAssetManifestDocument = assetManifestDocument; + } + + public string TransportKind => "cultmesh-cultnet-provider-transport"; + + public string SurfacePointer => CurrentSurfaceDocument.SourcePointer; + + public string AssetManifestPointer => CurrentAssetManifestDocument.ManifestRef; + + public EveUnitySceneProviderSurfaceDocument CurrentSurfaceDocument { get; private set; } + + public EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; private set; } + + public List Submitted { get; } = new List(); + + public int ConnectCount { get; private set; } + + public int DisconnectCount { get; private set; } + + public int RefreshCount { get; private set; } + + public event Action? SurfaceDocumentAvailable; + + public event Action? AssetManifestDocumentAvailable; + + public event Action? CommandReceiptAvailable; + + public void Connect() + { + ConnectCount++; + } + + public void Disconnect() + { + DisconnectCount++; + } + + public void Refresh() + { + RefreshCount++; + } + + public void SubmitCommand(EveSurfaceCommandRequest request) + { + Submitted.Add(request); + } + + public void PublishSurface(EveUnitySceneProviderSurfaceDocument document) + { + CurrentSurfaceDocument = document; + SurfaceDocumentAvailable?.Invoke(document); + } + + public void PublishAssetManifest(EveUnityPlayableWorldAssetManifestDocument document) + { + CurrentAssetManifestDocument = document; + AssetManifestDocumentAvailable?.Invoke(document); + } + + public void PublishReceipt(EveUnitySceneCommandReceipt receipt) + { + CommandReceiptAvailable?.Invoke(receipt); + } + } + + private sealed class FakePlayableWorldProviderComponent : + MonoBehaviour, + IEveUnitySceneProviderSurfaceDocumentSource, + IEveUnityPlayableWorldAssetManifestDocumentSource, + IEveUnitySceneCommandSink, + IEveUnitySceneCommandReceiptSource, + IEveUnityProviderRefreshSource + { + public List Submitted { get; } = new List(); + + public int RefreshCount { get; private set; } + + public string SinkKind => "fake-provider-command-sink"; + + public string ManifestRef => CurrentDocument.ManifestRef; + + public EveUnitySceneProviderSurfaceDocument CurrentSurfaceDocument { get; private set; } = + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1); + + public EveUnityPlayableWorldAssetManifestDocument CurrentDocument { get; private set; } = + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria"); + + EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument => + CurrentSurfaceDocument; + + public event Action? DocumentAvailable; + + public event Action? AssetManifestDocumentAvailable; + + event Action IEveUnityPlayableWorldAssetManifestDocumentSource.DocumentAvailable + { + add => AssetManifestDocumentAvailable += value; + remove => AssetManifestDocumentAvailable -= value; + } + + public event Action? ReceiptAvailable; + + public void Set( + EveUnitySceneProviderSurfaceDocument surfaceDocument, + EveUnityPlayableWorldAssetManifestDocument assetManifest) + { + CurrentSurfaceDocument = surfaceDocument; + CurrentDocument = assetManifest; + } + + public void Refresh() + { + RefreshCount++; + DocumentAvailable?.Invoke(CurrentSurfaceDocument); + AssetManifestDocumentAvailable?.Invoke(CurrentDocument); + } + + public void Submit(EveSurfaceCommandRequest request) + { + Submitted.Add(request); + } + + public void PublishReceipt(EveUnitySceneCommandReceipt receipt) + { + ReceiptAvailable?.Invoke(receipt); + } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs.meta new file mode 100644 index 0000000..ef6d6e2 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2b3006d5699924498750dbf5f087435 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef b/packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef new file mode 100644 index 0000000..392a1b5 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef @@ -0,0 +1,19 @@ +{ + "name": "GameCult.Eve.UnityScene.Tests", + "rootNamespace": "GameCult.Eve.UnityScene.Tests", + "references": [ + "GameCult.Eve.Surface", + "GameCult.Eve.UnityScene" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef.meta new file mode 100644 index 0000000..d045f6a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 10e7994adb919944aadc88f960300bc4 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json new file mode 100644 index 0000000..fb41ac1 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json @@ -0,0 +1,211 @@ +{ + "schema": "gamecult.eve.runtime_capability.v1", + "runtimeId": "unity-scene", + "title": "Unity / Scene", + "owner": "EveUnity", + "incubation": { + "ownerRepo": "EveUnity", + "currentHostRepo": "EveUnity", + "splitTarget": "EveUnity", + "graduationTrigger": "Graduated: EveUnity owns the provider-agnostic Unity scene package, release lifecycle, and runtime tests. Real Unity frame capture and live CultMesh transport remain active lifecycle work.", + "splitHandoff": { + "manifestPath": "", + "smokeCommand": "" + } + }, + "supportedFeatures": [ + "providerAdvertisements", + "commandTransport", + "providerSurfaceSession", + "providerSurfaceSource", + "providerSurfaceDocumentSource", + "liveProviderTransportBridge", + "livePlayableWorldClient", + "providerCommandReceipts", + "providerAssetManifestDocumentSource", + "playableWorldClientHost", + "playableWorldClientBootstrap", + "playableWorldInputDriver", + "playableWorldCameraRig", + "sceneGraphProjection", + "playableWorldProjection", + "playableWorldRuntimeHost", + "playableWorldScenePresentation", + "unityGameObjectSceneSink", + "providerAssetManifestResolution", + "providerAssetManifestSource", + "embeddedDocuments" + ], + "supportedPlugins": [ + { + "pluginId": "sai.vn", + "capabilities": [ + "vn.stage", + "story.choose", + "story.continue", + "story.jump" + ], + "projectionAdapter": "SaiVisualNovelUnitySceneProjectionAdapter" + }, + { + "pluginId": "norn.graph", + "capabilities": [ + "embed.norn" + ], + "projectionAdapter": "NornGraphUnitySceneProjectionAdapter" + }, + { + "pluginId": "tex.math", + "capabilities": [ + "embed.tex", + "tex.inline", + "tex.block", + "tex.scene-placement" + ], + "projectionAdapter": "TeXMathUnitySceneProjectionAdapter" + } + ], + "unsupportedPlugins": [ + + ], + "worldSurfaceLowering": [ + { + "targetId": "unity-scene", + "surfaceKinds": [ + "interactive-world" + ], + "projectionKinds": [ + "provider-authored-world-surface" + ], + "supportLevel": "unity-playable-world-scene-command-surface", + "ownership": "runtime-lowers-provider-world-surface-without-owning-world-state", + "evidencePaths": [ + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldAssetManifest.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs", + "packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs" + ] + } + ], + "commandTransport": { + "schema": "gamecult.eve.command.v1", + "status": "provider-shell-contract-skeleton", + "emitter": "EveUnitySceneSurfaceLowerer.CreateCommandIntent", + "clientId": "unity-scene", + "authority": "Unity scene input emits command intents through provider-advertised command boundaries; providers accept, deny, and publish receipts." + }, + "lifecycle": { + "release": { + "ownerRepo": "EveUnity", + "status": "owner-repo-upm-artifact", + "releaseContract": { + "ownerRepo": "EveUnity", + "packageName": "org.gamecult.eve.unity-scene", + "packageRoot": "packages/org.gamecult.eve.unity-scene", + "versionSource": "packages/org.gamecult.eve.unity-scene/package.json", + "tagPattern": "eveunity-scene-v{version}", + "artifactKind": "upm-package", + "requestSchema": "gamecult.eve.runtime_release_request.v1", + "requestBuilder": "tools/eveunity/eveunity-release-contract.mjs", + "artifactBuilder": "tools/eveunity/eveunity-release-artifact.mjs", + "artifactPattern": "artifacts/eveunity-scene-release/{version}/org.gamecult.eve.unity-scene-{version}.tgz", + "publishProof": "Tagged Unity scene package release is created from the EveUnity repository.", + "requiredPackageDependencies": [ + { + "packageName": "org.gamecult.eve.surface", + "version": "0.1.0", + "packageManager": "upm", + "ownerRepo": "Eve", + "purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents." + } + ] + }, + "evidencePaths": [ + "packages/org.gamecult.eve.unity-scene/package.json", + "packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef", + "scripts/pack.ps1" + ], + "pendingProofs": [ + "Cut and publish the first tagged EveUnity package release." + ] + }, + "test": { + "ownerRepo": "EveUnity", + "status": "unity-scene-editmode-batchmode", + "testContract": { + "ownerRepo": "EveUnity", + "runnerKind": "unity-editmode-batchmode", + "runnerScript": "scripts/run-package-tests.ps1", + "consumerProject": "E:\\Projects\\Aetheria", + "packageName": "org.gamecult.eve.unity-scene", + "testAssembly": "GameCult.Eve.UnityScene.Tests", + "testPlatform": "EditMode", + "resultsArtifact": "artifacts/aetheria-unity-scene-editmode/{stamp}/unity-editmode-results.xml", + "logArtifact": "artifacts/aetheria-unity-scene-editmode/{stamp}/unity-editmode.log", + "manifestMutation": "Temporarily adds org.gamecult.eve.unity-scene to Aetheria Unity testables, runs GameCult.Eve.UnityScene.Tests, and restores Packages/manifest.json." + }, + "evidencePaths": [ + "packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef", + "packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs", + "scripts/run-package-tests.ps1" + ], + "pendingProofs": [ + "Move the Aetheria consumer scenario into a provider-owned conformance pack that installs EveUnity from its owner repository." + ] + }, + "capture": { + "ownerRepo": "EveUnity", + "status": "pending-unity-frame-capture", + "captureContract": { + "ownerRepo": "EveUnity", + "runtimeId": "unity-scene", + "targetId": "unity-scene", + "requestSchema": "gamecult.eve.runtime_capture_request.v1", + "requestBuilder": "tools/eveunity/eveunity-capture-contract.mjs", + "advertisementPath": "GameCult/Aetheria:web/fixtures/aetheria.provider-advertisement.json", + "captureKind": "unity-scene-frame-png", + "artifactKind": "png", + "artifactPattern": "artifacts/eveunity-scene-capture/{stamp}/unity-scene.png", + "conformanceAttachment": "runtime.captureArtifacts[]", + "requiredProvider": "aetheria", + "requiredSurface": "aetheria.daemon.game", + "additionalProviderSurfaces": [ + { + "providerId": "eve.world-smoke", + "surfaceId": "eve.world-smoke.surface", + "purpose": "Generic non-product interactive-world capture control." + } + ], + "authority": "Capture observes Unity scene projection only; provider state, command acceptance, and receipts stay provider-owned.", + "publishProof": "Unity scene screenshot or frame-capture PNG artifact is attached to the runtime conformance report from EveUnity." + }, + "evidencePaths": [ + + ], + "pendingProofs": [ + "Unity scene screenshot or frame-capture PNG artifact is attached to the runtime conformance report." + ] + } + }, + "capture": { + "status": "pending-unity-frame-capture", + "note": "A real Unity PNG/frame capture driven by a provider surface remains active lifecycle work.", + "artifacts": [ + + ] + } +} diff --git a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json.meta b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json.meta new file mode 100644 index 0000000..b672c6f --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 16a73a75d0939424bad58c752f0f8a3e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json new file mode 100644 index 0000000..bdd7c4f --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -0,0 +1,18 @@ +{ + "name": "org.gamecult.eve.unity-scene", + "displayName": "Eve Unity Scene Lowering", + "version": "0.1.0", + "unity": "2021.3", + "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", + "dependencies": { + "org.gamecult.eve.surface": "0.1.0" + }, + "keywords": [ + "Eve", + "Unity", + "Scene", + "Interactive World" + ], + "license": "MPL-2.0", + "category": "Scripting" +} diff --git a/packages/org.gamecult.eve.unity-scene/package.json.meta b/packages/org.gamecult.eve.unity-scene/package.json.meta new file mode 100644 index 0000000..f41eec4 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8eaedcd0b70a6574c8ddb12c84b444a5 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/pack.ps1 b/scripts/pack.ps1 new file mode 100644 index 0000000..05d86cb --- /dev/null +++ b/scripts/pack.ps1 @@ -0,0 +1,27 @@ +param( + [string] $OutputRoot = "artifacts\packages" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$packageRoot = Join-Path $repoRoot "packages\org.gamecult.eve.unity-scene" +$output = if ([System.IO.Path]::IsPathRooted($OutputRoot)) { + $OutputRoot +} else { + Join-Path $repoRoot $OutputRoot +} + +New-Item -ItemType Directory -Force -Path $output | Out-Null +$packed = npm pack $packageRoot --pack-destination $output +if ($LASTEXITCODE -ne 0) { + throw "EveUnity UPM package failed to pack" +} + +$artifact = Join-Path $output ($packed | Select-Object -Last 1) +if (-not (Test-Path -LiteralPath $artifact)) { + throw "EveUnity package artifact was not produced: $artifact" +} + +Write-Host "EveUnity package: $artifact" + diff --git a/scripts/run-package-tests.ps1 b/scripts/run-package-tests.ps1 new file mode 100644 index 0000000..ad6e052 --- /dev/null +++ b/scripts/run-package-tests.ps1 @@ -0,0 +1,74 @@ +param( + [string] $AetheriaRoot = "E:\Projects\Aetheria", + [string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe", + [string] $OutputRoot = "artifacts\aetheria-consumer" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$packageRoot = Join-Path $repoRoot "packages\org.gamecult.eve.unity-scene" +$manifestPath = Join-Path $AetheriaRoot "Packages\manifest.json" +$output = if ([System.IO.Path]::IsPathRooted($OutputRoot)) { + $OutputRoot +} else { + Join-Path $repoRoot $OutputRoot +} + +foreach ($required in @($packageRoot, $manifestPath, $UnityExe)) { + if (-not (Test-Path -LiteralPath $required)) { + throw "Required EveUnity consumer-test path not found: $required" + } +} + +$stamp = Get-Date -Format "yyyyMMddTHHmmss" +$runRoot = Join-Path $output $stamp +New-Item -ItemType Directory -Force -Path $runRoot | Out-Null +$resultsPath = Join-Path $runRoot "unity-editmode-results.xml" +$logPath = Join-Path $runRoot "unity-editmode.log" +$originalManifest = Get-Content -Raw -LiteralPath $manifestPath + +try { + $manifest = $originalManifest | ConvertFrom-Json + $dependency = "file:" + ($packageRoot -replace '\\', '/') + $manifest.dependencies.'org.gamecult.eve.unity-scene' = $dependency + $testables = @($manifest.testables) | Where-Object { $_ } + if ($testables -notcontains 'org.gamecult.eve.unity-scene') { + $testables += 'org.gamecult.eve.unity-scene' + } + if ($manifest.PSObject.Properties.Name -contains 'testables') { + $manifest.testables = $testables + } else { + $manifest | Add-Member -NotePropertyName testables -NotePropertyValue $testables + } + $manifest | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $manifestPath + + $arguments = @( + '-batchmode', + '-projectPath', $AetheriaRoot, + '-runTests', + '-testPlatform', 'EditMode', + '-assemblyNames', 'GameCult.Eve.UnityScene.Tests', + '-testResults', $resultsPath, + '-logFile', $logPath + ) + $process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0) { + if (Test-Path -LiteralPath $logPath) { + Get-Content -LiteralPath $logPath -Tail 120 + } + throw "EveUnity consumer tests failed with exit code $($process.ExitCode)" + } +} finally { + Set-Content -NoNewline -LiteralPath $manifestPath -Value $originalManifest +} + +[xml] $results = Get-Content -Raw -LiteralPath $resultsPath +$run = $results.SelectSingleNode('//test-run') +if ($null -eq $run -or [int] $run.failed -gt 0) { + throw "EveUnity consumer tests did not pass: $resultsPath" +} + +Write-Host "EveUnity consumer tests: $($run.passed) passed, $($run.total) total" +Write-Host "Results: $resultsPath" +