#nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; namespace ReplacedPerson.Core { public interface IReplacedPersonMod { string Id { get; } string Version { get; } void Register(IReplacedPersonRegistry registry); } public interface IReplacedPersonRegistry { void RegisterCard(ReplacedCardDefinition definition); void RegisterEndCard(ReplacedEndCardDefinition definition); void RegisterEnemy(ReplacedEnemyDefinition definition); void RegisterReward(string id, string displayName); void RegisterStory(string id, string title, string body); void RegisterEffectResolver(string effectId, IReplacedPersonEffectResolver resolver); void RegisterCommand(string path, Func, string> handler); } public interface IReplacedPersonEffectResolver { ReplacedEffectResult Resolve(ReplacedEffectContext context); } public sealed class ReplacedEffectContext { public string EffectId { get; set; } = string.Empty; public int OwnerIndex { get; set; } public int OpponentIndex => 1 - OwnerIndex; public int Value { get; set; } public ReplacedMatchState State { get; set; } = null!; } public sealed class ReplacedEffectResult { public int BonusDamage { get; set; } public int CostDelta { get; set; } public int DrawDelta { get; set; } public string Log { get; set; } = string.Empty; } [Serializable] public sealed class ReplacedPersonModFile { public string Path { get; set; } = string.Empty; public string Sha256 { get; set; } = string.Empty; } [Serializable] public sealed class ReplacedPersonModManifest { public string Id { get; set; } = string.Empty; public string Version { get; set; } = string.Empty; public List Files { get; set; } = new(); } public sealed class ReplacedModCompatibilityResult { public bool Compatible => Differences.Count == 0; public List Differences { get; } = new(); } public static class ReplacedModCompatibility { public static ReplacedModCompatibilityResult Compare(IEnumerable authority, IEnumerable client) { var result = new ReplacedModCompatibilityResult(); var expected = authority.ToDictionary(value => value.Id, StringComparer.Ordinal); var actual = client.ToDictionary(value => value.Id, StringComparer.Ordinal); foreach (var id in expected.Keys.Union(actual.Keys, StringComparer.Ordinal).OrderBy(value => value, StringComparer.Ordinal)) { if (!expected.TryGetValue(id, out var expectedMod)) { result.Differences.Add("unexpected-mod:" + id); continue; } if (!actual.TryGetValue(id, out var actualMod)) { result.Differences.Add("missing-mod:" + id); continue; } if (!string.Equals(expectedMod.Version, actualMod.Version, StringComparison.Ordinal)) result.Differences.Add($"version:{id}:{expectedMod.Version}!={actualMod.Version}"); CompareFiles(id, expectedMod.Files, actualMod.Files, result.Differences); } return result; } public static string ComputeManifestSetHash(IEnumerable manifests) { var text = new StringBuilder(); foreach (var manifest in manifests.OrderBy(value => value.Id, StringComparer.Ordinal)) { text.Append(manifest.Id).Append('|').Append(manifest.Version).Append('\n'); foreach (var file in manifest.Files.OrderBy(value => value.Path, StringComparer.Ordinal)) text.Append(file.Path).Append('|').Append(file.Sha256.ToLowerInvariant()).Append('\n'); } using var sha = SHA256.Create(); var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(text.ToString())); return string.Concat(bytes.Select(value => value.ToString("x2"))); } private static void CompareFiles(string modId, IEnumerable expected, IEnumerable actual, ICollection differences) { var expectedFiles = expected.ToDictionary(value => value.Path, StringComparer.Ordinal); var actualFiles = actual.ToDictionary(value => value.Path, StringComparer.Ordinal); foreach (var path in expectedFiles.Keys.Union(actualFiles.Keys, StringComparer.Ordinal).OrderBy(value => value, StringComparer.Ordinal)) { if (!expectedFiles.TryGetValue(path, out var expectedFile)) { differences.Add($"unexpected-file:{modId}:{path}"); continue; } if (!actualFiles.TryGetValue(path, out var actualFile)) { differences.Add($"missing-file:{modId}:{path}"); continue; } if (!string.Equals(expectedFile.Sha256, actualFile.Sha256, StringComparison.OrdinalIgnoreCase)) differences.Add($"hash:{modId}:{path}"); } } } public sealed class ReplacedPersonRegistry : IReplacedPersonRegistry { private readonly ReplacedContentCatalog _catalog; private readonly Dictionary _rewards = new(StringComparer.Ordinal); private readonly Dictionary _stories = new(StringComparer.Ordinal); private readonly Dictionary _effects = new(StringComparer.Ordinal); private readonly Dictionary, string>> _commands = new(StringComparer.OrdinalIgnoreCase); public ReplacedPersonRegistry(ReplacedContentCatalog baseCatalog) => _catalog = baseCatalog.Clone(); public ReplacedContentCatalog CreateSnapshot() => _catalog.Clone(); public IReadOnlyDictionary Effects => _effects; public IReadOnlyDictionary, string>> Commands => _commands; public void RegisterCard(ReplacedCardDefinition definition) => AddUnique(_catalog.Cards, definition.Id, definition); public void RegisterEndCard(ReplacedEndCardDefinition definition) => AddUnique(_catalog.EndCards, definition.Id, definition); public void RegisterEnemy(ReplacedEnemyDefinition definition) => AddUnique(_catalog.Enemies, definition.Id, definition); public void RegisterReward(string id, string displayName) => AddUnique(_rewards, id, displayName); public void RegisterStory(string id, string title, string body) => AddUnique(_stories, id, (title, body)); public void RegisterEffectResolver(string effectId, IReplacedPersonEffectResolver resolver) { AddUnique(_effects, effectId, resolver); AddUnique(_catalog.EffectResolvers, effectId, resolver); } public void RegisterCommand(string path, Func, string> handler) => AddUnique(_commands, path, handler); private static void AddUnique(IDictionary target, string id, T value) { if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("Registry id must not be empty.", nameof(id)); var key = id.Trim(); if (target.ContainsKey(key)) throw new InvalidOperationException("Duplicate registry id: " + id); target.Add(key, value); } } public sealed class ReplacedHotReloadCatalog { public ReplacedContentCatalog Current { get; private set; } public ReplacedContentCatalog? Pending { get; private set; } public string CurrentHash => Current.ComputeHash(); public string PendingHash => Pending?.ComputeHash() ?? string.Empty; public ReplacedHotReloadCatalog(ReplacedContentCatalog initial) => Current = initial.Clone(); public void Stage(ReplacedContentCatalog next) => Pending = next.Clone(); public ReplacedContentCatalog SnapshotForNewMatch() { if (Pending != null) { Current = Pending; Pending = null; } return Current.Clone(); } } }