Add typed Aetheria client state facade

This commit is contained in:
Meta Vens 2026-06-27 13:51:08 +01:00
commit 2266ae096e
5 changed files with 262 additions and 16 deletions

View file

@ -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()
{

View file

@ -87,6 +87,7 @@
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeDaemonEditorSurfaceBuilder.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeDaemonOperationClient.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaClient.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaClientState.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaControl.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaUi.cs" />
<Compile Include="Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeVerseClient.cs" />

View file

@ -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<AetheriaRuntimeCurrentZoneDocument> CurrentZoneAsync()
{
ThrowIfDisposed();
var frame = await RequireFrameAsync().ConfigureAwait(false);
return AetheriaRuntimeRtsProjection.ProjectCurrentZone(frame);
return await State.Current.Zone.LatestAsync().ConfigureAwait(false);
}
public async Task<AetheriaRuntimeCurrentEntityDocument> CurrentEntityAsync()
{
ThrowIfDisposed();
var frame = await RequireFrameAsync().ConfigureAwait(false);
return AetheriaRuntimeRtsProjection.ProjectCurrentEntity(frame);
return await State.Current.Entity.LatestAsync().ConfigureAwait(false);
}
public async Task<AetheriaRuntimeCurrentDockingDocument> CurrentDockingAsync()
{
ThrowIfDisposed();
var frame = await RequireFrameAsync().ConfigureAwait(false);
return AetheriaRuntimeRtsProjection.ProjectCurrentDocking(frame);
return await State.Current.Docking.LatestAsync().ConfigureAwait(false);
}
public async Task<AetheriaRuntimeZoneContactsDocument> ZoneContactsAsync()
{
ThrowIfDisposed();
var frame = await RequireFrameAsync().ConfigureAwait(false);
return AetheriaRuntimeRtsProjection.ProjectZoneContacts(frame);
return await State.ZoneContacts.LatestAsync().ConfigureAwait(false);
}
public async Task<AetheriaRuntimeStationRefitDocument> 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<AetheriaRuntimeSectorMapDocument> SectorMapAsync()
{
ThrowIfDisposed();
var frame = await RequireFrameAsync().ConfigureAwait(false);
return AetheriaRuntimeRtsProjection.ProjectSectorMap(frame);
return await State.SectorMap.LatestAsync().ConfigureAwait(false);
}
public async Task<AetheriaRuntimeZoneDetailsDocument> ZoneDetailsAsync(int zoneIndex)
@ -195,8 +191,7 @@ namespace GameCult.Aetheria.State.Verse
public async Task<AetheriaRuntimeZoneRenderDocument> ZoneRenderAsync()
{
ThrowIfDisposed();
var frame = await RequireFrameAsync().ConfigureAwait(false);
return AetheriaRuntimeRtsProjection.ProjectZoneRender(frame);
return await State.ZoneRender.LatestAsync().ConfigureAwait(false);
}
public async Task<AetheriaRuntimeStarbridgeSessionSummaryDocument> 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<TDocument> ProjectedDocument<TDocument>(
string documentId,
Func<AetheriaRuntimeDaemonFrameDocument, Task<TDocument>> project,
string schemaId,
params CultMeshProjectionSource[] additionalSources)
{
var sources = new[]
{
CultMesh.ProjectionSource(
AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString(),
AetheriaRuntimeDaemonSchemas.Frame,
"latest authoritative daemon frame")
}
.Concat(additionalSources ?? Array.Empty<CultMeshProjectionSource>())
.Append(CultMesh.ProjectionSource(documentId, schemaId, "projected Aetheria client document"))
.ToArray();
return AetheriaRuntimeProjectedDocument<TDocument>.Create(
documentId,
RuntimeId,
async () => await project(await RequireFrameAsync().ConfigureAwait(false)).ConfigureAwait(false),
frameChanges,
project,
sources);
}
async Task<AetheriaRuntimeStationRefitDocument> 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)

View file

@ -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<TDocument>
{
private static readonly CultMeshPollingWatchOptions<TDocument> DefaultWatchOptions =
new CultMeshPollingWatchOptions<TDocument>(TimeSpan.FromMilliseconds(100));
private readonly CultMeshBoundLiveFeed<AetheriaRuntimeStateQueryParameters, TDocument> _feed;
internal AetheriaRuntimeProjectedDocument(
CultMeshBoundLiveFeed<AetheriaRuntimeStateQueryParameters, TDocument> feed)
{
_feed = feed ?? throw new ArgumentNullException(nameof(feed));
}
public string DocumentId => _feed.FeedId;
public CultMeshRouteHint RouteHint => _feed.RouteHint;
public System.Collections.Generic.IReadOnlyList<CultMeshProjectionSource> Sources => _feed.Sources;
public CultMeshLiveFeed<AetheriaRuntimeStateQueryParameters, TDocument> Feed => _feed.Feed;
public Task<TDocument> LatestAsync()
{
return _feed.SnapshotAsync(AetheriaRuntimeStateQueryParameters.Empty);
}
public Observable<TDocument> Watch()
{
return _feed.Watch(AetheriaRuntimeStateQueryParameters.Empty);
}
public IDisposable Watch(Action<TDocument> onNext)
{
if (onNext == null) throw new ArgumentNullException(nameof(onNext));
return Watch().Subscribe(onNext);
}
internal static AetheriaRuntimeProjectedDocument<TDocument> Create(
string documentId,
string runtimeId,
Func<Task<TDocument>> latest,
Observable<AetheriaRuntimeDaemonFrameDocument> frameChanges,
Func<AetheriaRuntimeDaemonFrameDocument, Task<TDocument>> 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<AetheriaRuntimeStateQueryParameters, TDocument>(
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<TDocument>(feed.Bind(verse));
}
}
public sealed class AetheriaClientState
{
internal AetheriaClientState(
AetheriaRuntimeProjectedDocument<AetheriaRuntimeCurrentZoneDocument> currentZone,
AetheriaRuntimeProjectedDocument<AetheriaRuntimeCurrentEntityDocument> currentEntity,
AetheriaRuntimeProjectedDocument<AetheriaRuntimeCurrentDockingDocument> currentDocking,
AetheriaRuntimeProjectedDocument<AetheriaRuntimeZoneContactsDocument> zoneContacts,
AetheriaRuntimeProjectedDocument<AetheriaRuntimeStationRefitDocument> stationRefit,
AetheriaRuntimeProjectedDocument<AetheriaRuntimeSectorMapDocument> sectorMap,
AetheriaRuntimeProjectedDocument<AetheriaRuntimeZoneRenderDocument> 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<AetheriaRuntimeZoneContactsDocument> ZoneContacts { get; }
public AetheriaRuntimeProjectedDocument<AetheriaRuntimeStationRefitDocument> StationRefit { get; }
public AetheriaRuntimeProjectedDocument<AetheriaRuntimeSectorMapDocument> SectorMap { get; }
public AetheriaRuntimeProjectedDocument<AetheriaRuntimeZoneRenderDocument> ZoneRender { get; }
}
public sealed class AetheriaClientCurrentState
{
internal AetheriaClientCurrentState(
AetheriaRuntimeProjectedDocument<AetheriaRuntimeCurrentZoneDocument> zone,
AetheriaRuntimeProjectedDocument<AetheriaRuntimeCurrentEntityDocument> entity,
AetheriaRuntimeProjectedDocument<AetheriaRuntimeCurrentDockingDocument> docking)
{
Zone = zone ?? throw new ArgumentNullException(nameof(zone));
Entity = entity ?? throw new ArgumentNullException(nameof(entity));
Docking = docking ?? throw new ArgumentNullException(nameof(docking));
}
public AetheriaRuntimeProjectedDocument<AetheriaRuntimeCurrentZoneDocument> Zone { get; }
public AetheriaRuntimeProjectedDocument<AetheriaRuntimeCurrentEntityDocument> Entity { get; }
public AetheriaRuntimeProjectedDocument<AetheriaRuntimeCurrentDockingDocument> Docking { get; }
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 80b87956450e46e4a9b7ed79b9d8aee8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: