Serve Verse discovery from daemon host settings

This commit is contained in:
Meta Vens 2026-06-17 12:46:11 +01:00
commit c1267795bf
6 changed files with 188 additions and 4 deletions

View file

@ -537,7 +537,7 @@ Console.WriteLine("Generic popup inspector shell: PropertiesPanel, PropertiesLis
Console.WriteLine("Typed behavior metadata authority: live heat/mining/thermotoggle payload kinds stay owned by package metadata");
Console.WriteLine("NameTools editor shell: the remaining name helper window lowers through UI Toolkit instead of IMGUI");
Console.WriteLine("Runtime state reader authority: Unity gameplay/UI read typed state through a shared runtime reader instead of direct store spelunking");
Console.WriteLine("Verse host authority: daemon-owned typed verse host settings now drive provider advertisement and operations telemetry");
Console.WriteLine("Verse host authority: daemon-owned typed verse host settings now drive provider advertisement, operations telemetry, and served Verse discovery");
Console.WriteLine("Client target boot authority: Unity boot resolves the active Verse through a typed client target instead of local path folklore");
Console.WriteLine("Verse settings shell: client target edits and Verse-host visibility commands lower through typed Eve surfaces");
Console.WriteLine("Main-menu Verse projection: the Unity Eve shell lowers daemon-owned verse identity instead of ad-libbing local menu copy");
@ -3665,6 +3665,8 @@ static void RequireVerseHostSettingsAuthority(string root)
{
var settingsPath = Path.Combine(root, "Aetheria.State", "Documents", "AetheriaVerseHostSettings.cs");
var normalizerPath = Path.Combine(root, "Aetheria.State", "AetheriaVerseHostSettingsNormalizer.cs");
var verseCatalogProjectorPath = Path.Combine(root, "Aetheria.State", "AetheriaVerseCatalogProjector.cs");
var verseDiscoveryHostPath = Path.Combine(root, "Aetheria.State", "AetheriaVerseDiscoveryHost.cs");
var registryPath = Path.Combine(root, "Aetheria.State", "AetheriaDocumentRegistry.cs");
var nodePath = Path.Combine(root, "Aetheria.State", "AetheriaStateNode.cs");
var providerPath = Path.Combine(root, "Aetheria.State", "AetheriaProviderAdvertisementProjector.cs");
@ -3677,6 +3679,8 @@ static void RequireVerseHostSettingsAuthority(string root)
{
settingsPath,
normalizerPath,
verseCatalogProjectorPath,
verseDiscoveryHostPath,
registryPath,
nodePath,
providerPath,
@ -3698,6 +3702,8 @@ static void RequireVerseHostSettingsAuthority(string root)
var settings = File.ReadAllText(settingsPath);
var normalizer = File.ReadAllText(normalizerPath);
var verseCatalogProjector = File.ReadAllText(verseCatalogProjectorPath);
var verseDiscoveryHost = File.ReadAllText(verseDiscoveryHostPath);
var registry = File.ReadAllText(registryPath);
var node = File.ReadAllText(nodePath);
var provider = File.ReadAllText(providerPath);
@ -3731,6 +3737,42 @@ static void RequireVerseHostSettingsAuthority(string root)
"Verse-host settings normalizer is missing normalize/equivalence ownership.");
}
var requiredVerseCatalogProjectorSymbols = new[]
{
"public static class AetheriaVerseCatalogProjector",
"CultMeshVerseDescriptor",
"BuildDiscoveryEndpoint",
"CultMeshVerseDescriptor.ComputeRulesHash",
"cultnet://"
};
var missingVerseCatalogProjectorSymbols = requiredVerseCatalogProjectorSymbols
.Where(symbol => !verseCatalogProjector.Contains(symbol, StringComparison.Ordinal))
.ToArray();
if (missingVerseCatalogProjectorSymbols.Length > 0)
{
throw new InvalidOperationException(
"Verse catalog projector no longer derives public CultMesh discovery from typed host settings: " +
string.Join(", ", missingVerseCatalogProjectorSymbols));
}
var requiredVerseDiscoveryHostSymbols = new[]
{
"public sealed class AetheriaVerseDiscoveryHost",
"AetheriaVerseCatalogProjector.Build(normalized)",
"CultMesh.CreateVerseCatalog()",
"CultMesh.ServeVerseCatalog(_node.MeshNode, _catalog)",
"normalized.Visibility"
};
var missingVerseDiscoveryHostSymbols = requiredVerseDiscoveryHostSymbols
.Where(symbol => !verseDiscoveryHost.Contains(symbol, StringComparison.Ordinal))
.ToArray();
if (missingVerseDiscoveryHostSymbols.Length > 0)
{
throw new InvalidOperationException(
"Verse discovery host no longer gates served Verse catalogs through the typed host settings: " +
string.Join(", ", missingVerseDiscoveryHostSymbols));
}
if (!registry.Contains("CultNetDocumentBinding.ForDocument<AetheriaVerseHostSettings>", StringComparison.Ordinal) ||
!registry.Contains("typeof(AetheriaVerseHostSettings)", StringComparison.Ordinal))
{
@ -3740,6 +3782,7 @@ static void RequireVerseHostSettingsAuthority(string root)
if (!node.Contains("PutVerseHostSettingsAsync", StringComparison.Ordinal) ||
!node.Contains("GetVerseHostSettingsAsync", StringComparison.Ordinal) ||
!node.Contains("public CultMeshNode MeshNode => _node;", StringComparison.Ordinal) ||
!node.Contains("global:aetheria.verse_host_settings.v1", StringComparison.Ordinal))
{
throw new InvalidOperationException(
@ -3783,6 +3826,8 @@ static void RequireVerseHostSettingsAuthority(string root)
var requiredServerSymbols = new[]
{
"EnsureVerseHostSettingsAsync",
"AetheriaVerseDiscoveryHost",
"RefreshVerseDiscoveryAsync",
"LoadVerseHostOverrides",
"GetVerseHostSettingsAsync",
"PutVerseHostSettingsAsync",

View file

@ -21,6 +21,8 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
public string StatePath { get; }
public CultMeshNode MeshNode => _node;
public CultCache Cache => _node.Cache;
public CultNetDatabase Database => _node.Database;

View file

@ -0,0 +1,54 @@
using Aetheria.State.Documents;
using GameCult.Mesh;
namespace Aetheria.State;
public static class AetheriaVerseCatalogProjector
{
private const string TransportVersion = "cultmesh.v0";
private const string RulesEpoch = "aetheria-runtime-state.v1";
public static CultMeshVerseDescriptor Build(AetheriaVerseHostSettings? settings)
{
var normalized = AetheriaVerseHostSettingsNormalizer.Normalize(settings);
var discoveryEndpoint = BuildDiscoveryEndpoint(normalized);
var rulesHash = CultMeshVerseDescriptor.ComputeRulesHash(
"aetheria",
RulesEpoch,
normalized.RootVerse,
normalized.CanonicalService);
return new CultMeshVerseDescriptor(
normalized.VerseId,
normalized.Title,
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility(TransportVersion, rulesHash),
discoveryEndpoints: new[] { discoveryEndpoint },
authorityRuntimeIds: new[] { normalized.ServiceId },
parentVerseId: normalized.RootVerse,
description: BuildDescription(normalized));
}
public static string BuildDiscoveryEndpoint(AetheriaVerseHostSettings? settings)
{
var normalized = AetheriaVerseHostSettingsNormalizer.Normalize(settings);
var host = normalized.LocatedService;
if (string.IsNullOrWhiteSpace(host))
host = normalized.CanonicalService;
if (string.IsNullOrWhiteSpace(host))
host = "localhost";
host = host.Trim();
var slashIndex = host.IndexOf('/', StringComparison.Ordinal);
if (slashIndex >= 0)
host = host.Substring(0, slashIndex);
return $"cultnet://{host}:3075";
}
private static string BuildDescription(AetheriaVerseHostSettings settings)
{
var visibility = string.IsNullOrWhiteSpace(settings.Visibility) ? "private" : settings.Visibility;
return $"{settings.Title} Verse host ({visibility})";
}
}

View file

@ -0,0 +1,67 @@
using Aetheria.State.Documents;
using GameCult.Mesh;
namespace Aetheria.State;
public sealed class AetheriaVerseDiscoveryHost : IDisposable
{
private readonly AetheriaStateNode _node;
private CultMeshVerseCatalog? _catalog;
private CultMeshVerseDiscoveryServer? _server;
private string _fingerprint = "";
public AetheriaVerseDiscoveryHost(AetheriaStateNode node)
{
_node = node ?? throw new ArgumentNullException(nameof(node));
}
public void Update(AetheriaVerseHostSettings? settings)
{
var normalized = AetheriaVerseHostSettingsNormalizer.Normalize(settings);
if (!string.Equals(normalized.Visibility, "public", StringComparison.OrdinalIgnoreCase))
{
DisposeServer();
return;
}
var nextFingerprint = CreateFingerprint(normalized);
if (string.Equals(_fingerprint, nextFingerprint, StringComparison.Ordinal))
return;
DisposeServer();
_catalog = CultMesh.CreateVerseCatalog();
_catalog.Upsert(AetheriaVerseCatalogProjector.Build(normalized));
_server = CultMesh.ServeVerseCatalog(_node.MeshNode, _catalog);
_fingerprint = nextFingerprint;
}
public void Dispose()
{
DisposeServer();
GC.SuppressFinalize(this);
}
private void DisposeServer()
{
_server?.Dispose();
_server = null;
_catalog?.Dispose();
_catalog = null;
_fingerprint = "";
}
private static string CreateFingerprint(AetheriaVerseHostSettings settings)
{
return string.Join(
"\u001f",
settings.ServiceId ?? "",
settings.VerseId ?? "",
settings.RootVerse ?? "",
settings.CanonicalService ?? "",
settings.LocatedService ?? "",
settings.CultMeshAddress ?? "",
settings.Title ?? "",
settings.Visibility ?? "");
}
}

View file

@ -402,8 +402,11 @@ depth, accepted/applied/rejected counts, failures, timestamps, and player
settings controls are typed state rather than console-only status. It also
persists daemon-owned `aetheria.verse_host_settings.v1`, with boot-time
defaults or overrides for service id, Verse id, visibility, and CultMesh
address. The operations surface and provider advertisement project from that
typed host document instead of hardcoded daemon identity strings. The provider
address. When visibility is `public`, the long-running daemon also serves a
derived CultMesh Verse catalog from that same typed host document, using the
located service name as the discoverable `cultnet://...:3075` endpoint. The
operations surface and provider advertisement project from that typed host
document instead of hardcoded daemon identity strings. The provider
advertisement still publishes `gamecult.eve.provider_advertisement.v1` for the
`aetheria` provider, advertising the catalog, operations, and player-settings
surfaces plus the typed schemas witnessed by the local `.cc` state file.

View file

@ -17,13 +17,16 @@ internal static class Program
statePath,
runtimeId: RuntimeId,
startServer: true).ConfigureAwait(false);
using var verseDiscovery = new AetheriaVerseDiscoveryHost(node);
await EnsureVerseHostSettingsAsync(node).ConfigureAwait(false);
await RefreshVerseDiscoveryAsync(node, verseDiscovery).ConfigureAwait(false);
var startedAtUtc = DateTimeOffset.UtcNow.ToString("O");
await PublishRuntimeSessionAsync(node, startedAtUtc, "starting").ConfigureAwait(false);
await EnsureWorldDocumentAsync(node).ConfigureAwait(false);
await ApplyPendingRuntimeCommitsAsync(node).ConfigureAwait(false);
await ApplyPendingEveCommandsAsync(node).ConfigureAwait(false);
await RefreshVerseDiscoveryAsync(node, verseDiscovery).ConfigureAwait(false);
await PublishRuntimeSessionAsync(node, startedAtUtc, "running").ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
@ -34,7 +37,7 @@ internal static class Program
}
Console.WriteLine("Aetheria CultMesh state host is running. Press Ctrl+C to stop.");
await RunUntilShutdownAsync(node, startedAtUtc, PendingInterval(args)).ConfigureAwait(false);
await RunUntilShutdownAsync(node, verseDiscovery, startedAtUtc, PendingInterval(args)).ConfigureAwait(false);
await PublishRuntimeSessionAsync(node, startedAtUtc, "stopping").ConfigureAwait(false);
Console.WriteLine("Aetheria CultMesh state host stopping.");
@ -286,6 +289,7 @@ internal static class Program
private static async Task RunUntilShutdownAsync(
AetheriaStateNode node,
AetheriaVerseDiscoveryHost verseDiscovery,
string startedAtUtc,
TimeSpan pendingInterval)
{
@ -305,10 +309,19 @@ internal static class Program
await ApplyPendingRuntimeCommitsAsync(node).ConfigureAwait(false);
await ApplyPendingEveCommandsAsync(node).ConfigureAwait(false);
await RefreshVerseDiscoveryAsync(node, verseDiscovery).ConfigureAwait(false);
await PublishRuntimeSessionAsync(node, startedAtUtc, "running").ConfigureAwait(false);
}
}
private static async Task RefreshVerseDiscoveryAsync(
AetheriaStateNode node,
AetheriaVerseDiscoveryHost verseDiscovery)
{
var settings = await node.GetVerseHostSettingsAsync().ConfigureAwait(false);
verseDiscovery.Update(settings);
}
private static TimeSpan PendingInterval(IReadOnlyList<string> args)
{
for (var i = 0; i < args.Count - 1; i++)