Resolve Eve plugin deployments from advertisements

This commit is contained in:
Meta Vens 2026-07-12 21:19:25 +01:00
commit 5118387a44
5 changed files with 259 additions and 3 deletions

View file

@ -23,16 +23,19 @@ namespace GameCult.Eve.Surface
internal EveSurfaceSelection(
EveProviderAdvertisementDocument provider,
EveAdvertisedSurface advertisement,
CultMeshDocumentHandle<EveSurfaceDocument> surface)
CultMeshDocumentHandle<EveSurfaceDocument> surface,
IReadOnlyList<EvePluginAdvertisementDocument> plugins)
{
Provider = provider;
Advertisement = advertisement;
Surface = surface;
Plugins = plugins;
}
public EveProviderAdvertisementDocument Provider { get; }
public EveAdvertisedSurface Advertisement { get; }
public CultMeshDocumentHandle<EveSurfaceDocument> Surface { get; }
public IReadOnlyList<EvePluginAdvertisementDocument> Plugins { get; }
}
public static class EveCultMeshSurfaceSelection
@ -48,12 +51,54 @@ namespace GameCult.Eve.Surface
var providers = await mesh.CollectionAsync<EveProviderAdvertisementDocument>(endpointId, cancellationToken)
.ConfigureAwait(false);
var match = Select(await providers.LatestAsync().ConfigureAwait(false), resolved);
IReadOnlyList<EvePluginAdvertisementDocument> plugins = Array.Empty<EvePluginAdvertisementDocument>();
if (match.Surface.RequiresPlugins.Count > 0)
{
var advertisements = await mesh.CollectionAsync<EvePluginAdvertisementDocument>(endpointId, cancellationToken)
.ConfigureAwait(false);
plugins = ResolvePlugins(match.Surface, await advertisements.LatestAsync().ConfigureAwait(false));
}
var surface = await mesh.DocumentAsync<EveSurfaceDocument>(
endpointId,
match.Surface.RecordRef,
cancellationToken)
.ConfigureAwait(false);
return new EveSurfaceSelection(match.Provider, match.Surface, surface);
return new EveSurfaceSelection(match.Provider, match.Surface, surface, plugins);
}
public static IReadOnlyList<EvePluginAdvertisementDocument> ResolvePlugins(
EveAdvertisedSurface surface,
IEnumerable<EvePluginAdvertisementDocument> advertisements)
{
if (surface == null) throw new ArgumentNullException(nameof(surface));
if (advertisements == null) throw new ArgumentNullException(nameof(advertisements));
var available = advertisements
.GroupBy(plugin => plugin.PluginId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.OrderByDescending(plugin => plugin.Version, StringComparer.Ordinal).First(), StringComparer.Ordinal);
var selected = new List<EvePluginAdvertisementDocument>();
foreach (var requirement in surface.RequiresPlugins)
{
if (!available.TryGetValue(requirement.PluginId, out var plugin))
{
if (string.Equals(requirement.Availability, "required", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Eve surface '{surface.SurfaceId}' requires unavailable plugin '{requirement.PluginId}'.");
continue;
}
var claims = plugin.Schemas.Concat(plugin.ComponentKinds).Concat(plugin.Commands)
.ToHashSet(StringComparer.Ordinal);
var missing = requirement.RequiredCapabilities.Where(capability => !claims.Contains(capability)).ToArray();
if (missing.Length > 0)
{
if (string.Equals(requirement.Availability, "required", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Eve plugin '{plugin.PluginId}' does not advertise required capabilities: {string.Join(", ", missing)}.");
continue;
}
selected.Add(plugin);
}
return selected;
}
public static (EveProviderAdvertisementDocument Provider, EveAdvertisedSurface Surface) Select(

View file

@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
#nullable enable
namespace GameCult.Eve.Surface
{
[CultDocument("gamecult.eve.plugin_advertisement", SchemaId)]
[MessagePackObject]
public sealed class EvePluginAdvertisementDocument
{
public const string SchemaId = "gamecult.eve.plugin_advertisement.v1";
public EvePluginAdvertisementDocument(
string pluginId,
string ownerService,
string version,
string manifestAddress,
EvePluginRuntimeAdvertisement runtime,
IReadOnlyList<string> schemas,
IReadOnlyList<string> componentKinds,
IReadOnlyList<string> commands,
IReadOnlyList<string> fixtures)
: this(SchemaId, pluginId, ownerService, version, manifestAddress, runtime, schemas, componentKinds, commands, fixtures)
{
}
[SerializationConstructor]
public EvePluginAdvertisementDocument(
string schema,
string pluginId,
string ownerService,
string version,
string manifestAddress,
EvePluginRuntimeAdvertisement runtime,
IReadOnlyList<string> schemas,
IReadOnlyList<string> componentKinds,
IReadOnlyList<string> commands,
IReadOnlyList<string> fixtures)
{
Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
PluginId = pluginId ?? "";
OwnerService = ownerService ?? "";
Version = version ?? "";
ManifestAddress = manifestAddress ?? "";
Runtime = runtime ?? new EvePluginRuntimeAdvertisement("", "", Array.Empty<string>(), Array.Empty<string>(), null);
Schemas = schemas ?? Array.Empty<string>();
ComponentKinds = componentKinds ?? Array.Empty<string>();
Commands = commands ?? Array.Empty<string>();
Fixtures = fixtures ?? Array.Empty<string>();
}
[Key(0)] public string Schema { get; }
[Key(1), CultName] public string PluginId { get; }
[Key(2)] public string OwnerService { get; }
[Key(3)] public string Version { get; }
[Key(4)] public string ManifestAddress { get; }
[Key(5)] public EvePluginRuntimeAdvertisement Runtime { get; }
[Key(6)] public IReadOnlyList<string> Schemas { get; }
[Key(7)] public IReadOnlyList<string> ComponentKinds { get; }
[Key(8)] public IReadOnlyList<string> Commands { get; }
[Key(9)] public IReadOnlyList<string> Fixtures { get; }
}
[MessagePackObject]
public sealed class EvePluginRuntimeAdvertisement
{
[SerializationConstructor]
public EvePluginRuntimeAdvertisement(
string invocationModel,
string contract,
IReadOnlyList<string> transports,
IReadOnlyList<string> authority,
EvePluginSidecarAdvertisement? sidecar)
{
InvocationModel = invocationModel ?? "";
Contract = contract ?? "";
Transports = transports ?? Array.Empty<string>();
Authority = authority ?? Array.Empty<string>();
Sidecar = sidecar;
}
[Key(0)] public string InvocationModel { get; }
[Key(1)] public string Contract { get; }
[Key(2)] public IReadOnlyList<string> Transports { get; }
[Key(3)] public IReadOnlyList<string> Authority { get; }
[Key(4)] public EvePluginSidecarAdvertisement? Sidecar { get; }
}
[MessagePackObject]
public sealed class EvePluginSidecarAdvertisement
{
[SerializationConstructor]
public EvePluginSidecarAdvertisement(
string processKind,
string protocol,
string requestSchema,
string responseSchema,
IReadOnlyList<string> operations,
string commandEnvelope,
string receiptSchema,
string stateAuthority)
{
ProcessKind = processKind ?? "";
Protocol = protocol ?? "";
RequestSchema = requestSchema ?? "";
ResponseSchema = responseSchema ?? "";
Operations = operations ?? Array.Empty<string>();
CommandEnvelope = commandEnvelope ?? "";
ReceiptSchema = receiptSchema ?? "";
StateAuthority = stateAuthority ?? "";
}
[Key(0)] public string ProcessKind { get; }
[Key(1)] public string Protocol { get; }
[Key(2)] public string RequestSchema { get; }
[Key(3)] public string ResponseSchema { get; }
[Key(4)] public IReadOnlyList<string> Operations { get; }
[Key(5)] public string CommandEnvelope { get; }
[Key(6)] public string ReceiptSchema { get; }
[Key(7)] public string StateAuthority { get; }
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a5b91c7defe47d2a38940a460aa59c5

View file

@ -119,7 +119,8 @@ namespace GameCult.Eve.Surface
string transport,
string status,
string surfaceKind,
EveWorldInteractionAdvertisement? worldInteraction = null)
EveWorldInteractionAdvertisement? worldInteraction = null,
IReadOnlyList<EvePluginRequirement>? requiresPlugins = null)
{
SurfaceId = surfaceId ?? "";
Schema = schema ?? "";
@ -128,6 +129,7 @@ namespace GameCult.Eve.Surface
Status = status ?? "";
SurfaceKind = surfaceKind ?? "";
WorldInteraction = worldInteraction;
RequiresPlugins = requiresPlugins ?? Array.Empty<EvePluginRequirement>();
}
[Key(0)] public string SurfaceId { get; }
@ -137,6 +139,32 @@ namespace GameCult.Eve.Surface
[Key(4)] public string Status { get; }
[Key(5)] public string SurfaceKind { get; }
[Key(6)] public EveWorldInteractionAdvertisement? WorldInteraction { get; }
[Key(7)] public IReadOnlyList<EvePluginRequirement> RequiresPlugins { get; }
}
[MessagePackObject]
public sealed class EvePluginRequirement
{
[SerializationConstructor]
public EvePluginRequirement(
string pluginId,
string versionRange,
string availability,
IReadOnlyList<string> requiredCapabilities,
IReadOnlyList<string> optionalCapabilities)
{
PluginId = pluginId ?? "";
VersionRange = versionRange ?? "";
Availability = string.IsNullOrWhiteSpace(availability) ? "required" : availability;
RequiredCapabilities = requiredCapabilities ?? Array.Empty<string>();
OptionalCapabilities = optionalCapabilities ?? Array.Empty<string>();
}
[Key(0)] public string PluginId { get; }
[Key(1)] public string VersionRange { get; }
[Key(2)] public string Availability { get; }
[Key(3)] public IReadOnlyList<string> RequiredCapabilities { get; }
[Key(4)] public IReadOnlyList<string> OptionalCapabilities { get; }
}
[MessagePackObject]

View file

@ -61,6 +61,41 @@ namespace GameCult.Eve.UnityScene.Tests
new EveSurfaceRequest { LoweringTarget = "flutter" }));
}
[Test]
public void ResolvePlugins_KeepsIndependentRequiredAndOptionalNestedCapabilities()
{
var surface = new EveAdvertisedSurface(
"sai.stage", EveSurfaceDocument.SchemaId, "eve:surface:sai", "cultmesh-record", "active", "interface",
requiresPlugins: new[]
{
new EvePluginRequirement("sai.vn", "^0.1.0", "required", new[] { "vn.stage", "story.choose" }, Array.Empty<string>()),
new EvePluginRequirement("norn.graph", "^0.1.0", "optional-nested", Array.Empty<string>(), new[] { "embed.norn" }),
new EvePluginRequirement("tex.math", "^0.1.0", "optional-nested", Array.Empty<string>(), new[] { "embed.tex" })
});
var sai = Plugin("sai.vn", new[] { "vn.stage" }, new[] { "story.choose" });
var norn = Plugin("norn.graph", new[] { "embed.norn" }, Array.Empty<string>());
var selected = EveCultMeshSurfaceSelection.ResolvePlugins(surface, new[] { sai, norn });
Assert.That(selected, Is.EqualTo(new[] { sai, norn }));
}
[Test]
public void ResolvePlugins_RejectsMissingRequiredCapability()
{
var surface = new EveAdvertisedSurface(
"sai.stage", EveSurfaceDocument.SchemaId, "eve:surface:sai", "cultmesh-record", "active", "interface",
requiresPlugins: new[]
{
new EvePluginRequirement("sai.vn", "^0.1.0", "required", new[] { "story.choose" }, Array.Empty<string>())
});
Assert.Throws<InvalidOperationException>(() =>
EveCultMeshSurfaceSelection.ResolvePlugins(
surface,
new[] { Plugin("sai.vn", new[] { "vn.stage" }, Array.Empty<string>()) }));
}
private static EveProviderAdvertisementDocument Provider(EveAdvertisedSurface surface) =>
new EveProviderAdvertisementDocument(
"aetheria.daemon",
@ -75,5 +110,26 @@ namespace GameCult.Eve.UnityScene.Tests
Array.Empty<EveProviderWitness>(),
new[] { surface },
Array.Empty<EveAdvertisedCommand>());
private static EvePluginAdvertisementDocument Plugin(
string pluginId,
string[] components,
string[] commands) =>
new EvePluginAdvertisementDocument(
pluginId,
pluginId.Split('.')[0],
"0.1.0",
"cultmesh://plugins/" + pluginId,
new EvePluginRuntimeAdvertisement(
"sidecar",
"gamecult.eve.plugin_abi.v1",
new[] { "cultmesh" },
new[] { "plugin" },
new EvePluginSidecarAdvertisement(
"daemon", "cultmesh", "request", "response", Array.Empty<string>(), "command", "receipt", "plugin")),
Array.Empty<string>(),
components,
commands,
Array.Empty<string>());
}
}