From 65f1534b9da2061339243e2a2875387356db8901 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 01:23:42 +0100 Subject: [PATCH] Make live world proof realtime and receipt-correlated --- .../PlayMode/GenericWorldCaptureTests.cs | 127 +++++++++++++----- .../Runtime/EveCultMeshSurfaceSelection.cs | 27 +++- .../EveUnityCultMeshLiveProviderTransport.cs | 15 ++- scripts/run-aetheria-daemon-world-witness.ps1 | 12 +- 4 files changed, 143 insertions(+), 38 deletions(-) diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 5b3e278..ac11694 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -186,8 +186,11 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests WitnessReceipt movementReceipt = null; WitnessReceipt focusReceipt = null; WitnessReceipt actionReceipt = null; + EveUnityShotReceipt combatShot = null; + string combatActionId = ""; long initialVersion = 0; float movementDistance = 0f; + var commandReceipts = new Dictionary(StringComparer.Ordinal); try { @@ -199,7 +202,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests surfaceId, requiredSurfaceKind: "interactive-world", clientRuntimeId: $"eve-unity-test-{Guid.NewGuid():N}"); - var publicationDeadline = Time.realtimeSinceStartup + 10f; + var publicationDeadline = Time.realtimeSinceStartup + 30f; while (true) { var published = false; @@ -225,6 +228,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests null, provider, provider); + runtime.ReceiptAvailable += receipt => commandReceipts[receipt.CommandId] = receipt; var initial = runtime.Connect(); Assert.That(initial.ActiveEntities, Is.GreaterThan(0)); Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty); @@ -241,67 +245,84 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var deadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < deadline && - (runtime.LastReceipt == null || runtime.ActiveVersion <= initialVersion || + (!commandReceipts.TryGetValue(request.CommandId, out var observedMovement) || + runtime.ActiveVersion < observedMovement.SourceVersion || 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(commandReceipts.TryGetValue(request.CommandId, out var movementCommandReceipt), Is.True); Assert.That( - runtime.LastReceipt.State, + movementCommandReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), - runtime.LastReceipt.Message); - Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(initialVersion)); - Assert.That(runtime.ActiveVersion, Is.GreaterThan(initialVersion)); + movementCommandReceipt.Message); + Assert.That(movementCommandReceipt.SourceVersion, Is.GreaterThan(initialVersion)); + Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(movementCommandReceipt.SourceVersion)); movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition); Assert.That(movementDistance, Is.GreaterThan(0.01f)); - movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt); + movementReceipt = WitnessReceipt.From("movement", movementCommandReceipt); var movementVersion = runtime.ActiveVersion; var focus = runtime.SubmitFocusIntent(playerId); var focusDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < focusDeadline && - (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != focus.CommandId || - runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + (!commandReceipts.TryGetValue(focus.CommandId, out var observedFocus) || + runtime.ActiveVersion < observedFocus.SourceVersion)) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } - Assert.That(runtime.LastReceipt, Is.Not.Null); - Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(focus.CommandId)); - Assert.That(runtime.LastReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled")); - Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(movementVersion)); - focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt); + Assert.That(commandReceipts.TryGetValue(focus.CommandId, out var focusCommandReceipt), Is.True); + Assert.That(focusCommandReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled")); + Assert.That(focusCommandReceipt.SourceVersion, Is.GreaterThan(movementVersion)); + focusReceipt = WitnessReceipt.From("targeting", focusCommandReceipt); var focusVersion = runtime.ActiveVersion; + var trajectoryRenderer = root.AddComponent(); + runtime.ShotAvailable += shot => + { + var playerIdentity = provider.CurrentEntityView?.Identities + .FirstOrDefault(identity => string.Equals(identity.EntityId, playerId, StringComparison.Ordinal)); + if (playerIdentity != null && shot.SourceEntityIndex == playerIdentity.EntityIndex) + { + combatShot = shot; + trajectoryRenderer.Present(shot); + } + }; Assert.That(provider.CurrentInputCapability, Is.Not.Null, "The pilot world did not advertise a typed input capability document."); Assert.That(provider.CurrentInputCapability.Actions, Is.Not.Empty, "The pilot input capability advertised no available actions."); var advertisedAction = provider.CurrentInputCapability.Actions - .FirstOrDefault(candidate => string.Equals(candidate.Category, "weapon-group", StringComparison.Ordinal) && - string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)) - ?? provider.CurrentInputCapability.Actions - .First(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal)); + .First(candidate => string.Equals(candidate.Category, "weapon-group", StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)); + combatActionId = advertisedAction.ActionId; var action = runtime.SubmitActionIntent(playerId, advertisedAction.ActionId); var actionDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < actionDeadline && - (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId || - runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + (!commandReceipts.TryGetValue(action.CommandId, out var observedAction) || + runtime.ActiveVersion < observedAction.SourceVersion || combatShot == null)) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } - Assert.That(runtime.LastReceipt, Is.Not.Null); - Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(action.CommandId)); + Assert.That(commandReceipts.TryGetValue(action.CommandId, out var actionCommandReceipt), Is.True); Assert.That( - runtime.LastReceipt.State, + actionCommandReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), - runtime.LastReceipt.Message); - Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(focusVersion)); - Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); - actionReceipt = WitnessReceipt.From("action", runtime.LastReceipt); + actionCommandReceipt.Message); + Assert.That(actionCommandReceipt.SourceVersion, Is.GreaterThan(focusVersion)); + Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(actionCommandReceipt.SourceVersion)); + Assert.That(combatShot, Is.Not.Null, + $"The daemon accepted fire but published no shot receipt through Eve. Weapon state: {DescribeWeaponStates(provider)}"); + Assert.That(combatShot.ShotId, Is.Not.Empty); + var playerEntityIndex = provider.CurrentEntityView.Identities + .Single(identity => string.Equals(identity.EntityId, playerId, StringComparison.Ordinal)).EntityIndex; + Assert.That(combatShot.SourceEntityIndex, Is.EqualTo(playerEntityIndex)); + Assert.That(combatShot.TargetEntityIndex, Is.GreaterThanOrEqualTo(0)); + Assert.That(combatShot.PresentationKind, Is.Not.Empty); + Assert.That(trajectoryRenderer.ActiveTrajectoryCount, Is.GreaterThan(0)); + actionReceipt = WitnessReceipt.From("action", actionCommandReceipt); var light = lightObject.AddComponent(); light.type = LightType.Directional; @@ -350,7 +371,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests movementDistance, movementReceipt, focusReceipt, - actionReceipt); + actionReceipt, + combatActionId, + combatShot); } finally { @@ -372,7 +395,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests float movementDistance, WitnessReceipt movement, WitnessReceipt targeting, - WitnessReceipt action) + WitnessReceipt action, + string combatActionId, + EveUnityShotReceipt combatShot) { var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH"); if (string.IsNullOrWhiteSpace(path)) return; @@ -383,6 +408,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests movement = movement != null, targeting = targeting != null, action = action != null, + combatShot = combatShot != null, + combatActionId = combatActionId ?? "", + shotId = combatShot?.ShotId ?? "", + shotOutcome = combatShot?.Outcome ?? "", + shotPresentationKind = combatShot?.PresentationKind ?? "", + shotHit = combatShot?.Hit ?? false, initialVersion = initialVersion, finalVersion = finalVersion, movementDistance = movementDistance, @@ -400,6 +431,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public bool movement; public bool targeting; public bool action; + public bool combatShot; + public string combatActionId; + public string shotId; + public string shotOutcome; + public string shotPresentationKind; + public bool shotHit; public long initialVersion; public long finalVersion; public float movementDistance; @@ -436,6 +473,34 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests throw new InvalidOperationException($"Projected world has no entity marker '{entityId}'."); } + private static string DescribeWeaponStates(EveUnityCultMeshPlayableWorldProvider provider) + { + return string.Join("; ", Flatten(provider.CurrentDocument.SurfaceDocument.Surface.Root) + .Where(node => string.Equals(node.Kind, "weapon.state", StringComparison.Ordinal)) + .Select(node => string.Join(",", new[] + { + Prop(node, "behaviorKind"), + $"pending={Prop(node, "triggerPending")}", + $"firing={Prop(node, "firing")}", + $"charging={Prop(node, "charging")}", + $"charged={Prop(node, "charged")}", + $"lock={Prop(node, "lockProgress")}", + $"target={Prop(node, "targetEntityId")}", + $"refusal={Prop(node, "lastRefusalReason")}" + }))); + } + + private static string Prop(EveSurfaceComponent node, string key) => + node.Props.TryGetValue(key, out var value) ? value : ""; + + private static IEnumerable Flatten(EveSurfaceComponent root) + { + yield return root; + foreach (var child in root.Children) + foreach (var descendant in Flatten(child)) + yield return descendant; + } + private static EveUnitySceneProviderSurfaceDocument WorldDocument() { var entities = new[] diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs index 9c6f82f..6fc6948 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs @@ -48,9 +48,23 @@ namespace GameCult.Eve.Surface { if (mesh == null) throw new ArgumentNullException(nameof(mesh)); var resolved = request ?? new EveSurfaceRequest(); - var providers = await mesh.CollectionAsync(endpointId, cancellationToken) - .ConfigureAwait(false); - var match = Select(await providers.LatestAsync().ConfigureAwait(false), resolved); + IReadOnlyList providerDocuments; + if (string.IsNullOrWhiteSpace(resolved.ProviderId)) + { + var providers = await mesh.CollectionAsync(endpointId, cancellationToken) + .ConfigureAwait(false); + providerDocuments = await providers.LatestAsync().ConfigureAwait(false); + } + else + { + var provider = await mesh.DocumentAsync( + endpointId, + ProviderAdvertisementRecordRef(resolved.ProviderId), + cancellationToken) + .ConfigureAwait(false); + providerDocuments = new[] { await provider.LatestAsync().ConfigureAwait(false) }; + } + var match = Select(providerDocuments, resolved); IReadOnlyList plugins = Array.Empty(); if (match.Surface.RequiresPlugins.Count > 0) { @@ -133,5 +147,12 @@ namespace GameCult.Eve.Surface private static bool Matches(string expected, string actual) => string.IsNullOrWhiteSpace(expected) || string.Equals(expected, actual, StringComparison.Ordinal); + + public static string ProviderAdvertisementRecordRef(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + throw new ArgumentException("Provider id must be non-empty.", nameof(providerId)); + return $"eve:provider:{providerId.Trim()}"; + } } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index c74bff9..dcc0c1c 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -56,6 +56,7 @@ namespace GameCult.Eve.UnityScene private bool _liveSubscriptionsOpen; private EveProviderAdvertisementDocument? _advertisement; private EveAdvertisedSurface? _advertisedSurface; + private string _assetCatalogPointer = ""; public EveUnityCultMeshLiveProviderTransport( string replicaPath, @@ -123,6 +124,8 @@ namespace GameCult.Eve.UnityScene PublishReceipt(receipt); else if (document is EveInputCapabilityDocument inputCapability) CurrentInputCapability = inputCapability; + else if (document is EveAssetCatalogDocument assetCatalog) + ApplyAssetCatalog(assetCatalog); } } @@ -274,8 +277,18 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef)) return; - var catalog = _mesh.ReadAsync(_endpointId, interaction.AssetManifestRecordRef) + if (string.Equals(_assetCatalogPointer, interaction.AssetManifestRecordRef, StringComparison.Ordinal)) + return; + + _assetCatalogPointer = interaction.AssetManifestRecordRef; + var document = _mesh.DocumentAsync(_endpointId, _assetCatalogPointer) .GetAwaiter().GetResult(); + ApplyAssetCatalog(document.LatestAsync().GetAwaiter().GetResult()); + _liveWatches.Add(document.Watch(catalog => _liveDocuments.Enqueue(catalog))); + } + + private void ApplyAssetCatalog(EveAssetCatalogDocument catalog) + { if (catalog == null || catalog.Version == CurrentAssetCatalogVersion) return; diff --git a/scripts/run-aetheria-daemon-world-witness.ps1 b/scripts/run-aetheria-daemon-world-witness.ps1 index b56f14f..36561f0 100644 --- a/scripts/run-aetheria-daemon-world-witness.ps1 +++ b/scripts/run-aetheria-daemon-world-witness.ps1 @@ -101,12 +101,13 @@ $daemonArguments = @( "--client-cultmesh-host", "127.0.0.1", "--client-cultmesh-advertise-host", "127.0.0.1", "--client-cultmesh-port", $Port, - "--tick-interval-ms", 250, + "--tick-interval-ms", 20, "--fixed-delta-ms", 20, "--no-odin-announcements" ) $env:AETHERIA_TRACE_EVE_SNAPSHOTS = "1" $env:AETHERIA_TRACE_CLIENT_RUDP = "1" +$env:AETHERIA_TRACE_TICK_PHASES = "1" $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)" @@ -124,8 +125,8 @@ try { if (-not $ready) { throw "Aetheria daemon did not open CultMesh port $Port. See $daemonLogPath" } $env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port" - Remove-Item Env:EVEUNITY_PROVIDER_ID -ErrorAction SilentlyContinue - Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue + $env:EVEUNITY_PROVIDER_ID = "aetheria.daemon" + $env:EVEUNITY_SURFACE_ID = "aetheria.pilot" $env:EVEUNITY_REPLICA_PATH = $replicaPath $env:EVEUNITY_AETHERIA_CAPTURE_PATH = $capturePath $env:EVEUNITY_ASSET_CACHE_PATH = $assetCachePath @@ -149,6 +150,11 @@ try { if (-not (Test-Path $capturePath) -or (Get-Item $capturePath).Length -lt 1024) { throw "Live world capture is missing: $capturePath" } if (-not (Test-Path $factsPath)) { throw "Live world witness facts are missing: $factsPath" } $facts = Get-Content -LiteralPath $factsPath -Raw | ConvertFrom-Json + if (-not $facts.combatShot -or [string]::IsNullOrWhiteSpace([string]$facts.combatActionId) -or + [string]::IsNullOrWhiteSpace([string]$facts.shotId) -or + [string]::IsNullOrWhiteSpace([string]$facts.shotPresentationKind)) { + throw "Live world witness did not prove an advertised weapon action and authoritative shot presentation." + } $releaseLock = Get-Content -LiteralPath (Join-Path $repoRoot "ReleaseConsumerProject\Packages\packages-lock.json") -Raw | ConvertFrom-Json $releasedPackageClient = $projectRoot -eq "ReleaseConsumerProject" $facts | Add-Member -NotePropertyName releasedPackageClient -NotePropertyValue $releasedPackageClient