From 13e27e5572a06abef991dae35bbdb06859a12bf6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 03:15:17 +0100 Subject: [PATCH] Reconcile Unity commands from daemon facts --- Aetheria.State.Daemon/Program.cs | 4 - .../Tests/DaemonRuntimeDocumentTests.cs | 46 ++++++++++- .../README.md | 6 ++ .../AetheriaEveUnitySceneProviderBridge.cs | 77 +++++++++++++++++++ .../Runtime/AetheriaClient.cs | 7 ++ .../AetheriaRuntimeEveSurfaceCatalog.cs | 2 +- .../Runtime/AetheriaRuntimeVerseClient.cs | 9 ++- docs/aetheria-perfect-machine-map.md | 4 + 8 files changed, 145 insertions(+), 10 deletions(-) diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index d1aec1f4d..e2eaa5570 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -246,13 +246,9 @@ static async Task PublishCommittedCommandFactsAsync( options.VerseId)).ConfigureAwait(false); } - var policyRejectedIds = new HashSet( - policyRejectedCommandIds ?? Array.Empty(), - StringComparer.Ordinal); foreach (var commandId in frame.RejectedCommandIds ?? Array.Empty()) { if (string.IsNullOrWhiteSpace(commandId) || - !policyRejectedIds.Contains(commandId) || !byCommandId.TryGetValue(commandId, out var command)) continue; diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index ab6aac53a..31ef059f9 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -446,6 +446,44 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual("aetheria-remote-cultmesh-replica", bridge.TransportKind); } + [Test] + public void RemoteReceiptTrackerWaitsForAuthoritativeDaemonFrame() + { + var tracker = new AetheriaEveUnityRemoteReceiptTracker(); + var request = new EveSurfaceCommandRequest( + "aetheria.daemon", + AetheriaRuntimeDaemonGameSurfaceBuilder.SurfaceId, + CultMesh.OperationInvocation("aetheria.daemon.commands.SetMoveVector"), + CultMesh.OperationPayload(("directionX", "1.0"), ("directionY", "0.0")), + DateTimeOffset.Parse("2026-07-10T03:00:00Z"), + "unity-remote-test", + "aetheria.daemon.commands", + AetheriaRuntimeDaemonSchemas.Frame); + tracker.Track("move-remote-1", request); + + Assert.AreEqual( + 0, + tracker.Reconcile(Array.Empty(), 10).Count); + Assert.IsTrue(tracker.HasPending); + + var acceptedFrame = AetheriaRuntimeDaemonFrameDocument.Create( + RunWithTwoEntities(), "aetheria-daemon", "session", 11, 1.02, 0.02); + var fact = AetheriaRuntimeCommittedCommandFactDocument.FromAppliedCommand( + acceptedFrame, + new AetheriaRuntimeDaemonCommandDocument { CommandId = "move-remote-1" }, + "aetheria.local"); + Assert.AreEqual(0, tracker.Reconcile(new[] { fact }, 10).Count); + var receipts = tracker.Reconcile(new[] { fact }, 11); + + Assert.AreEqual(1, receipts.Count); + Assert.AreEqual("accepted", receipts[0].State); + Assert.AreEqual("move-remote-1", receipts[0].CommandId); + Assert.AreEqual("aetheria-daemon", receipts[0].Authority); + Assert.AreEqual(AetheriaRuntimeDaemonSchemas.CommittedCommandFact, receipts[0].Schema); + Assert.IsFalse(tracker.HasPending); + Assert.AreEqual(0, tracker.Reconcile(new[] { fact }, 11).Count); + } + [Test] public void TickRunnerAppliesObservedCommandsAndPublishesFrame() { @@ -553,7 +591,7 @@ public class DaemonRuntimeDocumentTests advertisedGameSurface.WorldInteraction.ProjectionKind); Assert.AreEqual("aetheria.daemon.commands", advertisedGameSurface.WorldInteraction.CommandBoundary); Assert.AreEqual( - AetheriaRuntimeDaemonSchemas.EveCommandAcceptanceStatus, + AetheriaRuntimeDaemonSchemas.CommittedCommandFact, advertisedGameSurface.WorldInteraction.ReceiptSchema); Assert.AreEqual( "provider-owns-world-state-assets-command-acceptance-and-receipts", @@ -2970,7 +3008,7 @@ public class DaemonRuntimeDocumentTests "aetheria.daemon.commands", liveBridge.CurrentSurfaceDocument.AdvertisedSurface.WorldInteraction.CommandBoundary); Assert.AreEqual( - AetheriaRuntimeDaemonSchemas.EveCommandAcceptanceStatus, + AetheriaRuntimeDaemonSchemas.CommittedCommandFact, liveBridge.CurrentSurfaceDocument.AdvertisedSurface.WorldInteraction.ReceiptSchema); Assert.AreEqual(1, presentation.ActiveEntities); Assert.AreEqual(1, sceneSink.Upserts.Count); @@ -2978,7 +3016,7 @@ public class DaemonRuntimeDocumentTests Assert.IsNotNull(runtime.ActiveProjection); Assert.AreEqual("provider-authored-world-surface", runtime.ActiveProjection.ProjectionKind); Assert.AreEqual("aetheria.daemon.commands", runtime.ActiveProjection.CommandBoundary); - Assert.AreEqual(AetheriaRuntimeDaemonSchemas.EveCommandAcceptanceStatus, runtime.ActiveProjection.ReceiptSchema); + Assert.AreEqual(AetheriaRuntimeDaemonSchemas.CommittedCommandFact, runtime.ActiveProjection.ReceiptSchema); Assert.AreEqual( AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString(), runtime.ActiveWorld.StatePointerId); @@ -3014,7 +3052,7 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual("aetheria.daemon", moveIntent.ProviderId); Assert.AreEqual(AetheriaRuntimeDaemonGameSurfaceBuilder.SurfaceId, moveIntent.SurfaceId); Assert.AreEqual("aetheria.daemon.commands", moveIntent.CommandBoundary); - Assert.AreEqual(AetheriaRuntimeDaemonSchemas.EveCommandAcceptanceStatus, moveIntent.ReceiptSchema); + Assert.AreEqual(AetheriaRuntimeDaemonSchemas.CommittedCommandFact, moveIntent.ReceiptSchema); Assert.AreEqual( AetheriaRuntimeDaemonSurfaceCommandCatalog.CommandName(AetheriaRuntimeDaemonCommandKinds.SetMoveVector), moveIntent.Payload.GetString("commandId")); diff --git a/Packages/org.gamecult.aetheria.eve-runtime/README.md b/Packages/org.gamecult.aetheria.eve-runtime/README.md index 8ec0e59aa..324d6691b 100644 --- a/Packages/org.gamecult.aetheria.eve-runtime/README.md +++ b/Packages/org.gamecult.aetheria.eve-runtime/README.md @@ -11,6 +11,12 @@ through CultMesh, and forward writes to the daemon's primary shard. The generic EveUnity PlayMode client must compose that remote path before this package can claim live daemon playability. +Remote command completion is derived only from provider-owned +`gamecult.aetheria.committed_command_fact.v1` documents. The adapter correlates +`CommandId` and waits until the synchronized game surface version reaches the +fact's `SourceFrameId` before emitting a terminal EveUnity receipt. Submission +success is never treated as command acceptance. + `AetheriaEveSurfacePresenter` is a typed surface document lowerer: - it resolves the active client target from `GameData/aetheria-client.cc`; diff --git a/Packages/org.gamecult.aetheria.eve-runtime/Runtime/AetheriaEveUnitySceneProviderBridge.cs b/Packages/org.gamecult.aetheria.eve-runtime/Runtime/AetheriaEveUnitySceneProviderBridge.cs index c7c340be5..3e85b78e8 100644 --- a/Packages/org.gamecult.aetheria.eve-runtime/Runtime/AetheriaEveUnitySceneProviderBridge.cs +++ b/Packages/org.gamecult.aetheria.eve-runtime/Runtime/AetheriaEveUnitySceneProviderBridge.cs @@ -28,6 +28,8 @@ namespace GameCult.Aetheria.EveRuntime private AetheriaRuntimeStateBootReport? _stateBoot; private AetheriaClientState? _runtimeState; private AetheriaClient? _remoteClient; + private readonly AetheriaEveUnityRemoteReceiptTracker _remoteReceipts = + new AetheriaEveUnityRemoteReceiptTracker(); private event Action? AssetManifestDocumentAvailable; public AetheriaEveUnitySceneProviderBridge( @@ -144,6 +146,7 @@ namespace GameCult.Aetheria.EveRuntime CurrentDocumentManifest = ReadAssetManifest(runtimeState); AssetManifestDocumentAvailable?.Invoke(CurrentDocumentManifest); + ReconcileRemoteReceipts(); } public void Submit(EveSurfaceCommandRequest request) @@ -158,6 +161,7 @@ namespace GameCult.Aetheria.EveRuntime Debug.Log( $"Forwarded Aetheria operation to remote CultMesh authority: {submitted!.Kind} {submitted.CommandId}"); + _remoteReceipts.Track(submitted.CommandId, request); return; } @@ -189,6 +193,7 @@ namespace GameCult.Aetheria.EveRuntime { _remoteClient?.Dispose(); _remoteClient = null; + _remoteReceipts.Clear(); } private bool IsRemote => !string.IsNullOrWhiteSpace(_cultMeshEndpoint); @@ -213,6 +218,17 @@ namespace GameCult.Aetheria.EveRuntime return _remoteClient; } + private void ReconcileRemoteReceipts() + { + if (!IsRemote || !_remoteReceipts.HasPending || _remoteClient == null) + return; + + foreach (var receipt in _remoteReceipts.Reconcile( + _remoteClient.CommittedCommandFacts(), + CurrentDocument.Version)) + ReceiptAvailable?.Invoke(receipt); + } + private AetheriaRuntimeStateBootReport ResolveStateBoot() { var stateBoot = AetheriaEveRuntimeUnityHooks.RequireStateBoot(_stateFilePathOverride); @@ -378,4 +394,65 @@ namespace GameCult.Aetheria.EveRuntime : asset.AssetKey.Replace('.', '/'); } } + + public sealed class AetheriaEveUnityRemoteReceiptTracker + { + private readonly Dictionary _pending = + new Dictionary(StringComparer.Ordinal); + + public bool HasPending => _pending.Count > 0; + + public void Track(string commandId, EveSurfaceCommandRequest request) + { + if (string.IsNullOrWhiteSpace(commandId)) + throw new ArgumentException("Remote command id must be non-empty.", nameof(commandId)); + _pending[commandId] = request ?? throw new ArgumentNullException(nameof(request)); + } + + public IReadOnlyList Reconcile( + IReadOnlyList facts, + long observedSurfaceVersion) + { + if (facts == null) throw new ArgumentNullException(nameof(facts)); + var receipts = new List(); + var byCommandId = facts + .Where(fact => fact != null && !string.IsNullOrWhiteSpace(fact.CommandId)) + .GroupBy(fact => fact.CommandId, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(fact => fact.SourceFrameId).First(), + StringComparer.Ordinal); + foreach (var commandId in _pending.Keys.ToArray()) + { + if (!byCommandId.TryGetValue(commandId, out var fact) || + observedSurfaceVersion < fact.SourceFrameId) + continue; + + var request = _pending[commandId]; + _pending.Remove(commandId); + var accepted = string.Equals( + fact.Outcome, + AetheriaRuntimeCommandFactOutcomes.Applied, + StringComparison.Ordinal); + receipts.Add(new EveUnitySceneCommandReceipt( + fact.FactId, + request.Operation?.OperationId ?? "", + commandId, + accepted ? "accepted" : "denied", + "Aetheria", + fact.SourceDaemonId, + AetheriaRuntimeDaemonSchemas.CommittedCommandFact, + request.ProviderId, + request.SurfaceId, + accepted ? "Command applied by authoritative daemon." : "Command rejected by authoritative daemon.", + DateTimeOffset.TryParse(fact.CommittedAtUtc, out var committedAt) ? committedAt : request.IssuedAt)); + } + return receipts; + } + + public void Clear() + { + _pending.Clear(); + } + } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs index 98b0a37c1..e34d39765 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using GameCult.Eve.Surface; @@ -76,6 +77,12 @@ namespace GameCult.Aetheria.State.Verse return _verse.RefreshRemoteAsync(); } + public IReadOnlyList CommittedCommandFacts() + { + ThrowIfDisposed(); + return _verse.CommittedCommandFacts(); + } + public static Task OpenLocalAsync( DirectoryInfo gameDataDirectory, string runtimeId, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs index 399641dd3..3b50e325e 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs @@ -202,7 +202,7 @@ namespace GameCult.Aetheria.State.Verse "tui" }, CommandBoundary = "aetheria.daemon.commands", - ReceiptSchema = AetheriaRuntimeDaemonSchemas.EveCommandAcceptanceStatus, + ReceiptSchema = AetheriaRuntimeDaemonSchemas.CommittedCommandFact, Ownership = "provider-owns-world-state-assets-command-acceptance-and-receipts" }; } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs index a700f57db..2573932eb 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs @@ -151,7 +151,8 @@ namespace GameCult.Aetheria.State.Verse typeof(AetheriaRuntimeSurfaceDocument), typeof(EveSurfaceDocument), typeof(AetheriaRuntimeDaemonCommandDocument), - typeof(AetheriaRuntimeEveCommandDocument) + typeof(AetheriaRuntimeEveCommandDocument), + typeof(AetheriaRuntimeCommittedCommandFactDocument) }; public static CultDocumentRegistry CreateCultCacheRegistry() @@ -1053,6 +1054,12 @@ namespace GameCult.Aetheria.State.Verse return AetheriaRuntimeCatalogStore.OpenReadOnly(StatePath); } + public IReadOnlyList CommittedCommandFacts() + { + ThrowIfDisposed(); + return Cache.GetAll().ToArray(); + } + private AetheriaRuntimeLoadoutTemplatesDocument BootstrapLoadoutTemplatesDocument() { return new AetheriaRuntimeLoadoutTemplatesDocument( diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 3129173a5..7e5f1eed0 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -1491,6 +1491,10 @@ First Aetheria surfaces to publish: the local `.cc` scene adapter is no longer described as a live daemon transport. The remaining proof is a separately running daemon, observed receipts, and PlayMode reconciliation in an EveUnity-owned client. + - Done: publish committed facts for every applied or rejected daemon command, + including operation-level rejection. The remote Unity adapter correlates + those facts by command ID and emits a terminal receipt only after the + synchronized game surface reaches the fact's source frame. 6. Eve UI - Done: publish the typed catalog operator surface from `Aetheria.State`.