diff --git a/My project/Assets/FlowScope/Runtime/Config/FileConfigSource.cs b/My project/Assets/FlowScope/Runtime/Config/FileConfigSource.cs new file mode 100644 index 0000000..be05e9a --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Config/FileConfigSource.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace FlowScope.Config +{ + public sealed class FileConfigSource : IConfigSource + { + private readonly string _rootPath; + + public FileConfigSource(string rootPath, string name = "File") + { + _rootPath = string.IsNullOrWhiteSpace(rootPath) + ? throw new ArgumentException("Config root path is required.", nameof(rootPath)) + : rootPath; + Name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Config source name is required.", nameof(name)) + : name; + } + + public string Name { get; } + + public Task LoadTextAsync(string fileName, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = Path.Combine(_rootPath, fileName); + return Task.Run(() => File.ReadAllText(path), cancellationToken); + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Config/FileConfigSource.cs.meta b/My project/Assets/FlowScope/Runtime/Config/FileConfigSource.cs.meta new file mode 100644 index 0000000..1fbea3e --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Config/FileConfigSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d50cd860ba849a1aa7647deba4e71db +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Config/InMemoryConfigSource.cs b/My project/Assets/FlowScope/Runtime/Config/InMemoryConfigSource.cs new file mode 100644 index 0000000..55ad6a2 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Config/InMemoryConfigSource.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace FlowScope.Config +{ + public sealed class InMemoryConfigSource : IConfigSource + { + private readonly Dictionary _files; + + public InMemoryConfigSource( + IReadOnlyDictionary files, + string name = "InMemory") + { + _files = files != null + ? new Dictionary(files) + : throw new ArgumentNullException(nameof(files)); + Name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Config source name is required.", nameof(name)) + : name; + } + + public string Name { get; } + + public Task LoadTextAsync(string fileName, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_files.TryGetValue(fileName, out var text)) + { + throw new InvalidOperationException($"Config file is missing: {fileName}"); + } + + return Task.FromResult(text); + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Config/InMemoryConfigSource.cs.meta b/My project/Assets/FlowScope/Runtime/Config/InMemoryConfigSource.cs.meta new file mode 100644 index 0000000..dcdce36 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Config/InMemoryConfigSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 607d9b20e7f04565929ea5876e5c25e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Config/JsonConfigParser.cs b/My project/Assets/FlowScope/Runtime/Config/JsonConfigParser.cs new file mode 100644 index 0000000..2cb4263 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Config/JsonConfigParser.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using FlowScope.Data; + +namespace FlowScope.Config +{ + public sealed class JsonConfigParser : IConfigParser + { + public string Format => "json"; + + public IReadOnlyList ParseRows(string fileName, string text) + where T : class, IConfigRow + { + return ReactivePropertyJsonConverter.DeserializeArray(text); + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Config/JsonConfigParser.cs.meta b/My project/Assets/FlowScope/Runtime/Config/JsonConfigParser.cs.meta new file mode 100644 index 0000000..cf41d48 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Config/JsonConfigParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f60f8a8e66394f26a20ed6f5ef314c63 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs b/My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs index 17ac4de..1e495b4 100644 --- a/My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs +++ b/My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Threading; using System.Threading.Tasks; -using FlowScope.Data; namespace FlowScope.Config { @@ -19,7 +19,19 @@ namespace FlowScope.Config public static ConfigMapping Mapping(string fileName, Func> loadTextAsync) where T : class, IConfigRow { - return new ConfigMapping(typeof(T), fileName, loadTextAsync); + return Mapping( + fileName, + new DelegateConfigSource(fileName, loadTextAsync), + new JsonConfigParser()); + } + + public static ConfigMapping Mapping( + string fileName, + IConfigSource source, + IConfigParser parser) + where T : class, IConfigRow + { + return new ConfigMapping(typeof(T), fileName, source, parser); } public async Task LoadAllAsync(CancellationToken cancellationToken) @@ -33,7 +45,7 @@ namespace FlowScope.Config string json; try { - json = await mapping.LoadTextAsync(cancellationToken); + json = await mapping.Source.LoadTextAsync(mapping.FileName, cancellationToken); } catch (OperationCanceledException) { @@ -41,7 +53,9 @@ namespace FlowScope.Config } catch (Exception exception) { - throw new InvalidOperationException($"Failed to load config file '{mapping.FileName}'.", exception); + throw new InvalidOperationException( + $"Failed to load config file '{mapping.FileName}' from source '{mapping.Source.Name}'.", + exception); } try @@ -84,42 +98,42 @@ namespace FlowScope.Config public sealed class ConfigMapping { - private readonly Func _parse; + private readonly MethodInfo _parseRowsMethod; - internal ConfigMapping(Type rowType, string fileName, Func> loadTextAsync) + internal ConfigMapping( + Type rowType, + string fileName, + IConfigSource source, + IConfigParser parser) { RowType = rowType ?? throw new ArgumentNullException(nameof(rowType)); FileName = string.IsNullOrWhiteSpace(fileName) ? throw new ArgumentException("Config file name is required.", nameof(fileName)) : fileName; - LoadTextAsync = loadTextAsync ?? throw new ArgumentNullException(nameof(loadTextAsync)); - _parse = CreateParser(rowType); + Source = source ?? throw new ArgumentNullException(nameof(source)); + Parser = parser ?? throw new ArgumentNullException(nameof(parser)); + _parseRowsMethod = typeof(IConfigParser) + .GetMethod(nameof(IConfigParser.ParseRows)) + .MakeGenericMethod(rowType); } public Type RowType { get; } public string FileName { get; } - public Func> LoadTextAsync { get; } + public IConfigSource Source { get; } + public IConfigParser Parser { get; } - internal object Parse(string json) => _parse(json); - - private static Func CreateParser(Type rowType) + internal object Parse(string json) { - var method = typeof(ConfigMapping) - .GetMethod(nameof(ParseRows), System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic) - .MakeGenericMethod(rowType); - - return json => method.Invoke(null, new object[] { json }); - } - - private static object ParseRows(string json) where T : class, IConfigRow - { - var rows = ReactivePropertyJsonConverter.DeserializeArray(json); - var byId = new Dictionary(); - foreach (var row in rows) + var rows = (System.Collections.IEnumerable)_parseRowsMethod.Invoke( + Parser, + new object[] { FileName, json }); + var byIdType = typeof(Dictionary<,>).MakeGenericType(typeof(int), RowType); + var byId = (System.Collections.IDictionary)Activator.CreateInstance(byIdType); + foreach (IConfigRow row in rows) { - if (byId.ContainsKey(row.Id)) + if (byId.Contains(row.Id)) { - throw new InvalidOperationException($"Duplicate config id {row.Id} for {typeof(T).Name}."); + throw new InvalidOperationException($"Duplicate config id {row.Id} for {RowType.Name}."); } byId.Add(row.Id, row); @@ -128,5 +142,25 @@ namespace FlowScope.Config return byId; } } + + private sealed class DelegateConfigSource : IConfigSource + { + private readonly Func> _loadTextAsync; + + public DelegateConfigSource( + string fileName, + Func> loadTextAsync) + { + Name = fileName; + _loadTextAsync = loadTextAsync ?? throw new ArgumentNullException(nameof(loadTextAsync)); + } + + public string Name { get; } + + public Task LoadTextAsync(string fileName, CancellationToken cancellationToken) + { + return _loadTextAsync(cancellationToken); + } + } } } diff --git a/My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs b/My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs index 5c44877..ff99832 100644 --- a/My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs +++ b/My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs @@ -12,6 +12,22 @@ namespace FlowScope.Save public sealed class JsonSaveSerializer : ISaveSerializer { + private readonly int _currentVersion; + private readonly SaveMigrationRegistry _migrations; + + public JsonSaveSerializer( + int currentVersion = 1, + SaveMigrationRegistry migrations = null) + { + if (currentVersion < 1) + { + throw new ArgumentOutOfRangeException(nameof(currentVersion)); + } + + _currentVersion = currentVersion; + _migrations = migrations ?? new SaveMigrationRegistry(); + } + public byte[] Serialize(T data) { return Encoding.UTF8.GetBytes(SerializeToString(data)); @@ -29,19 +45,75 @@ namespace FlowScope.Save public string SerializeToString(T data) { - return ReactivePropertyJsonConverter.SerializeObject(data); + var payload = ReactivePropertyJsonConverter.SerializeObject(data); + if (_currentVersion <= 1) + { + return payload; + } + + return ReactivePropertyJsonConverter.SerializeObject(new SaveEnvelope + { + Version = _currentVersion, + Payload = payload + }); } public T DeserializeFromString(string json) { var instance = (T)Activator.CreateInstance(typeof(T), true); - PopulateFromString(json, instance); + PopulateFromString(ResolvePayload(json), instance); return instance; } public void PopulateFromString(string json, T target) { - ReactivePropertyJsonConverter.PopulateObject(json, target); + ReactivePropertyJsonConverter.PopulateObject(ResolvePayload(json), target); + } + + private string ResolvePayload(string json) + { + if (!TryReadEnvelope(json, out var envelope)) + { + return _currentVersion > 1 + ? _migrations.Migrate(GetMigrationKey(), 1, _currentVersion, json) + : json; + } + + if (envelope.Version == _currentVersion) + { + return envelope.Payload; + } + + if (envelope.Version > _currentVersion) + { + throw new InvalidOperationException( + $"Save version {envelope.Version} is newer than current version {_currentVersion}."); + } + + return _migrations.Migrate( + GetMigrationKey(), + envelope.Version, + _currentVersion, + envelope.Payload); + } + + private static bool TryReadEnvelope(string json, out SaveEnvelope envelope) + { + envelope = null; + try + { + envelope = ReactivePropertyJsonConverter.DeserializeObject(json); + return envelope.Version > 0 && envelope.Payload != null; + } + catch + { + return false; + } + } + + private static string GetMigrationKey() + { + return typeof(T).FullName; } } } diff --git a/My project/Assets/FlowScope/Runtime/Save/SaveEnvelope.cs b/My project/Assets/FlowScope/Runtime/Save/SaveEnvelope.cs new file mode 100644 index 0000000..f2013f0 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/SaveEnvelope.cs @@ -0,0 +1,8 @@ +namespace FlowScope.Save +{ + internal sealed class SaveEnvelope + { + public int Version { get; set; } + public string Payload { get; set; } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Save/SaveEnvelope.cs.meta b/My project/Assets/FlowScope/Runtime/Save/SaveEnvelope.cs.meta new file mode 100644 index 0000000..11acd8a --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/SaveEnvelope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67ada4b73d47420db150f34ab12a236a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Save/SaveMigrationRegistry.cs b/My project/Assets/FlowScope/Runtime/Save/SaveMigrationRegistry.cs new file mode 100644 index 0000000..4ee5fa6 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/SaveMigrationRegistry.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +namespace FlowScope.Save +{ + public sealed class SaveMigrationRegistry + { + private readonly Dictionary _migrations = new(); + + public void Register(ISaveMigration migration) + { + if (migration == null) + { + throw new ArgumentNullException(nameof(migration)); + } + + _migrations[new MigrationKey(migration.Key, migration.FromVersion)] = migration; + } + + public string Migrate( + string key, + int fromVersion, + int currentVersion, + string json) + { + var current = fromVersion; + var payload = json; + while (current < currentVersion) + { + if (!_migrations.TryGetValue(new MigrationKey(key, current), out var migration) || + migration.ToVersion != current + 1) + { + throw new InvalidOperationException( + $"Missing save migration for key '{key}' from version {current}."); + } + + payload = migration.Migrate(payload); + current = migration.ToVersion; + } + + return payload; + } + + private readonly struct MigrationKey : IEquatable + { + private readonly string _key; + private readonly int _fromVersion; + + public MigrationKey(string key, int fromVersion) + { + _key = key ?? string.Empty; + _fromVersion = fromVersion; + } + + public bool Equals(MigrationKey other) + { + return _key == other._key && _fromVersion == other._fromVersion; + } + + public override bool Equals(object obj) + { + return obj is MigrationKey other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + return ((_key != null ? _key.GetHashCode() : 0) * 397) ^ _fromVersion; + } + } + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Save/SaveMigrationRegistry.cs.meta b/My project/Assets/FlowScope/Runtime/Save/SaveMigrationRegistry.cs.meta new file mode 100644 index 0000000..e74593f --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/SaveMigrationRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3f2c3dd694c94ed59d08bcdf88d830a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Config/ConfigExtensionTests.cs b/My project/Assets/FlowScope/Tests/EditMode/Config/ConfigExtensionTests.cs new file mode 100644 index 0000000..37b25d5 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Config/ConfigExtensionTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Config; +using NUnit.Framework; + +namespace FlowScope.Tests.EditMode.Config +{ + public sealed class ConfigExtensionTests + { + [Test] + public void LoadAllAsync_UsesConfiguredSourceAndParser() + { + var source = new InMemoryConfigSource( + new Dictionary + { + ["player.json"] = "[{\"id\":7,\"name\":\"Starter\"}]" + }); + var provider = new JsonConfigProvider(new[] + { + JsonConfigProvider.Mapping("player.json", source, new JsonConfigParser()) + }); + + provider.LoadAllAsync(CancellationToken.None).GetAwaiter().GetResult(); + + Assert.That(provider.Get(7).Name, Is.EqualTo("Starter")); + } + + [Test] + public void LoadAllAsync_WhenSourceFails_IncludesSourceAndFileName() + { + var source = new InMemoryConfigSource(new Dictionary(), "MemoryA"); + var provider = new JsonConfigProvider(new[] + { + JsonConfigProvider.Mapping("missing.json", source, new JsonConfigParser()) + }); + + var exception = ThrowsAsync( + () => provider.LoadAllAsync(CancellationToken.None)); + + Assert.That(exception.Message, Does.Contain("MemoryA")); + Assert.That(exception.Message, Does.Contain("missing.json")); + } + + [Test] + public void LoadAllAsync_WhenParserFindsDuplicateId_IncludesFileName() + { + var source = new InMemoryConfigSource( + new Dictionary + { + ["player.json"] = "[{\"id\":7,\"name\":\"A\"},{\"id\":7,\"name\":\"B\"}]" + }); + var provider = new JsonConfigProvider(new[] + { + JsonConfigProvider.Mapping("player.json", source, new JsonConfigParser()) + }); + + var exception = ThrowsAsync( + () => provider.LoadAllAsync(CancellationToken.None)); + + Assert.That(exception.Message, Does.Contain("player.json")); + Assert.That(exception.Message, Does.Contain("7")); + } + + private static TException ThrowsAsync(Func action) + where TException : Exception + { + try + { + action().GetAwaiter().GetResult(); + } + catch (TException exception) + { + return exception; + } + + Assert.Fail($"Expected {typeof(TException).Name}."); + return null; + } + + private sealed class PlayerConfig : IConfigRow + { + public int id; + public string name; + + public int Id => id; + public string Name => name; + } + } +} diff --git a/My project/Assets/FlowScope/Tests/EditMode/Config/ConfigExtensionTests.cs.meta b/My project/Assets/FlowScope/Tests/EditMode/Config/ConfigExtensionTests.cs.meta new file mode 100644 index 0000000..06f2a40 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Config/ConfigExtensionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58935522df66446e93252f079376b603 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Save/SaveMigrationTests.cs b/My project/Assets/FlowScope/Tests/EditMode/Save/SaveMigrationTests.cs new file mode 100644 index 0000000..5d153cc --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Save/SaveMigrationTests.cs @@ -0,0 +1,69 @@ +using System; +using FlowScope.Save; +using NUnit.Framework; +using R3; + +namespace FlowScope.Tests.EditMode.Save +{ + public sealed class SaveMigrationTests + { + [Test] + public void Deserialize_WhenEnvelopeVersionIsOld_AppliesMigrationChain() + { + var registry = new SaveMigrationRegistry(); + registry.Register(new RenameCoinsToGoldMigration()); + var serializer = new JsonSaveSerializer(currentVersion: 2, migrations: registry); + var oldPayload = "{\"Coins\":13}"; + var envelope = "{\"Version\":1,\"Payload\":\"{\\\"Coins\\\":13}\"}"; + + var loaded = serializer.DeserializeFromString(envelope); + + Assert.That(oldPayload, Does.Contain("Coins")); + Assert.That(loaded.Gold.Value, Is.EqualTo(13)); + } + + [Test] + public void Deserialize_WhenMigrationIsMissing_ThrowsWithKeyAndVersion() + { + var serializer = new JsonSaveSerializer(currentVersion: 2, migrations: new SaveMigrationRegistry()); + var envelope = "{\"Version\":1,\"Payload\":\"{\\\"Coins\\\":13}\"}"; + + var exception = Assert.Throws( + () => serializer.DeserializeFromString(envelope)); + + Assert.That(exception.Message, Does.Contain(typeof(PlayerSaveV2).FullName)); + Assert.That(exception.Message, Does.Contain("1")); + } + + [Test] + public void Serialize_WritesEnvelopeWithCurrentVersion() + { + var serializer = new JsonSaveSerializer(currentVersion: 2); + var data = new PlayerSaveV2(); + data.Gold.Value = 21; + + var json = serializer.SerializeToString(data); + + Assert.That(json, Does.Contain("\"Version\":2")); + Assert.That(json, Does.Contain("\"Payload\"")); + Assert.That(json, Does.Contain("Gold")); + } + + private sealed class RenameCoinsToGoldMigration : ISaveMigration + { + public string Key => typeof(PlayerSaveV2).FullName; + public int FromVersion => 1; + public int ToVersion => 2; + + public string Migrate(string json) + { + return json.Replace("Coins", "Gold"); + } + } + + private sealed class PlayerSaveV2 + { + public ReactiveProperty Gold { get; set; } = new(); + } + } +} diff --git a/My project/Assets/FlowScope/Tests/EditMode/Save/SaveMigrationTests.cs.meta b/My project/Assets/FlowScope/Tests/EditMode/Save/SaveMigrationTests.cs.meta new file mode 100644 index 0000000..8443cd1 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Save/SaveMigrationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e9fca0294774fed9d4532f3f1e5c58b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: