diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index aa85fcc74..dd5401104 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -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", 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", diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs index b7bb8a49c..1e6f7ea33 100644 --- a/Aetheria.State/AetheriaStateNode.cs +++ b/Aetheria.State/AetheriaStateNode.cs @@ -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; diff --git a/Aetheria.State/AetheriaVerseCatalogProjector.cs b/Aetheria.State/AetheriaVerseCatalogProjector.cs new file mode 100644 index 000000000..23f32491e --- /dev/null +++ b/Aetheria.State/AetheriaVerseCatalogProjector.cs @@ -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})"; + } +} diff --git a/Aetheria.State/AetheriaVerseDiscoveryHost.cs b/Aetheria.State/AetheriaVerseDiscoveryHost.cs new file mode 100644 index 000000000..c41103828 --- /dev/null +++ b/Aetheria.State/AetheriaVerseDiscoveryHost.cs @@ -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 ?? ""); + } +} diff --git a/Aetheria.State/README.md b/Aetheria.State/README.md index 529164cfa..27f578115 100644 --- a/Aetheria.State/README.md +++ b/Aetheria.State/README.md @@ -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. diff --git a/Economy.Server/Program.cs b/Economy.Server/Program.cs index 6e0d38693..beb1d609a 100644 --- a/Economy.Server/Program.cs +++ b/Economy.Server/Program.cs @@ -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 args) { for (var i = 0; i < args.Count - 1; i++)