Reconcile Unity commands from daemon facts
This commit is contained in:
parent
824c269001
commit
13e27e5572
8 changed files with 145 additions and 10 deletions
|
|
@ -246,13 +246,9 @@ static async Task PublishCommittedCommandFactsAsync(
|
|||
options.VerseId)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var policyRejectedIds = new HashSet<string>(
|
||||
policyRejectedCommandIds ?? Array.Empty<string>(),
|
||||
StringComparer.Ordinal);
|
||||
foreach (var commandId in frame.RejectedCommandIds ?? Array.Empty<string>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(commandId) ||
|
||||
!policyRejectedIds.Contains(commandId) ||
|
||||
!byCommandId.TryGetValue(commandId, out var command))
|
||||
continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AetheriaRuntimeCommittedCommandFactDocument>(), 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"));
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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<EveUnityPlayableWorldAssetManifestDocument>? 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<string, EveSurfaceCommandRequest> _pending =
|
||||
new Dictionary<string, EveSurfaceCommandRequest>(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<EveUnitySceneCommandReceipt> Reconcile(
|
||||
IReadOnlyList<AetheriaRuntimeCommittedCommandFactDocument> facts,
|
||||
long observedSurfaceVersion)
|
||||
{
|
||||
if (facts == null) throw new ArgumentNullException(nameof(facts));
|
||||
var receipts = new List<EveUnitySceneCommandReceipt>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AetheriaRuntimeCommittedCommandFactDocument> CommittedCommandFacts()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _verse.CommittedCommandFacts();
|
||||
}
|
||||
|
||||
public static Task<AetheriaClient> OpenLocalAsync(
|
||||
DirectoryInfo gameDataDirectory,
|
||||
string runtimeId,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AetheriaRuntimeCommittedCommandFactDocument> CommittedCommandFacts()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return Cache.GetAll<AetheriaRuntimeCommittedCommandFactDocument>().ToArray();
|
||||
}
|
||||
|
||||
private AetheriaRuntimeLoadoutTemplatesDocument BootstrapLoadoutTemplatesDocument()
|
||||
{
|
||||
return new AetheriaRuntimeLoadoutTemplatesDocument(
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Reference in a new issue