From 2266ae096eb3d4a2adf66e992b894f4b69f16611 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 13:51:08 +0100 Subject: [PATCH] Add typed Aetheria client state facade --- .../Tests/DaemonRuntimeDocumentTests.cs | 37 +++++ GameCult.Aetheria.State.Unity.csproj | 1 + .../Runtime/AetheriaClient.cs | 100 +++++++++++--- .../Runtime/AetheriaClientState.cs | 129 ++++++++++++++++++ .../Runtime/AetheriaClientState.cs.meta | 11 ++ 5 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaClientState.cs create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaClientState.cs.meta diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index b4bec4ce5..5f0136a74 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -144,6 +144,43 @@ public class DaemonRuntimeDocumentTests Assert.IsFalse(AetheriaRuntimeDaemonFrameStore.TryReadFrame(statePath, out _)); } + [Test] + public void AetheriaClientStateFacadeReadsProjectedDocumentsThroughCultMesh() + { + var statePath = Path.Combine( + Path.GetTempPath(), + "aetheria-client-state-facade-tests", + Path.GetRandomFileName(), + "state.cc"); + var frame = AetheriaRuntimeDaemonFrameDocument.Create( + RunWithTwoEntities(), + "daemon", + "session-state", + 7, + 1.25, + 0.02); + PublishLatestFrameThroughVerseClient(statePath, frame); + + using var client = AetheriaClient + .OpenAsync(statePath, "unity-test", pullOnOpen: true) + .GetAwaiter() + .GetResult(); + + var currentEntity = client.State.Current.Entity.LatestAsync().GetAwaiter().GetResult(); + var legacyCurrentEntity = client.CurrentEntityAsync().GetAwaiter().GetResult(); + + Assert.AreEqual("aetheria.current.entity", client.State.Current.Entity.DocumentId); + Assert.AreEqual(AetheriaRuntimeDaemonSchemas.CurrentEntity, currentEntity.Schema); + Assert.AreEqual("zone.0.entity.0", currentEntity.EntityKey); + Assert.AreEqual(0, currentEntity.EntityIndex); + Assert.AreEqual("Player", currentEntity.Entity?.Name); + Assert.AreEqual(legacyCurrentEntity.EntityKey, currentEntity.EntityKey); + Assert.IsTrue(client.State.Current.Entity.Sources.Any(source => + source.SourceId == AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString())); + Assert.IsTrue(client.State.StationRefit.Sources.Any(source => + source.SourceId == "catalog:aetheria.runtime")); + } + [Test] public void TickRunnerAppliesObservedCommandsAndPublishesFrame() { diff --git a/GameCult.Aetheria.State.Unity.csproj b/GameCult.Aetheria.State.Unity.csproj index 81ec8c727..a4cfb5970 100644 --- a/GameCult.Aetheria.State.Unity.csproj +++ b/GameCult.Aetheria.State.Unity.csproj @@ -87,6 +87,7 @@ + diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs index 381e9e3f2..d4c412fe4 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClient.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using GameCult.Eve.Surface; using GameCult.Mesh; +using R3; #nullable enable @@ -17,6 +18,7 @@ namespace GameCult.Aetheria.State.Verse private readonly AetheriaRuntimeDaemonOperationsClient _operations; private readonly AetheriaControl _control; private readonly AetheriaUi _ui; + private readonly AetheriaClientState _state; private bool _disposed; private AetheriaClient(AetheriaRuntimeVerseClient verse, string clientId, string sessionId) @@ -27,12 +29,14 @@ namespace GameCult.Aetheria.State.Verse _operations = new AetheriaRuntimeDaemonOperationsClient(SendOperation); _control = new AetheriaControl(_operations); _ui = new AetheriaUi(this); + _state = CreateStateFacade(); } public string StatePath => _verse.StatePath; public string RuntimeId => _verse.RuntimeId; public AetheriaControl Control => _control; public AetheriaUi Ui => _ui; + public AetheriaClientState State => _state; internal AetheriaRuntimeDaemonOperationsClient Operations => _operations; @@ -144,45 +148,37 @@ namespace GameCult.Aetheria.State.Verse public async Task CurrentZoneAsync() { ThrowIfDisposed(); - var frame = await RequireFrameAsync().ConfigureAwait(false); - return AetheriaRuntimeRtsProjection.ProjectCurrentZone(frame); + return await State.Current.Zone.LatestAsync().ConfigureAwait(false); } public async Task CurrentEntityAsync() { ThrowIfDisposed(); - var frame = await RequireFrameAsync().ConfigureAwait(false); - return AetheriaRuntimeRtsProjection.ProjectCurrentEntity(frame); + return await State.Current.Entity.LatestAsync().ConfigureAwait(false); } public async Task CurrentDockingAsync() { ThrowIfDisposed(); - var frame = await RequireFrameAsync().ConfigureAwait(false); - return AetheriaRuntimeRtsProjection.ProjectCurrentDocking(frame); + return await State.Current.Docking.LatestAsync().ConfigureAwait(false); } public async Task ZoneContactsAsync() { ThrowIfDisposed(); - var frame = await RequireFrameAsync().ConfigureAwait(false); - return AetheriaRuntimeRtsProjection.ProjectZoneContacts(frame); + return await State.ZoneContacts.LatestAsync().ConfigureAwait(false); } public async Task StationRefitAsync() { ThrowIfDisposed(); - var frame = await RequireFrameAsync().ConfigureAwait(false); - var loadoutTemplates = await _verse.GetLoadoutTemplatesAsync().ConfigureAwait(false); - var catalog = _verse.OpenRuntimeCatalog(); - return AetheriaRuntimeRtsProjection.ProjectStationRefit(frame, loadoutTemplates, catalog); + return await State.StationRefit.LatestAsync().ConfigureAwait(false); } public async Task SectorMapAsync() { ThrowIfDisposed(); - var frame = await RequireFrameAsync().ConfigureAwait(false); - return AetheriaRuntimeRtsProjection.ProjectSectorMap(frame); + return await State.SectorMap.LatestAsync().ConfigureAwait(false); } public async Task ZoneDetailsAsync(int zoneIndex) @@ -195,8 +191,7 @@ namespace GameCult.Aetheria.State.Verse public async Task ZoneRenderAsync() { ThrowIfDisposed(); - var frame = await RequireFrameAsync().ConfigureAwait(false); - return AetheriaRuntimeRtsProjection.ProjectZoneRender(frame); + return await State.ZoneRender.LatestAsync().ConfigureAwait(false); } public async Task StarbridgeSessionSummaryAsync( @@ -390,6 +385,79 @@ namespace GameCult.Aetheria.State.Verse return frame; } + private AetheriaClientState CreateStateFacade() + { + var frameChanges = _verse.WatchLatestFrames() + .Where(change => change.Document != null) + .Select(change => change.Document!); + + return new AetheriaClientState( + ProjectedDocument( + "aetheria.current.zone", + frame => Task.FromResult(AetheriaRuntimeRtsProjection.ProjectCurrentZone(frame)), + AetheriaRuntimeDaemonSchemas.CurrentZone), + ProjectedDocument( + "aetheria.current.entity", + frame => Task.FromResult(AetheriaRuntimeRtsProjection.ProjectCurrentEntity(frame)), + AetheriaRuntimeDaemonSchemas.CurrentEntity), + ProjectedDocument( + "aetheria.current.docking", + frame => Task.FromResult(AetheriaRuntimeRtsProjection.ProjectCurrentDocking(frame)), + AetheriaRuntimeDaemonSchemas.CurrentDocking), + ProjectedDocument( + "aetheria.zone.contacts", + frame => Task.FromResult(AetheriaRuntimeRtsProjection.ProjectZoneContacts(frame)), + AetheriaRuntimeDaemonSchemas.ZoneContacts), + ProjectedDocument( + "aetheria.station.refit", + ProjectStationRefitAsync, + AetheriaRuntimeDaemonSchemas.StationRefit, + CultMesh.ProjectionSource("catalog:aetheria.runtime"), + CultMesh.ProjectionSource("loadout-templates:aetheria.runtime")), + ProjectedDocument( + "aetheria.sector.map", + frame => Task.FromResult(AetheriaRuntimeRtsProjection.ProjectSectorMap(frame)), + AetheriaRuntimeDaemonSchemas.SectorMap), + ProjectedDocument( + "aetheria.zone.render", + frame => Task.FromResult(AetheriaRuntimeRtsProjection.ProjectZoneRender(frame)), + AetheriaRuntimeDaemonSchemas.ZoneRender)); + + AetheriaRuntimeProjectedDocument ProjectedDocument( + string documentId, + Func> project, + string schemaId, + params CultMeshProjectionSource[] additionalSources) + { + var sources = new[] + { + CultMesh.ProjectionSource( + AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString(), + AetheriaRuntimeDaemonSchemas.Frame, + "latest authoritative daemon frame") + } + .Concat(additionalSources ?? Array.Empty()) + .Append(CultMesh.ProjectionSource(documentId, schemaId, "projected Aetheria client document")) + .ToArray(); + + return AetheriaRuntimeProjectedDocument.Create( + documentId, + RuntimeId, + async () => await project(await RequireFrameAsync().ConfigureAwait(false)).ConfigureAwait(false), + frameChanges, + project, + sources); + } + + async Task ProjectStationRefitAsync( + AetheriaRuntimeDaemonFrameDocument frame) + { + var loadoutTemplates = await _verse.GetLoadoutTemplatesAsync().ConfigureAwait(false); + var catalog = _verse.OpenRuntimeCatalog(); + return AetheriaRuntimeRtsProjection.ProjectStationRefit(frame, loadoutTemplates, catalog); + } + } + private void ThrowIfDisposed() { if (_disposed) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClientState.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClientState.cs new file mode 100644 index 000000000..e5a4b4dde --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClientState.cs @@ -0,0 +1,129 @@ +using System; +using System.Threading.Tasks; +using GameCult.Mesh; +using R3; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public readonly struct AetheriaRuntimeStateQueryParameters + { + public static readonly AetheriaRuntimeStateQueryParameters Empty = new AetheriaRuntimeStateQueryParameters(); + } + + public sealed class AetheriaRuntimeProjectedDocument + { + private static readonly CultMeshPollingWatchOptions DefaultWatchOptions = + new CultMeshPollingWatchOptions(TimeSpan.FromMilliseconds(100)); + + private readonly CultMeshBoundLiveFeed _feed; + + internal AetheriaRuntimeProjectedDocument( + CultMeshBoundLiveFeed feed) + { + _feed = feed ?? throw new ArgumentNullException(nameof(feed)); + } + + public string DocumentId => _feed.FeedId; + + public CultMeshRouteHint RouteHint => _feed.RouteHint; + + public System.Collections.Generic.IReadOnlyList Sources => _feed.Sources; + + public CultMeshLiveFeed Feed => _feed.Feed; + + public Task LatestAsync() + { + return _feed.SnapshotAsync(AetheriaRuntimeStateQueryParameters.Empty); + } + + public Observable Watch() + { + return _feed.Watch(AetheriaRuntimeStateQueryParameters.Empty); + } + + public IDisposable Watch(Action onNext) + { + if (onNext == null) throw new ArgumentNullException(nameof(onNext)); + return Watch().Subscribe(onNext); + } + + internal static AetheriaRuntimeProjectedDocument Create( + string documentId, + string runtimeId, + Func> latest, + Observable frameChanges, + Func> projectFrame, + params CultMeshProjectionSource[] sources) + { + if (string.IsNullOrWhiteSpace(documentId)) + throw new ArgumentException("Document id must be non-empty.", nameof(documentId)); + if (string.IsNullOrWhiteSpace(runtimeId)) + throw new ArgumentException("Runtime id must be non-empty.", nameof(runtimeId)); + if (latest == null) throw new ArgumentNullException(nameof(latest)); + if (frameChanges == null) throw new ArgumentNullException(nameof(frameChanges)); + if (projectFrame == null) throw new ArgumentNullException(nameof(projectFrame)); + + var feed = new CultMeshLiveFeed( + documentId, + (_parameters, _context) => latest(), + (_parameters, context) => frameChanges + .SelectAwait(async (frame, cancellationToken) => + await projectFrame(frame).ConfigureAwait(false)), + sources: sources, + routeHint: new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "Aetheria typed projected state")); + + var verse = CultMesh.Verse("aetheria.local", runtimeId); + return new AetheriaRuntimeProjectedDocument(feed.Bind(verse)); + } + } + + public sealed class AetheriaClientState + { + internal AetheriaClientState( + AetheriaRuntimeProjectedDocument currentZone, + AetheriaRuntimeProjectedDocument currentEntity, + AetheriaRuntimeProjectedDocument currentDocking, + AetheriaRuntimeProjectedDocument zoneContacts, + AetheriaRuntimeProjectedDocument stationRefit, + AetheriaRuntimeProjectedDocument sectorMap, + AetheriaRuntimeProjectedDocument zoneRender) + { + Current = new AetheriaClientCurrentState(currentZone, currentEntity, currentDocking); + ZoneContacts = zoneContacts ?? throw new ArgumentNullException(nameof(zoneContacts)); + StationRefit = stationRefit ?? throw new ArgumentNullException(nameof(stationRefit)); + SectorMap = sectorMap ?? throw new ArgumentNullException(nameof(sectorMap)); + ZoneRender = zoneRender ?? throw new ArgumentNullException(nameof(zoneRender)); + } + + public AetheriaClientCurrentState Current { get; } + + public AetheriaRuntimeProjectedDocument ZoneContacts { get; } + + public AetheriaRuntimeProjectedDocument StationRefit { get; } + + public AetheriaRuntimeProjectedDocument SectorMap { get; } + + public AetheriaRuntimeProjectedDocument ZoneRender { get; } + } + + public sealed class AetheriaClientCurrentState + { + internal AetheriaClientCurrentState( + AetheriaRuntimeProjectedDocument zone, + AetheriaRuntimeProjectedDocument entity, + AetheriaRuntimeProjectedDocument docking) + { + Zone = zone ?? throw new ArgumentNullException(nameof(zone)); + Entity = entity ?? throw new ArgumentNullException(nameof(entity)); + Docking = docking ?? throw new ArgumentNullException(nameof(docking)); + } + + public AetheriaRuntimeProjectedDocument Zone { get; } + + public AetheriaRuntimeProjectedDocument Entity { get; } + + public AetheriaRuntimeProjectedDocument Docking { get; } + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClientState.cs.meta b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClientState.cs.meta new file mode 100644 index 000000000..65b4f266b --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaClientState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 80b87956450e46e4a9b7ed79b9d8aee8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: