From f191767471c35b46153a7955b95ea563f6524344 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 19:27:11 +0100 Subject: [PATCH] Use canonical Eve surface publication --- src/Ymir.Core/Ymir.Core.csproj | 2 + .../YmirServicePublicationDocument.cs | 187 +++++++++++++++++ src/ymir-daemon/Program.cs | 188 ++---------------- .../Ymir.Tests/YmirServicePublicationTests.cs | 119 +++++++++++ 4 files changed, 324 insertions(+), 172 deletions(-) create mode 100644 src/Ymir.Core/YmirServicePublicationDocument.cs create mode 100644 tests/Ymir.Tests/YmirServicePublicationTests.cs diff --git a/src/Ymir.Core/Ymir.Core.csproj b/src/Ymir.Core/Ymir.Core.csproj index 02139b9..241aa28 100644 --- a/src/Ymir.Core/Ymir.Core.csproj +++ b/src/Ymir.Core/Ymir.Core.csproj @@ -2,11 +2,13 @@ $(MSBuildThisFileDirectory)..\..\..\CultLib + $(MSBuildThisFileDirectory)..\..\..\EveUnity + diff --git a/src/Ymir.Core/YmirServicePublicationDocument.cs b/src/Ymir.Core/YmirServicePublicationDocument.cs new file mode 100644 index 0000000..da1d154 --- /dev/null +++ b/src/Ymir.Core/YmirServicePublicationDocument.cs @@ -0,0 +1,187 @@ +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using GameCult.Eve.Surface; +using CultMath; +using MessagePack; + +namespace Ymir.Core; + +[CultDocument("gamecult.ymir.provider_advertisement", "gamecult.ymir.provider_advertisement.v1")] +[MessagePackObject] +public sealed class YmirProviderAdvertisementDocument +{ + [Key(0)] + [CultName] + public string ProviderId { get; set; } = "ymir.physics"; + + [Key(1)] + public string Title { get; set; } = "Ymir Physics"; + + [Key(2)] + public string Description { get; set; } = "GameCult physics/world substrate engine built on CultMath."; + + [Key(3)] + public string[] Owns { get; set; } = []; + + [Key(4)] + public string[] DoesNotOwn { get; set; } = []; + + [Key(5)] + public YmirTransportLoweringDocument[] CommandLowerings { get; set; } = []; + + [Key(6)] + public string UpdatedAtUtc { get; set; } = ""; +} + +[CultDocument("gamecult.ymir.operator_state", "gamecult.ymir.operator_state.v1")] +[MessagePackObject] +public sealed class YmirOperatorStateDocument +{ + [Key(0)] + [CultName] + public string ProviderId { get; set; } = "ymir.physics"; + + [Key(1)] + public string Status { get; set; } = "mvp"; + + [Key(2)] + public string StateOwner { get; set; } = "Ymir.Core step and query results"; + + [Key(3)] + public string NumericSubstrate { get; set; } = "CultMath"; + + [Key(4)] + public string BatchKernel { get; set; } = ""; + + [Key(5)] + public string Persistence { get; set; } = "CultCache MessagePack directory store for gamecult.ymir.world_state.v1"; + + [Key(6)] + public string UpdatedAtUtc { get; set; } = ""; +} + +[MessagePackObject] +public sealed class YmirTransportLoweringDocument +{ + [Key(0)] + public string Label { get; set; } = ""; + + [Key(1)] + public string Method { get; set; } = "POST"; + + [Key(2)] + public string Path { get; set; } = ""; + + [Key(3)] + public string Purpose { get; set; } = ""; +} + +public static class YmirServicePublication +{ + public static YmirProviderAdvertisementDocument ProviderAdvertisement(DateTimeOffset now) => new() + { + ProviderId = "ymir.physics", + Title = "Ymir Physics", + Description = "GameCult physics/world substrate engine built on CultMath.", + Owns = + [ + "physics simulation truth", + "body integration", + "field acceleration", + "contact generation", + "collision query semantics", + "SoA CultCache world-state persistence" + ], + DoesNotOwn = + [ + "Unity scene truth", + "rendering", + "gameplay damage policy", + "editor authoring truth" + ], + CommandLowerings = [], + UpdatedAtUtc = FormatUtc(now) + }; + + public static YmirOperatorStateDocument OperatorState(DateTimeOffset now) => new() + { + ProviderId = "ymir.physics", + Status = "mvp", + StateOwner = "Ymir.Core step and query results", + NumericSubstrate = "CultMath", + BatchKernel = $"CultMath.BatchMath lanes={BatchMath.LaneCount}, hardwareAccelerated={BatchMath.IsHardwareAccelerated}", + Persistence = "CultCache MessagePack directory store for gamecult.ymir.world_state.v1", + UpdatedAtUtc = FormatUtc(now) + }; + + public static EveSurfaceDocument EveSurface(YmirOperatorStateDocument diagnostics) => + GameCult.Eve.Surface.EveSurface.Create("ymir.physics.operator") + .Provider(diagnostics.ProviderId, "physics.operator") + .Title("Ymir Physics") + .UpdatedAtUtc(diagnostics.UpdatedAtUtc) + .RootColumn("ymir.physics.operator.root", root => root + .Metric("ymir.physics.operator.status", "Status", diagnostics.Status) + .Metric("ymir.physics.operator.owner", "State owner", diagnostics.StateOwner) + .Metric("ymir.physics.operator.math", "Numeric substrate", diagnostics.NumericSubstrate) + .Metric("ymir.physics.operator.batch", "Batch kernel", diagnostics.BatchKernel) + .Metric("ymir.physics.operator.persistence", "Persistence", diagnostics.Persistence)) + .Build(); + + public static EveSurfaceDocument EveSurface(DateTimeOffset now) + { + return EveSurface(OperatorState(now)); + } + + private static string FormatUtc(DateTimeOffset value) => value.UtcDateTime.ToString("O"); +} + +public static class YmirServicePublicationStore +{ + public const string SurfaceRecordKey = "surface:ymir.physics.operator"; + + public static async Task RegenerateDerivedStoreAsync(string path, DateTimeOffset now) + { + DeleteDerivedStore(path); + await PublishAsync(path, now).ConfigureAwait(false); + } + + public static async Task PublishAsync(string path, DateTimeOffset now) + { + var cache = await CultCacheMessagePack.OpenAsync( + path, + new CultCacheOpenOptions + { + UseDirectoryStore = true, + FlushOnDispose = true, + StoreFlushOnDispose = true + }).ConfigureAwait(false); + + using (cache) + { + var operatorState = YmirServicePublication.OperatorState(now); + await cache.UpsertAsync( + YmirServicePublication.ProviderAdvertisement(now), + new CultRecordHandle(new CultRecordKey("ymir:provider:ymir.physics"))) + .ConfigureAwait(false); + await cache.UpsertAsync( + operatorState, + new CultRecordHandle(new CultRecordKey("ymir:operator:ymir.physics"))) + .ConfigureAwait(false); + await cache.UpsertAsync( + YmirServicePublication.EveSurface(operatorState), + new CultRecordHandle(new CultRecordKey(SurfaceRecordKey))) + .ConfigureAwait(false); + await cache.FlushAsync().ConfigureAwait(false); + } + } + + private static void DeleteDerivedStore(string path) + { + if (File.Exists(path)) + File.Delete(path); + + var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path); + if (Directory.Exists(recordsPath)) + Directory.Delete(recordsPath, recursive: true); + } +} diff --git a/src/ymir-daemon/Program.cs b/src/ymir-daemon/Program.cs index b4beb73..88b4b49 100644 --- a/src/ymir-daemon/Program.cs +++ b/src/ymir-daemon/Program.cs @@ -1,5 +1,3 @@ -using System.Net; -using System.Text; using System.Text.Json; using CultMath; using Ymir.Core; @@ -19,11 +17,8 @@ internal static class Program switch (command) { - case "provider": - WriteJson(ProviderAdvertisement()); - return 0; - case "operator-state": - WriteJson(OperatorState()); + case "publish-service": + await PublishServiceAsync(ValueAfter(args, "--path") ?? ".ymir/service.cc"); return 0; case "step-sample": WriteJson(new YmirSimulator().Step(SampleRequest())); @@ -33,9 +28,6 @@ internal static class Program return 0; case "step": return RunStep(args); - case "serve": - await ServeAsync(ParsePort(args, 8877)); - return 0; default: PrintHelp(); return command is "help" or "-h" or "--help" ? 0 : 1; @@ -65,146 +57,6 @@ internal static class Program return 0; } - private static async Task ServeAsync(int port) - { - using var listener = new HttpListener(); - listener.Prefixes.Add($"http://127.0.0.1:{port}/"); - listener.Start(); - Console.WriteLine($"Ymir serving on http://127.0.0.1:{port}/"); - - while (true) - { - var context = await listener.GetContextAsync(); - _ = Task.Run(() => HandleRequestAsync(context)); - } - } - - private static async Task HandleRequestAsync(HttpListenerContext context) - { - try - { - var path = context.Request.Url?.AbsolutePath ?? "/"; - var method = context.Request.HttpMethod; - - if (method == "GET" && path == "/health") - { - await WriteResponseAsync(context, new { ok = true, service = "ymir", checkedAt = DateTimeOffset.UtcNow }); - return; - } - - if (method == "GET" && path == "/provider-advertisement") - { - await WriteResponseAsync(context, ProviderAdvertisement()); - return; - } - - if (method == "GET" && path == "/operator-state") - { - await WriteResponseAsync(context, OperatorState()); - return; - } - - if (method == "GET" && path == "/eve/operator") - { - await WriteResponseAsync(context, EveOperatorSurface()); - return; - } - - if (method == "POST" && path == "/simulate/step") - { - using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding); - var body = await reader.ReadToEndAsync(); - var request = JsonSerializer.Deserialize(body, JsonOptions); - if (request is null) - { - await WriteErrorAsync(context, 400, "Invalid simulation request."); - return; - } - - await WriteResponseAsync(context, new YmirSimulator().Step(request)); - return; - } - - await WriteErrorAsync(context, 404, "No such Ymir route."); - } - catch (Exception error) - { - await WriteErrorAsync(context, 500, error.Message); - } - } - - private static object ProviderAdvertisement() => new - { - schema = "gamecult.provider_advertisement.v1", - providerId = "ymir.physics", - title = "Ymir Physics", - description = "GameCult physics/world substrate engine built on CultMath.", - authority = new - { - owns = new[] - { - "physics simulation truth", - "body integration", - "field acceleration", - "contact generation", - "collision query semantics", - "SoA CultCache world-state persistence" - }, - doesNotOwn = new[] - { - "Unity scene truth", - "rendering", - "gameplay damage policy", - "editor authoring truth" - } - }, - routes = new - { - health = "/health", - providerAdvertisement = "/provider-advertisement", - operatorState = "/operator-state", - operatorSurface = "/eve/operator", - step = "/simulate/step" - } - }; - - private static object OperatorState() => new - { - schema = "gamecult.ymir.operator_state.v0", - providerId = "ymir.physics", - status = "mvp", - stateOwner = "Ymir.Core step result; JSON is compatibility witness only", - numericSubstrate = "CultMath", - batchKernel = $"CultMath.BatchMath lanes={BatchMath.LaneCount}, hardwareAccelerated={BatchMath.IsHardwareAccelerated}", - persistence = "CultCache MessagePack directory store for gamecult.ymir.world_state.v0", - updatedAt = DateTimeOffset.UtcNow - }; - - private static object EveOperatorSurface() => new - { - schema = "gamecult.eve.surface.v1", - providerId = "ymir.physics", - title = "Ymir Physics", - updatedAt = DateTimeOffset.UtcNow, - surface = new - { - root = new - { - type = "panel", - title = "Ymir Physics", - children = new object[] - { - new { type = "stat", label = "Status", value = "mvp" }, - new { type = "stat", label = "Owner", value = "physics simulation truth" }, - new { type = "stat", label = "Math", value = "CultMath" }, - new { type = "stat", label = "Batch lanes", value = BatchMath.LaneCount.ToString() }, - new { type = "stat", label = "Persistence", value = "CultCache SoA world_state.v0" }, - new { type = "route", label = "Step", value = "POST /simulate/step" } - } - } - } - }; - private static SimulationStepRequest SampleRequest() => new( 0.1f, new YmirWorld( @@ -236,30 +88,24 @@ internal static class Program }); } - private static async Task WriteResponseAsync(HttpListenerContext context, object value) + private static async Task PublishServiceAsync(string path) { - var json = JsonSerializer.Serialize(value, JsonOptions); - var bytes = Encoding.UTF8.GetBytes(json); - context.Response.ContentType = "application/json; charset=utf-8"; - context.Response.ContentLength64 = bytes.Length; - await context.Response.OutputStream.WriteAsync(bytes); - context.Response.Close(); - } - - private static Task WriteErrorAsync(HttpListenerContext context, int statusCode, string message) - { - context.Response.StatusCode = statusCode; - return WriteResponseAsync(context, new { ok = false, error = message }); + await YmirServicePublicationStore.RegenerateDerivedStoreAsync(path, DateTimeOffset.UtcNow); + WriteJson(new + { + ok = true, + path, + records = new[] + { + "ymir:provider:ymir.physics", + "ymir:operator:ymir.physics", + "surface:ymir.physics.operator" + } + }); } private static void WriteJson(object value) => Console.WriteLine(JsonSerializer.Serialize(value, JsonOptions)); - private static int ParsePort(string[] args, int defaultPort) - { - var raw = ValueAfter(args, "--port"); - return int.TryParse(raw, out var port) ? port : defaultPort; - } - private static string? ValueAfter(string[] args, string key) { var index = Array.IndexOf(args, key); @@ -272,12 +118,10 @@ internal static class Program Ymir physics daemon Commands: - provider - operator-state + publish-service [--path .ymir/service.cc] Regenerate the derived service publication. step-sample save-sample [--path .ymir/worlds.cc] step --request - serve [--port 8877] """); } } diff --git a/tests/Ymir.Tests/YmirServicePublicationTests.cs b/tests/Ymir.Tests/YmirServicePublicationTests.cs new file mode 100644 index 0000000..5aedbac --- /dev/null +++ b/tests/Ymir.Tests/YmirServicePublicationTests.cs @@ -0,0 +1,119 @@ +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using GameCult.Eve.Surface; +using Ymir.Core; + +namespace Ymir.Tests; + +public sealed class YmirServicePublicationTests +{ + [Fact] + public void EveSurfaceUsesCanonicalDocumentAndOperatorMetrics() + { + var diagnostics = new YmirOperatorStateDocument + { + ProviderId = "ymir.physics", + Status = "ready", + StateOwner = "diagnostic-owner", + NumericSubstrate = "diagnostic-math", + BatchKernel = "diagnostic-batch", + Persistence = "diagnostic-persistence", + UpdatedAtUtc = "2026-07-13T12:00:00.0000000Z" + }; + + EveSurfaceDocument surface = YmirServicePublication.EveSurface(diagnostics); + + Assert.Equal(EveSurfaceDocument.SchemaId, surface.Schema); + Assert.Equal("ymir.physics.operator", surface.Surface.Id); + Assert.Equal("partition", surface.Surface.Root.Kind); + Assert.Equal(diagnostics.UpdatedAtUtc, surface.UpdatedAtUtc); + Assert.Equal( + new[] + { + ("Status", diagnostics.Status), + ("State owner", diagnostics.StateOwner), + ("Numeric substrate", diagnostics.NumericSubstrate), + ("Batch kernel", diagnostics.BatchKernel), + ("Persistence", diagnostics.Persistence) + }, + surface.Surface.Root.Children + .Select(child => (child.GetProp("label"), child.GetProp("value"))) + .ToArray()); + } + + [Fact] + public void CombinedRegistryContainsExactlyOneCanonicalSurfaceDescriptor() + { + var registry = new CultDocumentRegistry(); + + var descriptors = registry.AllDescriptors + .Where(descriptor => descriptor.SchemaVersion == EveSurfaceDocument.SchemaId) + .ToArray(); + + var descriptor = Assert.Single(descriptors); + Assert.Equal(typeof(EveSurfaceDocument), descriptor.DocumentType); + } + + [Fact] + public async Task PublicationPersistsAndReopensWithCombinedRegistry() + { + var path = TemporaryStorePath(); + try + { + await YmirServicePublicationStore.RegenerateDerivedStoreAsync(path, DateTimeOffset.UtcNow); + + using var cache = await CultCacheMessagePack.OpenAsync(path, new CultCacheOpenOptions + { + Registry = new CultDocumentRegistry(), + UseDirectoryStore = true + }); + + var surface = cache.Get(new CultRecordKey(YmirServicePublicationStore.SurfaceRecordKey)); + Assert.NotNull(surface); + Assert.Equal("ymir.physics.operator", surface.Surface.Id); + Assert.Single(cache.GetAll()); + } + finally + { + DeleteStore(path); + } + } + + [Fact] + public async Task RegenerationReplacesUnreadableLegacyDerivedStoreBeforeOpen() + { + var path = TemporaryStorePath(); + var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + Directory.CreateDirectory(recordsPath); + await File.WriteAllBytesAsync(path, [0xc1]); + await File.WriteAllBytesAsync(Path.Combine(recordsPath, "legacy.msgpack"), [0xc1]); + + await YmirServicePublicationStore.RegenerateDerivedStoreAsync(path, DateTimeOffset.UtcNow); + + using var cache = await CultCacheMessagePack.OpenAsync(path, new CultCacheOpenOptions + { + Registry = new CultDocumentRegistry(), + UseDirectoryStore = true + }); + Assert.NotNull(cache.Get(new CultRecordKey(YmirServicePublicationStore.SurfaceRecordKey))); + Assert.False(File.Exists(Path.Combine(recordsPath, "legacy.msgpack"))); + } + finally + { + DeleteStore(path); + } + } + + private static string TemporaryStorePath() => + Path.Combine(Path.GetTempPath(), "ymir-tests", Guid.NewGuid().ToString("N"), "service.cc"); + + private static void DeleteStore(string path) + { + var directory = Path.GetDirectoryName(path); + if (directory is not null && Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } +}