using System.Text.Json; namespace AcDream.Core.Plugins; /// Host facility an entry assembly declares in plugin.json. public enum PluginKind { Gameplay, RenderPack, } public sealed record PluginManifest( string Id, string DisplayName, string Version, string EntryDll, int ApiVersion, IReadOnlyList Dependencies) { /// /// Declared entry-point kinds. Old manifests omit kinds and remain /// gameplay plugins, preserving the pre-render-pack loading contract. /// public IReadOnlyList Kinds { get; init; } = [PluginKind.Gameplay]; public PluginManifest( string Id, string DisplayName, string Version, string EntryDll, int ApiVersion, IReadOnlyList Dependencies, IReadOnlyList Kinds) : this(Id, DisplayName, Version, EntryDll, ApiVersion, Dependencies) { ArgumentNullException.ThrowIfNull(Kinds); if (Kinds.Count == 0) throw new ArgumentException("At least one plugin kind is required.", nameof(Kinds)); this.Kinds = Kinds .Distinct() .ToArray(); } public bool Declares(PluginKind kind) => Kinds.Contains(kind); public static PluginManifest Parse(string json) { PluginManifestDto? dto; try { dto = JsonSerializer.Deserialize(json, JsonOptions); } catch (JsonException ex) { throw new PluginManifestException($"invalid json: {ex.Message}", ex); } if (dto is null) throw new PluginManifestException("manifest is empty"); Require(dto.Id, "id"); Require(dto.DisplayName, "displayName"); Require(dto.Version, "version"); Require(dto.EntryDll, "entryDll"); if (dto.ApiVersion <= 0) throw new PluginManifestException("apiVersion must be >= 1"); IReadOnlyList kinds = ParseKinds(dto.Kinds); return new PluginManifest( dto.Id!, dto.DisplayName!, dto.Version!, dto.EntryDll!, dto.ApiVersion, dto.Dependencies ?? Array.Empty(), kinds); } private static IReadOnlyList ParseKinds(IReadOnlyList? values) { if (values is null) return [PluginKind.Gameplay]; if (values.Count == 0) throw new PluginManifestException("kinds must contain at least one entry"); var kinds = new List(values.Count); foreach (string? value in values) { if (string.IsNullOrWhiteSpace(value) || !Enum.TryParse(value, ignoreCase: true, out PluginKind kind) || !Enum.IsDefined(kind)) { throw new PluginManifestException( $"unknown plugin kind: {value ?? ""}"); } if (!kinds.Contains(kind)) kinds.Add(kind); } return kinds; } private static void Require(string? value, string jsonFieldName) { if (string.IsNullOrWhiteSpace(value)) throw new PluginManifestException($"missing required field: {jsonFieldName}"); } private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, }; private sealed class PluginManifestDto { public string? Id { get; set; } public string? DisplayName { get; set; } public string? Version { get; set; } public string? EntryDll { get; set; } public int ApiVersion { get; set; } public IReadOnlyList? Dependencies { get; set; } public IReadOnlyList? Kinds { get; set; } } } /// /// A plugin declared a contract version this build cannot load. Distinct from /// (a malformed manifest) because the /// remedy differs: this one means update the plugin or the client. /// public sealed class PluginApiVersionException : Exception { public PluginApiVersionException(string message) : base(message) { } } public sealed class PluginManifestException : Exception { public PluginManifestException(string message) : base(message) { } public PluginManifestException(string message, Exception inner) : base(message, inner) { } }