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: