170 lines
8.2 KiB
C#
170 lines
8.2 KiB
C#
#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<IReadOnlyList<string>, 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<ReplacedPersonModFile> Files { get; set; } = new();
|
|
}
|
|
|
|
public sealed class ReplacedModCompatibilityResult
|
|
{
|
|
public bool Compatible => Differences.Count == 0;
|
|
public List<string> Differences { get; } = new();
|
|
}
|
|
|
|
public static class ReplacedModCompatibility
|
|
{
|
|
public static ReplacedModCompatibilityResult Compare(IEnumerable<ReplacedPersonModManifest> authority,
|
|
IEnumerable<ReplacedPersonModManifest> 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<ReplacedPersonModManifest> 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<ReplacedPersonModFile> expected,
|
|
IEnumerable<ReplacedPersonModFile> actual, ICollection<string> 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<string, string> _rewards = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, (string title, string body)> _stories = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, IReplacedPersonEffectResolver> _effects = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, Func<IReadOnlyList<string>, string>> _commands = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public ReplacedPersonRegistry(ReplacedContentCatalog baseCatalog) => _catalog = baseCatalog.Clone();
|
|
public ReplacedContentCatalog CreateSnapshot() => _catalog.Clone();
|
|
public IReadOnlyDictionary<string, IReplacedPersonEffectResolver> Effects => _effects;
|
|
public IReadOnlyDictionary<string, Func<IReadOnlyList<string>, 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<IReadOnlyList<string>, string> handler) => AddUnique(_commands, path, handler);
|
|
|
|
private static void AddUnique<T>(IDictionary<string, T> 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();
|
|
}
|
|
}
|
|
}
|