From 5118387a44605155aa80a4de17b7fedffd7c95e0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 21:19:25 +0100 Subject: [PATCH] Resolve Eve plugin deployments from advertisements --- .../Runtime/EveCultMeshSurfaceSelection.cs | 49 ++++++- .../Runtime/EvePluginAdvertisementDocument.cs | 125 ++++++++++++++++++ .../EvePluginAdvertisementDocument.cs.meta | 2 + .../Runtime/EveProviderDocuments.cs | 30 ++++- .../EveCultMeshSurfaceSelectionTests.cs | 56 ++++++++ 5 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs index f4625d2..9c6f82f 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs @@ -23,16 +23,19 @@ namespace GameCult.Eve.Surface internal EveSurfaceSelection( EveProviderAdvertisementDocument provider, EveAdvertisedSurface advertisement, - CultMeshDocumentHandle surface) + CultMeshDocumentHandle surface, + IReadOnlyList plugins) { Provider = provider; Advertisement = advertisement; Surface = surface; + Plugins = plugins; } public EveProviderAdvertisementDocument Provider { get; } public EveAdvertisedSurface Advertisement { get; } public CultMeshDocumentHandle Surface { get; } + public IReadOnlyList Plugins { get; } } public static class EveCultMeshSurfaceSelection @@ -48,12 +51,54 @@ namespace GameCult.Eve.Surface var providers = await mesh.CollectionAsync(endpointId, cancellationToken) .ConfigureAwait(false); var match = Select(await providers.LatestAsync().ConfigureAwait(false), resolved); + IReadOnlyList plugins = Array.Empty(); + if (match.Surface.RequiresPlugins.Count > 0) + { + var advertisements = await mesh.CollectionAsync(endpointId, cancellationToken) + .ConfigureAwait(false); + plugins = ResolvePlugins(match.Surface, await advertisements.LatestAsync().ConfigureAwait(false)); + } var surface = await mesh.DocumentAsync( 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 ResolvePlugins( + EveAdvertisedSurface surface, + IEnumerable 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(); + 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( diff --git a/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs new file mode 100644 index 0000000..d213976 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs @@ -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 schemas, + IReadOnlyList componentKinds, + IReadOnlyList commands, + IReadOnlyList 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 schemas, + IReadOnlyList componentKinds, + IReadOnlyList commands, + IReadOnlyList fixtures) + { + Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; + PluginId = pluginId ?? ""; + OwnerService = ownerService ?? ""; + Version = version ?? ""; + ManifestAddress = manifestAddress ?? ""; + Runtime = runtime ?? new EvePluginRuntimeAdvertisement("", "", Array.Empty(), Array.Empty(), null); + Schemas = schemas ?? Array.Empty(); + ComponentKinds = componentKinds ?? Array.Empty(); + Commands = commands ?? Array.Empty(); + Fixtures = fixtures ?? Array.Empty(); + } + + [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 Schemas { get; } + [Key(7)] public IReadOnlyList ComponentKinds { get; } + [Key(8)] public IReadOnlyList Commands { get; } + [Key(9)] public IReadOnlyList Fixtures { get; } + } + + [MessagePackObject] + public sealed class EvePluginRuntimeAdvertisement + { + [SerializationConstructor] + public EvePluginRuntimeAdvertisement( + string invocationModel, + string contract, + IReadOnlyList transports, + IReadOnlyList authority, + EvePluginSidecarAdvertisement? sidecar) + { + InvocationModel = invocationModel ?? ""; + Contract = contract ?? ""; + Transports = transports ?? Array.Empty(); + Authority = authority ?? Array.Empty(); + Sidecar = sidecar; + } + + [Key(0)] public string InvocationModel { get; } + [Key(1)] public string Contract { get; } + [Key(2)] public IReadOnlyList Transports { get; } + [Key(3)] public IReadOnlyList 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 operations, + string commandEnvelope, + string receiptSchema, + string stateAuthority) + { + ProcessKind = processKind ?? ""; + Protocol = protocol ?? ""; + RequestSchema = requestSchema ?? ""; + ResponseSchema = responseSchema ?? ""; + Operations = operations ?? Array.Empty(); + 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 Operations { get; } + [Key(5)] public string CommandEnvelope { get; } + [Key(6)] public string ReceiptSchema { get; } + [Key(7)] public string StateAuthority { get; } + } +} diff --git a/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta new file mode 100644 index 0000000..69b1c21 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7a5b91c7defe47d2a38940a460aa59c5 diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs index e9bb1da..ec8e37f 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs @@ -119,7 +119,8 @@ namespace GameCult.Eve.Surface string transport, string status, string surfaceKind, - EveWorldInteractionAdvertisement? worldInteraction = null) + EveWorldInteractionAdvertisement? worldInteraction = null, + IReadOnlyList? requiresPlugins = null) { SurfaceId = surfaceId ?? ""; Schema = schema ?? ""; @@ -128,6 +129,7 @@ namespace GameCult.Eve.Surface Status = status ?? ""; SurfaceKind = surfaceKind ?? ""; WorldInteraction = worldInteraction; + RequiresPlugins = requiresPlugins ?? Array.Empty(); } [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 RequiresPlugins { get; } + } + + [MessagePackObject] + public sealed class EvePluginRequirement + { + [SerializationConstructor] + public EvePluginRequirement( + string pluginId, + string versionRange, + string availability, + IReadOnlyList requiredCapabilities, + IReadOnlyList optionalCapabilities) + { + PluginId = pluginId ?? ""; + VersionRange = versionRange ?? ""; + Availability = string.IsNullOrWhiteSpace(availability) ? "required" : availability; + RequiredCapabilities = requiredCapabilities ?? Array.Empty(); + OptionalCapabilities = optionalCapabilities ?? Array.Empty(); + } + + [Key(0)] public string PluginId { get; } + [Key(1)] public string VersionRange { get; } + [Key(2)] public string Availability { get; } + [Key(3)] public IReadOnlyList RequiredCapabilities { get; } + [Key(4)] public IReadOnlyList OptionalCapabilities { get; } } [MessagePackObject] diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs index 280dd24..d4b2189 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs @@ -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()), + new EvePluginRequirement("norn.graph", "^0.1.0", "optional-nested", Array.Empty(), new[] { "embed.norn" }), + new EvePluginRequirement("tex.math", "^0.1.0", "optional-nested", Array.Empty(), new[] { "embed.tex" }) + }); + var sai = Plugin("sai.vn", new[] { "vn.stage" }, new[] { "story.choose" }); + var norn = Plugin("norn.graph", new[] { "embed.norn" }, Array.Empty()); + + 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()) + }); + + Assert.Throws(() => + EveCultMeshSurfaceSelection.ResolvePlugins( + surface, + new[] { Plugin("sai.vn", new[] { "vn.stage" }, Array.Empty()) })); + } + private static EveProviderAdvertisementDocument Provider(EveAdvertisedSurface surface) => new EveProviderAdvertisementDocument( "aetheria.daemon", @@ -75,5 +110,26 @@ namespace GameCult.Eve.UnityScene.Tests Array.Empty(), new[] { surface }, Array.Empty()); + + 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(), "command", "receipt", "plugin")), + Array.Empty(), + components, + commands, + Array.Empty()); } }