Use canonical Eve surface publication

This commit is contained in:
Meta Vens 2026-07-13 19:27:11 +01:00
commit f191767471
4 changed files with 324 additions and 172 deletions

View file

@ -2,11 +2,13 @@
<PropertyGroup>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
<EveUnityRoot Condition="'$(EveUnityRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\EveUnity</EveUnityRoot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="$(EveUnityRoot)\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj" />
<ProjectReference Include="..\..\..\CultMath\src\CultMath\CultMath.csproj" />
</ItemGroup>

View file

@ -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<YmirProviderAdvertisementDocument>(new CultRecordKey("ymir:provider:ymir.physics")))
.ConfigureAwait(false);
await cache.UpsertAsync(
operatorState,
new CultRecordHandle<YmirOperatorStateDocument>(new CultRecordKey("ymir:operator:ymir.physics")))
.ConfigureAwait(false);
await cache.UpsertAsync(
YmirServicePublication.EveSurface(operatorState),
new CultRecordHandle<EveSurfaceDocument>(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);
}
}

View file

@ -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<SimulationStepRequest>(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 <path>
serve [--port 8877]
""");
}
}

View file

@ -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<EveSurfaceDocument>(new CultRecordKey(YmirServicePublicationStore.SurfaceRecordKey));
Assert.NotNull(surface);
Assert.Equal("ymir.physics.operator", surface.Surface.Id);
Assert.Single(cache.GetAll<EveSurfaceDocument>());
}
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<EveSurfaceDocument>(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);
}
}