diff --git a/My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs b/My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs new file mode 100644 index 0000000..17ac4de --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Data; + +namespace FlowScope.Config +{ + public sealed class JsonConfigProvider : IConfigProvider + { + private readonly IReadOnlyList _mappings; + private readonly Dictionary _rowsByType = new(); + + public JsonConfigProvider(IReadOnlyList mappings) + { + _mappings = mappings ?? throw new ArgumentNullException(nameof(mappings)); + } + + public static ConfigMapping Mapping(string fileName, Func> loadTextAsync) + where T : class, IConfigRow + { + return new ConfigMapping(typeof(T), fileName, loadTextAsync); + } + + public async Task LoadAllAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var loadedRows = new Dictionary(); + + foreach (var mapping in _mappings) + { + cancellationToken.ThrowIfCancellationRequested(); + string json; + try + { + json = await mapping.LoadTextAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + throw new InvalidOperationException($"Failed to load config file '{mapping.FileName}'.", exception); + } + + try + { + loadedRows[mapping.RowType] = mapping.Parse(json); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + throw new InvalidOperationException( + $"Failed to parse config file '{mapping.FileName}': {exception.GetBaseException().Message}", + exception); + } + } + + _rowsByType.Clear(); + foreach (var pair in loadedRows) + { + _rowsByType[pair.Key] = pair.Value; + } + } + + public T Get(int id) where T : class, IConfigRow + { + return _rowsByType.TryGetValue(typeof(T), out var table) && + table is Dictionary rows && + rows.TryGetValue(id, out var row) + ? row + : null; + } + + public IReadOnlyList GetAll() where T : class, IConfigRow + { + if (!_rowsByType.TryGetValue(typeof(T), out var table) || table is not Dictionary rows) + { + return Array.Empty(); + } + + return new List(rows.Values); + } + + public sealed class ConfigMapping + { + private readonly Func _parse; + + internal ConfigMapping(Type rowType, string fileName, Func> loadTextAsync) + { + 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); + } + + public Type RowType { get; } + public string FileName { get; } + public Func> LoadTextAsync { get; } + + internal object Parse(string json) => _parse(json); + + private static Func CreateParser(Type rowType) + { + 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) + { + if (byId.ContainsKey(row.Id)) + { + throw new InvalidOperationException($"Duplicate config id {row.Id} for {typeof(T).Name}."); + } + + byId.Add(row.Id, row); + } + + return byId; + } + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs.meta b/My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs.meta new file mode 100644 index 0000000..08df55f --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b6c05d7a1d74798901d3efcc51cb7c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Data.meta b/My project/Assets/FlowScope/Runtime/Data.meta new file mode 100644 index 0000000..c9cbd31 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Data.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 67eb0ac45b8b4cf8bafcb604dd6e0e11 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Data/ReactivePropertyJsonConverter.cs b/My project/Assets/FlowScope/Runtime/Data/ReactivePropertyJsonConverter.cs new file mode 100644 index 0000000..6dd07d5 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Data/ReactivePropertyJsonConverter.cs @@ -0,0 +1,699 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Text; + +namespace FlowScope.Data +{ + public static class ReactivePropertyJsonConverter + { + public static string SerializeObject(object value) + { + return SimpleJson.Write(ToJsonValue(value)); + } + + public static T DeserializeObject(string json) + { + var instance = (T)Activator.CreateInstance(typeof(T), true); + PopulateObject(json, instance); + return instance; + } + + public static void PopulateObject(string json, object target) + { + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (SimpleJson.Parse(json) is not Dictionary values) + { + throw new InvalidOperationException($"JSON root must be an object for {target.GetType().Name}."); + } + + PopulateFromDictionary(values, target); + } + + public static IReadOnlyList DeserializeArray(string json) where T : class + { + if (SimpleJson.Parse(json) is not List rows) + { + throw new InvalidOperationException("JSON root must be an array."); + } + + var results = new List(rows.Count); + foreach (var row in rows) + { + if (row is not Dictionary rowValues) + { + throw new InvalidOperationException($"JSON row must be an object for {typeof(T).Name}."); + } + + var instance = (T)Activator.CreateInstance(typeof(T), true); + PopulateFromDictionary(rowValues, instance); + results.Add(instance); + } + + return results; + } + + internal static object ToJsonValue(object value) + { + if (value == null) + { + return null; + } + + var type = value.GetType(); + if (IsSimple(type)) + { + return value; + } + + if (TryGetReactivePropertyValue(value, out var reactiveValue)) + { + return ToJsonValue(reactiveValue); + } + + if (IsReactiveCollection(type) || value is IEnumerable && value is not string) + { + var array = new List(); + foreach (var item in (IEnumerable)value) + { + array.Add(ToJsonValue(item)); + } + + return array; + } + + var result = new Dictionary(); + foreach (var property in GetSerializableProperties(type)) + { + result[property.Name] = ToJsonValue(property.GetValue(value)); + } + + foreach (var field in GetSerializableFields(type)) + { + result[field.Name] = ToJsonValue(field.GetValue(value)); + } + + return result; + } + + internal static object ConvertValue(object value, Type targetType, string fieldName) + { + if (value == null) + { + if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null) + { + throw new InvalidOperationException($"Field '{fieldName}' cannot be null."); + } + + return null; + } + + var nullableType = Nullable.GetUnderlyingType(targetType); + if (nullableType != null) + { + return ConvertValue(value, nullableType, fieldName); + } + + if (targetType == typeof(string)) + { + if (value is string text) + { + return text; + } + + throw TypeMismatch(fieldName, targetType, value); + } + + if (targetType == typeof(bool)) + { + if (value is bool boolValue) + { + return boolValue; + } + + throw TypeMismatch(fieldName, targetType, value); + } + + if (targetType.IsEnum) + { + if (value is string enumName) + { + return Enum.Parse(targetType, enumName); + } + + return Enum.ToObject(targetType, Convert.ToInt32(value, CultureInfo.InvariantCulture)); + } + + if (IsNumeric(targetType)) + { + if (value is string || value is bool) + { + throw TypeMismatch(fieldName, targetType, value); + } + + return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture); + } + + if (value is Dictionary nested) + { + var instance = Activator.CreateInstance(targetType, true); + PopulateFromDictionary(nested, instance); + return instance; + } + + throw TypeMismatch(fieldName, targetType, value); + } + + private static void PopulateFromDictionary(IReadOnlyDictionary values, object target) + { + var members = GetWritableMembers(target.GetType()); + foreach (var pair in values) + { + if (!members.TryGetValue(pair.Key, out var member)) + { + continue; + } + + PopulateMember(target, member, pair.Value, pair.Key); + } + } + + private static void PopulateMember(object target, MemberInfo member, object value, string fieldName) + { + var memberType = GetMemberType(member); + var currentValue = GetMemberValue(member, target); + + if (IsReactiveProperty(memberType)) + { + var valueProperty = memberType.GetProperty("Value", BindingFlags.Instance | BindingFlags.Public); + var converted = ConvertValue(value, valueProperty.PropertyType, fieldName); + valueProperty.SetValue(currentValue, converted); + return; + } + + if (IsReactiveCollection(memberType)) + { + PopulateCollection(currentValue, memberType, value, fieldName); + return; + } + + if (CanSet(member)) + { + SetMemberValue(member, target, ConvertValue(value, memberType, fieldName)); + } + } + + private static void PopulateCollection(object collection, Type collectionType, object value, string fieldName) + { + if (value is not List rows) + { + throw TypeMismatch(fieldName, collectionType, value); + } + + var itemType = collectionType.GetGenericArguments()[0]; + collectionType.GetMethod("Clear", Type.EmptyTypes)?.Invoke(collection, Array.Empty()); + var addMethod = collectionType.GetMethod("Add", new[] { itemType }); + if (addMethod == null) + { + throw new InvalidOperationException($"Field '{fieldName}' collection does not expose Add."); + } + + foreach (var row in rows) + { + addMethod.Invoke(collection, new[] { ConvertValue(row, itemType, fieldName) }); + } + } + + private static Dictionary GetWritableMembers(Type type) + { + var members = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (property.GetIndexParameters().Length == 0) + { + members[property.Name] = property; + } + } + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public)) + { + members[field.Name] = field; + } + + return members; + } + + private static IEnumerable GetSerializableProperties(Type type) + { + foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (property.GetIndexParameters().Length == 0 && property.CanRead) + { + yield return property; + } + } + } + + private static IEnumerable GetSerializableFields(Type type) + { + return type.GetFields(BindingFlags.Instance | BindingFlags.Public); + } + + private static Type GetMemberType(MemberInfo member) + { + return member switch + { + PropertyInfo property => property.PropertyType, + FieldInfo field => field.FieldType, + _ => throw new NotSupportedException(member.MemberType.ToString()) + }; + } + + private static object GetMemberValue(MemberInfo member, object target) + { + return member switch + { + PropertyInfo property => property.GetValue(target), + FieldInfo field => field.GetValue(target), + _ => null + }; + } + + private static void SetMemberValue(MemberInfo member, object target, object value) + { + switch (member) + { + case PropertyInfo property: + property.SetValue(target, value); + break; + case FieldInfo field: + field.SetValue(target, value); + break; + } + } + + private static bool CanSet(MemberInfo member) + { + return member switch + { + PropertyInfo property => property.CanWrite, + FieldInfo field => !field.IsInitOnly, + _ => false + }; + } + + private static bool TryGetReactivePropertyValue(object value, out object reactiveValue) + { + reactiveValue = null; + if (!IsReactiveProperty(value.GetType())) + { + return false; + } + + reactiveValue = value.GetType().GetProperty("Value", BindingFlags.Instance | BindingFlags.Public)?.GetValue(value); + return true; + } + + private static bool IsReactiveProperty(Type type) + { + return type.IsGenericType && type.GetGenericTypeDefinition().FullName == "R3.ReactiveProperty`1"; + } + + private static bool IsReactiveCollection(Type type) + { + return type.IsGenericType && type.GetGenericTypeDefinition().FullName == "R3.ReactiveCollection`1"; + } + + private static bool IsSimple(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal); + } + + private static bool IsNumeric(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type == typeof(byte) || type == typeof(sbyte) || + type == typeof(short) || type == typeof(ushort) || + type == typeof(int) || type == typeof(uint) || + type == typeof(long) || type == typeof(ulong) || + type == typeof(float) || type == typeof(double) || + type == typeof(decimal); + } + + private static InvalidOperationException TypeMismatch(string fieldName, Type targetType, object value) + { + return new InvalidOperationException( + $"Field '{fieldName}' expected {targetType.Name}, but JSON value was {value.GetType().Name}."); + } + + internal static class SimpleJson + { + public static object Parse(string json) + { + var parser = new Parser(json); + var value = parser.ParseValue(); + parser.SkipWhitespace(); + if (!parser.IsEnd) + { + throw new FormatException("Unexpected trailing JSON content."); + } + + return value; + } + + public static string Write(object value) + { + var builder = new StringBuilder(); + WriteValue(builder, value); + return builder.ToString(); + } + + private static void WriteValue(StringBuilder builder, object value) + { + switch (value) + { + case null: + builder.Append("null"); + break; + case string text: + WriteString(builder, text); + break; + case bool boolValue: + builder.Append(boolValue ? "true" : "false"); + break; + case IDictionary map: + WriteObject(builder, map); + break; + case IEnumerable array: + WriteArray(builder, array); + break; + case float or double or decimal: + builder.Append(Convert.ToString(value, CultureInfo.InvariantCulture)); + break; + default: + if (value.GetType().IsEnum) + { + WriteString(builder, value.ToString()); + } + else + { + builder.Append(Convert.ToString(value, CultureInfo.InvariantCulture)); + } + + break; + } + } + + private static void WriteObject(StringBuilder builder, IDictionary map) + { + builder.Append('{'); + var first = true; + foreach (var pair in map) + { + if (!first) + { + builder.Append(','); + } + + first = false; + WriteString(builder, pair.Key); + builder.Append(':'); + WriteValue(builder, pair.Value); + } + + builder.Append('}'); + } + + private static void WriteArray(StringBuilder builder, IEnumerable values) + { + builder.Append('['); + var first = true; + foreach (var value in values) + { + if (!first) + { + builder.Append(','); + } + + first = false; + WriteValue(builder, value); + } + + builder.Append(']'); + } + + private static void WriteString(StringBuilder builder, string value) + { + builder.Append('"'); + foreach (var character in value) + { + switch (character) + { + case '"': + builder.Append("\\\""); + break; + case '\\': + builder.Append("\\\\"); + break; + case '\n': + builder.Append("\\n"); + break; + case '\r': + builder.Append("\\r"); + break; + case '\t': + builder.Append("\\t"); + break; + default: + builder.Append(character); + break; + } + } + + builder.Append('"'); + } + + private sealed class Parser + { + private readonly string _json; + private int _index; + + public Parser(string json) + { + _json = json ?? throw new ArgumentNullException(nameof(json)); + } + + public bool IsEnd => _index >= _json.Length; + + public object ParseValue() + { + SkipWhitespace(); + if (IsEnd) + { + throw new FormatException("Unexpected end of JSON."); + } + + return _json[_index] switch + { + '{' => ParseObject(), + '[' => ParseArray(), + '"' => ParseString(), + 't' => ParseLiteral("true", true), + 'f' => ParseLiteral("false", false), + 'n' => ParseLiteral("null", null), + _ => ParseNumber() + }; + } + + public void SkipWhitespace() + { + while (!IsEnd && char.IsWhiteSpace(_json[_index])) + { + _index++; + } + } + + private Dictionary ParseObject() + { + Expect('{'); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + SkipWhitespace(); + if (TryConsume('}')) + { + return map; + } + + while (true) + { + SkipWhitespace(); + var key = ParseString(); + SkipWhitespace(); + Expect(':'); + map[key] = ParseValue(); + SkipWhitespace(); + if (TryConsume('}')) + { + return map; + } + + Expect(','); + } + } + + private List ParseArray() + { + Expect('['); + var array = new List(); + SkipWhitespace(); + if (TryConsume(']')) + { + return array; + } + + while (true) + { + array.Add(ParseValue()); + SkipWhitespace(); + if (TryConsume(']')) + { + return array; + } + + Expect(','); + } + } + + private string ParseString() + { + Expect('"'); + var builder = new StringBuilder(); + while (!IsEnd) + { + var character = _json[_index++]; + if (character == '"') + { + return builder.ToString(); + } + + if (character != '\\') + { + builder.Append(character); + continue; + } + + if (IsEnd) + { + throw new FormatException("Unexpected end of JSON escape sequence."); + } + + var escaped = _json[_index++]; + builder.Append(escaped switch + { + '"' => '"', + '\\' => '\\', + '/' => '/', + 'b' => '\b', + 'f' => '\f', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + _ => throw new FormatException($"Unsupported JSON escape '\\{escaped}'.") + }); + } + + throw new FormatException("Unterminated JSON string."); + } + + private object ParseNumber() + { + var start = _index; + if (!IsEnd && _json[_index] == '-') + { + _index++; + } + + while (!IsEnd && char.IsDigit(_json[_index])) + { + _index++; + } + + var isFloatingPoint = false; + if (!IsEnd && _json[_index] == '.') + { + isFloatingPoint = true; + _index++; + while (!IsEnd && char.IsDigit(_json[_index])) + { + _index++; + } + } + + if (!IsEnd && (_json[_index] == 'e' || _json[_index] == 'E')) + { + isFloatingPoint = true; + _index++; + if (!IsEnd && (_json[_index] == '+' || _json[_index] == '-')) + { + _index++; + } + + while (!IsEnd && char.IsDigit(_json[_index])) + { + _index++; + } + } + + var text = _json.Substring(start, _index - start); + if (string.IsNullOrEmpty(text) || text == "-") + { + throw new FormatException("Invalid JSON number."); + } + + return isFloatingPoint + ? double.Parse(text, CultureInfo.InvariantCulture) + : long.Parse(text, CultureInfo.InvariantCulture); + } + + private object ParseLiteral(string literal, object value) + { + if (_index + literal.Length > _json.Length || + string.CompareOrdinal(_json, _index, literal, 0, literal.Length) != 0) + { + throw new FormatException($"Invalid JSON literal near index {_index}."); + } + + _index += literal.Length; + return value; + } + + private bool TryConsume(char expected) + { + if (!IsEnd && _json[_index] == expected) + { + _index++; + return true; + } + + return false; + } + + private void Expect(char expected) + { + if (IsEnd || _json[_index] != expected) + { + throw new FormatException($"Expected '{expected}' near index {_index}."); + } + + _index++; + } + } + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Data/ReactivePropertyJsonConverter.cs.meta b/My project/Assets/FlowScope/Runtime/Data/ReactivePropertyJsonConverter.cs.meta new file mode 100644 index 0000000..c89b997 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Data/ReactivePropertyJsonConverter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0c9f18f342e54f8d9a4dbdc61b74a2d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Save/FileSaveStorage.cs b/My project/Assets/FlowScope/Runtime/Save/FileSaveStorage.cs new file mode 100644 index 0000000..92be2e5 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/FileSaveStorage.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace FlowScope.Save +{ + public interface ISaveStorage + { + Task WriteAsync(string key, byte[] data, CancellationToken cancellationToken); + Task ReadAsync(string key, CancellationToken cancellationToken); + void Delete(string key); + bool Exists(string key); + } + + public sealed class FileSaveStorage : ISaveStorage + { + private readonly string _rootDirectory; + + public FileSaveStorage(string rootDirectory = null) + { + _rootDirectory = string.IsNullOrWhiteSpace(rootDirectory) + ? Path.Combine(Application.persistentDataPath, "FlowScopeSaves") + : rootDirectory; + } + + public async Task WriteAsync(string key, byte[] data, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (data == null) + { + throw new ArgumentNullException(nameof(data)); + } + + Directory.CreateDirectory(_rootDirectory); + var path = GetPath(key); + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true); + await stream.WriteAsync(data, 0, data.Length, cancellationToken); + } + + public async Task ReadAsync(string key, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = GetPath(key); + if (!File.Exists(path)) + { + return null; + } + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true); + var data = new byte[stream.Length]; + var read = 0; + while (read < data.Length) + { + var count = await stream.ReadAsync(data, read, data.Length - read, cancellationToken); + if (count == 0) + { + break; + } + + read += count; + } + + return data; + } + + public void Delete(string key) + { + var path = GetPath(key); + if (File.Exists(path)) + { + File.Delete(path); + } + } + + public bool Exists(string key) + { + return File.Exists(GetPath(key)); + } + + private string GetPath(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("Save key is required.", nameof(key)); + } + + return Path.Combine(_rootDirectory, Convert.ToBase64String(Encoding.UTF8.GetBytes(key)) + ".json"); + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Save/FileSaveStorage.cs.meta b/My project/Assets/FlowScope/Runtime/Save/FileSaveStorage.cs.meta new file mode 100644 index 0000000..059eef1 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/FileSaveStorage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 59beaa2c506548ea9f526f3784f5f938 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs b/My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs new file mode 100644 index 0000000..5c44877 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs @@ -0,0 +1,47 @@ +using System; +using System.Text; +using FlowScope.Data; + +namespace FlowScope.Save +{ + public interface ISaveSerializer + { + byte[] Serialize(T data); + T Deserialize(byte[] bytes); + } + + public sealed class JsonSaveSerializer : ISaveSerializer + { + public byte[] Serialize(T data) + { + return Encoding.UTF8.GetBytes(SerializeToString(data)); + } + + public T Deserialize(byte[] bytes) + { + if (bytes == null) + { + throw new ArgumentNullException(nameof(bytes)); + } + + return DeserializeFromString(Encoding.UTF8.GetString(bytes)); + } + + public string SerializeToString(T data) + { + return ReactivePropertyJsonConverter.SerializeObject(data); + } + + public T DeserializeFromString(string json) + { + var instance = (T)Activator.CreateInstance(typeof(T), true); + PopulateFromString(json, instance); + return instance; + } + + public void PopulateFromString(string json, T target) + { + ReactivePropertyJsonConverter.PopulateObject(json, target); + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs.meta b/My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs.meta new file mode 100644 index 0000000..8edd90b --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6823b62f8e7044d0a8f7e85fc33ca10b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Save/PlayerPrefsSaveStorage.cs b/My project/Assets/FlowScope/Runtime/Save/PlayerPrefsSaveStorage.cs new file mode 100644 index 0000000..2598348 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/PlayerPrefsSaveStorage.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace FlowScope.Save +{ + public sealed class PlayerPrefsSaveStorage : ISaveStorage + { + private const string Prefix = "FlowScope.Save."; + + public Task WriteAsync(string key, byte[] data, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (data == null) + { + throw new ArgumentNullException(nameof(data)); + } + + PlayerPrefs.SetString(ToPrefsKey(key), Convert.ToBase64String(data)); + PlayerPrefs.Save(); + return Task.CompletedTask; + } + + public Task ReadAsync(string key, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var prefsKey = ToPrefsKey(key); + return Task.FromResult(PlayerPrefs.HasKey(prefsKey) + ? Convert.FromBase64String(PlayerPrefs.GetString(prefsKey)) + : null); + } + + public void Delete(string key) + { + PlayerPrefs.DeleteKey(ToPrefsKey(key)); + PlayerPrefs.Save(); + } + + public bool Exists(string key) + { + return PlayerPrefs.HasKey(ToPrefsKey(key)); + } + + private static string ToPrefsKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("Save key is required.", nameof(key)); + } + + return Prefix + key; + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Save/PlayerPrefsSaveStorage.cs.meta b/My project/Assets/FlowScope/Runtime/Save/PlayerPrefsSaveStorage.cs.meta new file mode 100644 index 0000000..4a382ad --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/PlayerPrefsSaveStorage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d421a058fe6d4927ad7b3f48b3060529 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Save/SaveService.cs b/My project/Assets/FlowScope/Runtime/Save/SaveService.cs new file mode 100644 index 0000000..7eb65fb --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/SaveService.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace FlowScope.Save +{ + public sealed class SaveService : ISaveService + { + private readonly ISaveStorage _storage; + private readonly ISaveSerializer _serializer; + + public SaveService(ISaveStorage storage, ISaveSerializer serializer) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + } + + public async Task SaveAsync(string key, T data, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var bytes = _serializer.Serialize(data); + await _storage.WriteAsync(key, bytes, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + throw new InvalidOperationException($"Failed to write save key '{key}'.", exception); + } + } + + public async Task LoadAsync(string key, T defaultValue, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + byte[] bytes; + try + { + bytes = await _storage.ReadAsync(key, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + Debug.LogWarning($"Failed to read save key '{key}': {exception.Message}"); + return defaultValue; + } + + if (bytes == null) + { + return defaultValue; + } + + try + { + return _serializer.Deserialize(bytes); + } + catch (Exception exception) + { + Debug.LogWarning($"Failed to deserialize save key '{key}': {exception.Message}"); + return defaultValue; + } + } + + public void Delete(string key) + { + _storage.Delete(key); + } + + public bool Exists(string key) + { + return _storage.Exists(key); + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Save/SaveService.cs.meta b/My project/Assets/FlowScope/Runtime/Save/SaveService.cs.meta new file mode 100644 index 0000000..7051a49 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Save/SaveService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c71e93285f6c4f8287c96785fe1975d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests.meta b/My project/Assets/FlowScope/Tests.meta new file mode 100644 index 0000000..36d3de2 --- /dev/null +++ b/My project/Assets/FlowScope/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f491ea648ef64647a176f6e6900f47cc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode.meta b/My project/Assets/FlowScope/Tests/EditMode.meta new file mode 100644 index 0000000..d5af623 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6b7d567b67904be49f027c1d0bc935f0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Config.meta b/My project/Assets/FlowScope/Tests/EditMode/Config.meta new file mode 100644 index 0000000..52fb221 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Config.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c2c7341087f04116b19ce4c0b982ae51 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Config/JsonConfigProviderTests.cs b/My project/Assets/FlowScope/Tests/EditMode/Config/JsonConfigProviderTests.cs new file mode 100644 index 0000000..58e4b9b --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Config/JsonConfigProviderTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Config; +using NUnit.Framework; + +namespace FlowScope.Tests.EditMode.Config +{ + public sealed class JsonConfigProviderTests + { + [Test] + public async Task LoadAllAsync_LoadsRegisteredJsonRows() + { + var provider = new JsonConfigProvider(new[] + { + JsonConfigProvider.Mapping("weapons.json", _ => Task.FromResult("[{\"id\":101,\"name\":\"Axe\"}]")) + }); + + await provider.LoadAllAsync(CancellationToken.None); + + Assert.That(provider.Get(101).Name, Is.EqualTo("Axe")); + } + + [Test] + public async Task Get_MissingId_ReturnsNull() + { + var provider = new JsonConfigProvider(new[] + { + JsonConfigProvider.Mapping("weapons.json", _ => Task.FromResult("[{\"id\":101,\"name\":\"Axe\"}]")) + }); + + await provider.LoadAllAsync(CancellationToken.None); + + Assert.That(provider.Get(999), Is.Null); + } + + [Test] + public async Task GetAll_UnloadedType_ReturnsEmptyList() + { + var provider = new JsonConfigProvider(Array.Empty()); + + await provider.LoadAllAsync(CancellationToken.None); + + Assert.That(provider.GetAll(), Is.Empty); + } + + [Test] + public void LoadAllAsync_DuplicateId_ThrowsWithId() + { + var provider = new JsonConfigProvider(new[] + { + JsonConfigProvider.Mapping("weapons.json", _ => Task.FromResult("[{\"id\":101,\"name\":\"Axe\"},{\"id\":101,\"name\":\"Sword\"}]")) + }); + + var exception = Assert.ThrowsAsync( + async () => await provider.LoadAllAsync(CancellationToken.None)); + + Assert.That(exception.Message, Does.Contain("101")); + } + + [Test] + public void LoadAllAsync_MalformedJson_ThrowsWithFileName() + { + var provider = new JsonConfigProvider(new[] + { + JsonConfigProvider.Mapping("weapons.json", _ => Task.FromResult("[{\"id\":101")) + }); + + var exception = Assert.ThrowsAsync( + async () => await provider.LoadAllAsync(CancellationToken.None)); + + Assert.That(exception.Message, Does.Contain("weapons.json")); + } + + [Test] + public void LoadAllAsync_CanceledToken_ThrowsOperationCanceledException() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var provider = new JsonConfigProvider(new[] + { + JsonConfigProvider.Mapping("weapons.json", _ => Task.FromResult("[]")) + }); + + Assert.ThrowsAsync( + async () => await provider.LoadAllAsync(cts.Token)); + } + + private sealed class WeaponConfig : 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/JsonConfigProviderTests.cs.meta b/My project/Assets/FlowScope/Tests/EditMode/Config/JsonConfigProviderTests.cs.meta new file mode 100644 index 0000000..17091fb --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Config/JsonConfigProviderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 08b0393564b64127a76abf9480fcf28b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Data.meta b/My project/Assets/FlowScope/Tests/EditMode/Data.meta new file mode 100644 index 0000000..04c2c41 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Data.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3b65c31816084c438fa4c0182033c9fc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Data/DataSerializationTests.cs b/My project/Assets/FlowScope/Tests/EditMode/Data/DataSerializationTests.cs new file mode 100644 index 0000000..49fa20d --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Data/DataSerializationTests.cs @@ -0,0 +1,85 @@ +using System; +using FlowScope.Save; +using NUnit.Framework; +using R3; + +namespace FlowScope.Tests.EditMode.Data +{ + public sealed class DataSerializationTests + { + [Test] + public void Serialize_ReactiveProperty_WritesValueOnly() + { + var serializer = new JsonSaveSerializer(); + var data = new PlayerData(); + + data.Gold.Value = 25; + + var json = serializer.SerializeToString(data); + + Assert.That(json, Does.Contain("\"Gold\":25")); + Assert.That(json, Does.Not.Contain("ReactiveProperty")); + Assert.That(json, Does.Not.Contain("CurrentValue")); + } + + [Test] + public void Deserialize_ReactiveProperty_UpdatesExistingDataInstance() + { + var serializer = new JsonSaveSerializer(); + var data = new PlayerData(); + + serializer.PopulateFromString("{\"Gold\":42,\"Level\":7}", data); + + Assert.That(data.Gold.Value, Is.EqualTo(42)); + Assert.That(data.Level.Value, Is.EqualTo(7)); + } + + [Test] + public void Serialize_ReactiveCollection_WritesJsonArray() + { + var serializer = new JsonSaveSerializer(); + var data = new InventoryData(); + + data.ItemIds.Add(3); + data.ItemIds.Add(5); + + var json = serializer.SerializeToString(data); + + Assert.That(json, Does.Contain("\"ItemIds\":[3,5]")); + } + + [Test] + public void Deserialize_ReactiveCollection_ReplacesCollectionContents() + { + var serializer = new JsonSaveSerializer(); + var data = new InventoryData(); + + data.ItemIds.Add(99); + serializer.PopulateFromString("{\"ItemIds\":[1,2,3]}", data); + + Assert.That(data.ItemIds, Is.EqualTo(new[] { 1, 2, 3 })); + } + + [Test] + public void Populate_TypeMismatch_ThrowsWithFieldName() + { + var serializer = new JsonSaveSerializer(); + + var exception = Assert.Throws( + () => serializer.PopulateFromString("{\"Gold\":\"wrong\"}", new PlayerData())); + + Assert.That(exception.Message, Does.Contain("Gold")); + } + + private sealed class PlayerData + { + public ReactiveProperty Gold { get; } = new(0); + public ReactiveProperty Level { get; } = new(1); + } + + private sealed class InventoryData + { + public ReactiveCollection ItemIds { get; } = new(); + } + } +} diff --git a/My project/Assets/FlowScope/Tests/EditMode/Data/DataSerializationTests.cs.meta b/My project/Assets/FlowScope/Tests/EditMode/Data/DataSerializationTests.cs.meta new file mode 100644 index 0000000..bcd80bc --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Data/DataSerializationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e4a5ec0b75042a78bf3a035173f4f40 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Save.meta b/My project/Assets/FlowScope/Tests/EditMode/Save.meta new file mode 100644 index 0000000..fd95e8d --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Save.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0f6957e7aa224958a64f86fd324ef2d4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/EditMode/Save/SaveServiceTests.cs b/My project/Assets/FlowScope/Tests/EditMode/Save/SaveServiceTests.cs new file mode 100644 index 0000000..433171d --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Save/SaveServiceTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Save; +using NUnit.Framework; +using R3; + +namespace FlowScope.Tests.EditMode.Save +{ + public sealed class SaveServiceTests + { + [Test] + public async Task SaveAsync_ThenLoadAsync_ReturnsEquivalentData() + { + var service = new SaveService(new MemorySaveStorage(), new JsonSaveSerializer()); + var data = new PlayerData(); + data.Gold.Value = 88; + + await service.SaveAsync("player", data, CancellationToken.None); + var loaded = await service.LoadAsync("player", new PlayerData(), CancellationToken.None); + + Assert.That(loaded.Gold.Value, Is.EqualTo(88)); + } + + [Test] + public async Task LoadAsync_MissingKey_ReturnsDefaultValue() + { + var service = new SaveService(new MemorySaveStorage(), new JsonSaveSerializer()); + var fallback = new PlayerData(); + fallback.Gold.Value = 5; + + var loaded = await service.LoadAsync("missing", fallback, CancellationToken.None); + + Assert.That(loaded, Is.SameAs(fallback)); + } + + [Test] + public async Task Delete_RemovesExistingKey() + { + var service = new SaveService(new MemorySaveStorage(), new JsonSaveSerializer()); + + await service.SaveAsync("player", new PlayerData(), CancellationToken.None); + service.Delete("player"); + + Assert.That(service.Exists("player"), Is.False); + } + + [Test] + public async Task LoadAsync_DeserializeFailure_ReturnsDefaultValue() + { + var storage = new MemorySaveStorage(); + storage.Seed("player", new byte[] { 123, 34, 71, 111, 108, 100 }); + var service = new SaveService(storage, new JsonSaveSerializer()); + var fallback = new PlayerData(); + fallback.Gold.Value = 9; + + var loaded = await service.LoadAsync("player", fallback, CancellationToken.None); + + Assert.That(loaded, Is.SameAs(fallback)); + } + + [Test] + public void SaveAsync_WriteFailure_ThrowsWithKey() + { + var service = new SaveService(new FailingWriteStorage(), new JsonSaveSerializer()); + + var exception = Assert.ThrowsAsync( + async () => await service.SaveAsync("player", new PlayerData(), CancellationToken.None)); + + Assert.That(exception.Message, Does.Contain("player")); + } + + [Test] + public void SaveAsync_CanceledToken_ThrowsOperationCanceledException() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var service = new SaveService(new MemorySaveStorage(), new JsonSaveSerializer()); + + Assert.ThrowsAsync( + async () => await service.SaveAsync("player", new PlayerData(), cts.Token)); + } + + private sealed class PlayerData + { + public ReactiveProperty Gold { get; } = new(0); + } + + private sealed class MemorySaveStorage : ISaveStorage + { + private readonly Dictionary _entries = new(); + + public Task WriteAsync(string key, byte[] data, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + _entries[key] = data; + return Task.CompletedTask; + } + + public Task ReadAsync(string key, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_entries.TryGetValue(key, out var data) ? data : null); + } + + public void Delete(string key) => _entries.Remove(key); + public bool Exists(string key) => _entries.ContainsKey(key); + public void Seed(string key, byte[] data) => _entries[key] = data; + } + + private sealed class FailingWriteStorage : ISaveStorage + { + public Task WriteAsync(string key, byte[] data, CancellationToken cancellationToken) + { + throw new Exception("write failed"); + } + + public Task ReadAsync(string key, CancellationToken cancellationToken) => Task.FromResult(null); + public void Delete(string key) { } + public bool Exists(string key) => false; + } + } +} diff --git a/My project/Assets/FlowScope/Tests/EditMode/Save/SaveServiceTests.cs.meta b/My project/Assets/FlowScope/Tests/EditMode/Save/SaveServiceTests.cs.meta new file mode 100644 index 0000000..1710582 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/EditMode/Save/SaveServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 934dbfb274c942f4ae780e66c390b5c1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: