diff --git a/TestProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef b/TestProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef index a7c4dd2..4d21cb3 100644 --- a/TestProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef +++ b/TestProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef @@ -4,14 +4,17 @@ "references": [ "GameCult.CultLib", "GameCult.Eve.Surface", - "GameCult.Eve.UnityScene" + "GameCult.Eve.UnityScene", + "GameCult.Eve.UnityScene.CultMesh" ], "includePlatforms": [], "excludePlatforms": [], "overrideReferences": true, "precompiledReferences": [ "GameCult.Caching.dll", - "GameCult.Caching.MessagePack.dll" + "GameCult.Caching.MessagePack.dll", + "GameCult.Mesh.dll", + "GameCult.Networking.dll" ], "autoReferenced": false, "optionalUnityReferences": [ diff --git a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 2d0feda..77bd210 100644 --- a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -161,6 +161,123 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests } } + [UnityTest] + public IEnumerator GenericCultMeshClientLowersAndMovesAdvertisedWorld() + { + var endpoint = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + Assert.Ignore("EVEUNITY_PROVIDER_ENDPOINT is not configured for the live provider witness."); + + var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? "aetheria.daemon"; + var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? "aetheria.game"; + var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ?? + Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc"); + var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ?? + Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png"); + var root = new GameObject("Generic Eve World Projection"); + var cameraObject = new GameObject("Generic Eve Capture Camera"); + var lightObject = new GameObject("Generic Eve World Light"); + RenderTexture target = null; + Texture2D pixels = null; + EveUnityCultMeshLiveProviderTransport transport = null; + EveUnitySceneLiveProviderBridge bridge = null; + EveUnityPlayableWorldRuntime runtime = null; + + try + { + transport = new EveUnityCultMeshLiveProviderTransport( + replicaPath, endpoint, providerId, surfaceId, $"eve-unity-test-{Guid.NewGuid():N}"); + var publicationDeadline = Time.realtimeSinceStartup + 10f; + while (true) + { + var published = false; + try + { + transport.Refresh(); + published = true; + } + catch (InvalidOperationException) when (Time.realtimeSinceStartup < publicationDeadline) + { + } + if (published) break; + yield return new WaitForSecondsRealtime(0.1f); + } + bridge = new EveUnitySceneLiveProviderBridge(transport); + bridge.Connect(); + runtime = EveUnityPlayableWorldRuntime.CreateForGameObjectScene( + root.transform, bridge, bridge, bridge, bridge); + var initial = runtime.Connect(); + Assert.That(initial.ActiveEntities, Is.GreaterThan(0)); + Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty); + + var playerId = runtime.ActiveWorld.PlayerEntityId; + var marker = FindMarker(root, playerId); + var initialPosition = marker.transform.position; + var initialVersion = runtime.ActiveVersion; + var request = runtime.SubmitMoveVectorIntent(playerId, 1f, 0f, 1f); + + var deadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < deadline && + (runtime.LastReceipt == null || runtime.ActiveVersion <= initialVersion || + Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition) < 0.01f)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + + Assert.That(runtime.LastReceipt, Is.Not.Null); + Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(request.CommandId)); + Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(initialVersion)); + Assert.That(runtime.ActiveVersion, Is.GreaterThan(initialVersion)); + Assert.That(Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition), Is.GreaterThan(0.01f)); + + var light = lightObject.AddComponent(); + light.type = LightType.Directional; + light.intensity = 1.4f; + lightObject.transform.rotation = Quaternion.Euler(45f, -35f, 0f); + var camera = cameraObject.AddComponent(); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f); + camera.transform.position = new Vector3(14f, 12f, -16f); + camera.transform.LookAt(FindMarker(root, playerId).transform.position); + target = new RenderTexture(640, 360, 24, RenderTextureFormat.ARGB32); + camera.targetTexture = target; + yield return null; + camera.Render(); + RenderTexture.active = target; + pixels = new Texture2D(640, 360, TextureFormat.RGB24, false); + pixels.ReadPixels(new Rect(0, 0, 640, 360), 0, 0); + pixels.Apply(); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(capturePath))); + File.WriteAllBytes(capturePath, pixels.EncodeToPNG()); + Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(1024)); + } + finally + { + RenderTexture.active = null; + var camera = cameraObject.GetComponent(); + if (camera != null) camera.targetTexture = null; + if (target != null) UnityEngine.Object.DestroyImmediate(target); + if (pixels != null) UnityEngine.Object.DestroyImmediate(pixels); + runtime?.Dispose(); + bridge?.Dispose(); + transport?.Dispose(); + UnityEngine.Object.DestroyImmediate(lightObject); + UnityEngine.Object.DestroyImmediate(cameraObject); + UnityEngine.Object.DestroyImmediate(root); + } + } + + private static EveUnityPlayableWorldEntityMarker FindMarker(GameObject root, string entityId) + { + foreach (var marker in root.GetComponentsInChildren()) + { + if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal)) + return marker; + } + throw new InvalidOperationException($"Projected world has no entity marker '{entityId}'."); + } + private static EveUnitySceneProviderSurfaceDocument WorldDocument() { var entities = new[] diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index 046dd1d..0cfe881 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -35,6 +35,7 @@ "depth": 0, "source": "local", "dependencies": { + "org.gamecult.cultlib": "1.0.3", "org.gamecult.eve.surface": "0.1.0" } }, diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh.meta new file mode 100644 index 0000000..6c11a6b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 47197212764bfe04192e7b6a7e6f96a0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs new file mode 100644 index 0000000..00b097e --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using GameCult.Eve.Surface; +using GameCult.Mesh; +using GameCult.Networking; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityCultMeshLiveProviderTransport : + IEveUnitySceneLiveProviderTransport, + IDisposable + { + private const string RemoteShardId = "provider"; + + private static readonly Type[] WireDocumentTypes = + { + typeof(EveProviderAdvertisementDocument), + typeof(EveSurfaceDocument), + typeof(EveSurfaceCommandRequest), + typeof(EveCommandReceiptDocument) + }; + + private readonly string _replicaPath; + private readonly string _endpoint; + private readonly string _providerId; + private readonly string _surfaceId; + private readonly string _runtimeId; + private readonly HashSet _pendingCommandIds = new HashSet(StringComparer.Ordinal); + private readonly HashSet _publishedReceiptIds = new HashSet(StringComparer.Ordinal); + private CultMeshNode? _node; + private CultMeshSnapshotEndpoint? _snapshot; + private CultNetDocumentRegistry? _networkRegistry; + private CultNetShardDescriptor? _replicaShard; + private EveProviderAdvertisementDocument? _advertisement; + private EveAdvertisedSurface? _advertisedSurface; + + public EveUnityCultMeshLiveProviderTransport( + string replicaPath, + string endpoint, + string providerId, + string surfaceId, + string runtimeId = "eve-unity") + { + _replicaPath = string.IsNullOrWhiteSpace(replicaPath) + ? throw new ArgumentException("Replica path must be non-empty.", nameof(replicaPath)) + : Path.GetFullPath(replicaPath); + _endpoint = string.IsNullOrWhiteSpace(endpoint) + ? throw new ArgumentException("CultMesh endpoint must be non-empty.", nameof(endpoint)) + : endpoint.Trim(); + _providerId = string.IsNullOrWhiteSpace(providerId) + ? throw new ArgumentException("Provider id must be non-empty.", nameof(providerId)) + : providerId.Trim(); + _surfaceId = string.IsNullOrWhiteSpace(surfaceId) + ? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId)) + : surfaceId.Trim(); + _runtimeId = string.IsNullOrWhiteSpace(runtimeId) ? "eve-unity" : runtimeId.Trim(); + CurrentSurfaceDocument = EmptySurfaceDocument(); + CurrentAssetManifestDocument = EmptyAssetManifest(); + } + + public string TransportKind => "eve-cultmesh-remote-replica"; + + public string SurfacePointer => CurrentSurfaceDocument.SourcePointer; + + public string AssetManifestPointer => CurrentAssetManifestDocument.ManifestRef; + + public EveUnitySceneProviderSurfaceDocument CurrentSurfaceDocument { get; private set; } + + public EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; private set; } + + public event Action? SurfaceDocumentAvailable; + + public event Action? AssetManifestDocumentAvailable; + + public event Action? CommandReceiptAvailable; + + public void Connect() + { + EnsureOpen(); + Refresh(); + } + + public void Disconnect() + { + } + + public void Refresh() + { + EnsureOpen(); + ResolveAdvertisement(); + RefreshSurface(); + RefreshReceipts(); + } + + public void SubmitCommand(EveSurfaceCommandRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + EnsureOpen(); + ResolveAdvertisement(); + var interaction = RequireWorldInteraction(); + if (string.IsNullOrWhiteSpace(interaction.CommandRecordRef)) + throw new InvalidOperationException("The provider advertisement does not publish a command record reference."); + if (!string.Equals(request.ProviderId, _providerId, StringComparison.Ordinal) || + !string.Equals(request.SurfaceId, _surfaceId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The command does not target the connected provider surface."); + } + + var commandId = request.CommandId; + if (string.IsNullOrWhiteSpace(commandId)) + throw new InvalidOperationException("Eve command invocations require an idempotency key."); + + var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId); + var message = _networkRegistry!.CreateRawDocumentPutMessage( + $"eve-unity-{commandId}", + new CultRecordHandle(new CultRecordKey(recordKey)), + request, + new CultNetDocumentMessageOptions + { + SourceRuntimeId = _runtimeId, + SourceRole = "eve-unity" + }); + new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions()) + .ForwardPutAsync(_replicaShard!, message) + .GetAwaiter() + .GetResult(); + _pendingCommandIds.Add(commandId); + } + + public void Dispose() + { + _node?.Dispose(); + _node = null; + _snapshot = null; + _networkRegistry = null; + _replicaShard = null; + _pendingCommandIds.Clear(); + _publishedReceiptIds.Clear(); + } + + private void EnsureOpen() + { + if (_node != null) + return; + + var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(WireDocumentTypes); + _networkRegistry = CultMesh.CreateCultNetDocumentRegistry(WireDocumentTypes, cacheRegistry); + _replicaShard = new CultNetShardDescriptor( + RemoteShardId, + _providerId, + epoch: 1, + isPrimary: false, + schemaIds: WireDocumentTypes.Select(type => cacheRegistry.GetRequired(type).SchemaId), + primaryEndpoints: new[] { _endpoint }); + _node = CultMesh.CreateNodeAsync( + _replicaPath, + new CultMeshNodeOptions + { + StartServer = false, + EnableDurableShardLogs = true, + CacheOptions = new CultCacheOpenOptions + { + Registry = cacheRegistry, + PullOnOpen = false, + StoreFlushOnDispose = true, + UseDirectoryStore = true + }, + DatabaseOptions = new CultNetDatabaseOptions + { + RuntimeId = _runtimeId, + Shards = new[] { _replicaShard }, + DocumentRegistry = _networkRegistry + } + }) + .GetAwaiter() + .GetResult(); + _snapshot = CultMesh.SnapshotEndpoint( + _endpoint, + new CultMeshSnapshotEndpointOptions + { + Context = CultMesh.Verse("eve.remote", _runtimeId).Context, + DocumentRegistry = _networkRegistry, + Request = new CultMeshSnapshotRequestOptions + { + ShardId = RemoteShardId, + ShardEpoch = 1, + ConnectTimeout = TimeSpan.FromSeconds(5), + ResponseTimeout = TimeSpan.FromSeconds(10), + MessageIdPrefix = "eve-unity", + RudpRuntimeId = _runtimeId, + RudpMaxFragmentBytes = 1200 + } + }); + } + + private void ResolveAdvertisement() + { + var documents = _snapshot! + .FetchDocumentsAsync() + .GetAwaiter() + .GetResult(); + _advertisement = documents.FirstOrDefault(document => + string.Equals(document.ProviderId, _providerId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Provider '{_providerId}' did not publish an Eve advertisement."); + _advertisedSurface = _advertisement.Surfaces.FirstOrDefault(surface => + string.Equals(surface.SurfaceId, _surfaceId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Provider '{_providerId}' did not advertise surface '{_surfaceId}'."); + if (!string.Equals(_advertisedSurface.Transport, "cultmesh-record", StringComparison.Ordinal)) + throw new InvalidOperationException("EveUnity CultMesh transport requires a cultmesh-record surface advertisement."); + if (string.IsNullOrWhiteSpace(_advertisedSurface.RecordRef)) + throw new InvalidOperationException("The provider surface advertisement does not publish a record reference."); + } + + private void RefreshSurface() + { + var surface = _snapshot! + .FetchDocumentsAsync( + recordKeys: new[] { _advertisedSurface!.RecordRef }, + schemaIds: new[] { EveSurfaceDocument.SchemaId }) + .GetAwaiter() + .GetResult() + .FirstOrDefault() + ?? throw new InvalidOperationException("The advertised Eve surface record was not available."); + var interaction = RequireWorldInteraction(); + CurrentSurfaceDocument = new EveUnitySceneProviderSurfaceDocument( + surface, + new EveUnitySceneProviderSurfaceAdvertisement( + _advertisedSurface.SurfaceId, + _advertisedSurface.SurfaceKind, + new EveUnitySceneWorldInteraction( + interaction.ProjectionKind, + interaction.CommandBoundary, + interaction.ReceiptSchema, + interaction.Ownership)), + _advertisedSurface.RecordRef, + surface.Version); + SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument); + AssetManifestDocumentAvailable?.Invoke(CurrentAssetManifestDocument); + } + + private void RefreshReceipts() + { + if (_pendingCommandIds.Count == 0) + return; + + var receipts = _snapshot! + .FetchDocumentsAsync() + .GetAwaiter() + .GetResult(); + foreach (var receipt in receipts + .Where(receipt => _pendingCommandIds.Contains(receipt.CommandId)) + .OrderBy(receipt => receipt.SourceVersion)) + { + if (!_publishedReceiptIds.Add(receipt.ReceiptId)) + continue; + + CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt( + receipt.ReceiptId, + receipt.Command, + receipt.CommandId, + receipt.State, + receipt.OwnerRepo, + receipt.Authority, + receipt.Schema, + receipt.ProviderId, + receipt.SurfaceId, + receipt.Message, + DateTimeOffset.TryParse(receipt.IssuedAtUtc, out var issuedAt) ? issuedAt : null, + receipt.SourceVersion)); + if (string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) || + string.Equals(receipt.State, "denied", StringComparison.OrdinalIgnoreCase) || + string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) + { + _pendingCommandIds.Remove(receipt.CommandId); + } + } + } + + private EveWorldInteractionAdvertisement RequireWorldInteraction() + { + return _advertisedSurface?.WorldInteraction + ?? throw new InvalidOperationException("The advertised surface has no interactive-world contract."); + } + + private EveUnitySceneProviderSurfaceDocument EmptySurfaceDocument() + { + var surface = new EveSurfaceDocument( + _providerId, + "", + "", + 0, + "", + new EveSurfaceTree( + _surfaceId, + new EveSurfaceComponent( + "root", + "surface", + new Dictionary(), + Array.Empty()), + Array.Empty()), + Array.Empty()); + return new EveUnitySceneProviderSurfaceDocument( + surface, + new EveUnitySceneProviderSurfaceAdvertisement( + _surfaceId, + "interactive-world", + new EveUnitySceneWorldInteraction("", "", EveCommandReceiptDocument.SchemaId, "")), + "", + 0); + } + + private EveUnityPlayableWorldAssetManifestDocument EmptyAssetManifest() + { + return new EveUnityPlayableWorldAssetManifestDocument( + "", + Array.Empty(), + _providerId); + } + + private static string ChildRecordKey(string parent, string child) + { + return $"{parent.TrimEnd(':', '/')}:{child}"; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs.meta new file mode 100644 index 0000000..1ce64a7 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c11a1170aa79f19448d38816e0b4080a \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef new file mode 100644 index 0000000..034cdf8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef @@ -0,0 +1,22 @@ +{ + "name": "GameCult.Eve.UnityScene.CultMesh", + "rootNamespace": "GameCult.Eve.UnityScene", + "references": [ + "GameCult.Eve.Surface", + "GameCult.Eve.UnityScene" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "GameCult.Caching.dll", + "GameCult.Caching.MessagePack.dll", + "GameCult.Mesh.dll", + "GameCult.Networking.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef.meta new file mode 100644 index 0000000..6dd3760 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9455c0bf8c92c1f4b80f60572332c88e +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index bdd7c4f..0e18f0e 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -5,6 +5,7 @@ "unity": "2021.3", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { + "org.gamecult.cultlib": "1.0.3", "org.gamecult.eve.surface": "0.1.0" }, "keywords": [ diff --git a/scripts/run-aetheria-daemon-world-witness.ps1 b/scripts/run-aetheria-daemon-world-witness.ps1 new file mode 100644 index 0000000..726c6a8 --- /dev/null +++ b/scripts/run-aetheria-daemon-world-witness.ps1 @@ -0,0 +1,89 @@ +param( + [string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe", + [string] $AetheriaRoot = "E:\Projects\Aetheria", + [string] $CultLibRoot = "E:\Projects\CultLib", + [int] $Port = 3076, + [string] $OutputDirectory = "artifacts\aetheria-daemon" +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$projectRoot = Join-Path $repoRoot "TestProject" +$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory } +$resultsPath = Join-Path $outputRoot "results.xml" +$unityLogPath = Join-Path $outputRoot "unity.log" +$daemonLogPath = Join-Path $outputRoot "aetheria-daemon.log" +$capturePath = Join-Path $outputRoot "aetheria-daemon-world.png" +$replicaPath = Join-Path $outputRoot "eve-unity-replica.cc" +$statePath = Join-Path $outputRoot "aetheria-witness-state.cc" +$daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj" + +foreach ($required in @($UnityExe, $projectRoot, $daemonProject, (Join-Path $CultLibRoot "scripts\build-unity-package.ps1"))) { + if (-not (Test-Path -LiteralPath $required)) { throw "Required world witness path not found: $required" } +} + +New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null +foreach ($ephemeralPath in @($replicaPath, $statePath)) { + $resolved = [IO.Path]::GetFullPath($ephemeralPath) + if (-not $resolved.StartsWith([IO.Path]::GetFullPath($outputRoot), [StringComparison]::OrdinalIgnoreCase)) { + throw "Witness cleanup escaped its artifact directory: $resolved" + } + if (Test-Path -LiteralPath $resolved) { Remove-Item -LiteralPath $resolved -Recurse -Force } +} +powershell -ExecutionPolicy Bypass -File (Join-Path $CultLibRoot "scripts\build-unity-package.ps1") +if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" } + +$daemonArguments = @( + "run", "--project", $daemonProject, "--", + "--root", $AetheriaRoot, + "--state", $statePath, + "--client-cultmesh-host", "127.0.0.1", + "--client-cultmesh-advertise-host", "127.0.0.1", + "--client-cultmesh-port", $Port, + "--no-odin-announcements" +) +$daemon = Start-Process -FilePath "dotnet" -ArgumentList $daemonArguments -PassThru -WindowStyle Hidden ` + -RedirectStandardOutput $daemonLogPath -RedirectStandardError (Join-Path $outputRoot "aetheria-daemon.error.log") +Write-Host "Aetheria daemon PID: $($daemon.Id)" +Write-Host "Daemon log: $daemonLogPath" +Write-Host "Poll: Select-String -Path '$daemonLogPath' -Pattern 'Aetheria client CultMesh endpoint'" + +try { + $ready = $false + for ($attempt = 0; $attempt -lt 60; $attempt++) { + if ($daemon.HasExited) { throw "Aetheria daemon exited before publishing CultMesh. See $daemonLogPath" } + if ((Test-Path $daemonLogPath) -and + (Select-String -Path $daemonLogPath -Pattern "Aetheria client CultMesh endpoint: rudp://127.0.0.1:$Port" -Quiet)) { $ready = $true; break } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw "Aetheria daemon did not open CultMesh port $Port. See $daemonLogPath" } + + $env:EVEUNITY_PROVIDER_ENDPOINT = "rudp://127.0.0.1:$Port" + $env:EVEUNITY_PROVIDER_ID = "aetheria.daemon" + $env:EVEUNITY_SURFACE_ID = "aetheria.game" + $env:EVEUNITY_REPLICA_PATH = $replicaPath + $env:EVEUNITY_AETHERIA_CAPTURE_PATH = $capturePath + $arguments = @( + "-batchmode", "-projectPath", $projectRoot, + "-runTests", "-testPlatform", "PlayMode", + "-assemblyNames", "GameCult.EveUnity.GenericClient.PlayModeTests", + "-testFilter", "GenericCultMeshClientLowersAndMovesAdvertisedWorld", + "-testResults", $resultsPath, "-logFile", $unityLogPath + ) + $unity = Start-Process -FilePath $UnityExe -ArgumentList $arguments -PassThru -WindowStyle Hidden + if (-not $unity.WaitForExit(90000)) { + Stop-Process -Id $unity.Id -Force -ErrorAction SilentlyContinue + throw "Unity witness exceeded 90 seconds. See $unityLogPath" + } + if ($unity.ExitCode -ne 0) { Get-Content $unityLogPath -Tail 160; throw "Unity witness failed with exit code $($unity.ExitCode)" } + [xml] $results = Get-Content $resultsPath -Raw + $run = $results.SelectSingleNode("//test-run") + if ($null -eq $run -or [int]$run.passed -ne 1 -or [int]$run.failed -ne 0) { throw "Live world witness did not pass: $resultsPath" } + if (-not (Test-Path $capturePath) -or (Get-Item $capturePath).Length -lt 1024) { throw "Live world capture is missing: $capturePath" } + Write-Host "Aetheria daemon world witness: passed" + Write-Host "Capture: $capturePath" +} +finally { + if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process -Id $daemon.Id -Force } + Remove-Item Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_AETHERIA_CAPTURE_PATH -ErrorAction SilentlyContinue +}