扩展 P1 配置来源和存档迁移
This commit is contained in:
@@ -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<string> LoadTextAsync(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var path = Path.Combine(_rootPath, fileName);
|
||||
return Task.Run(() => File.ReadAllText(path), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d50cd860ba849a1aa7647deba4e71db
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<string, string> _files;
|
||||
|
||||
public InMemoryConfigSource(
|
||||
IReadOnlyDictionary<string, string> files,
|
||||
string name = "InMemory")
|
||||
{
|
||||
_files = files != null
|
||||
? new Dictionary<string, string>(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<string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 607d9b20e7f04565929ea5876e5c25e1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<T> ParseRows<T>(string fileName, string text)
|
||||
where T : class, IConfigRow
|
||||
{
|
||||
return ReactivePropertyJsonConverter.DeserializeArray<T>(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f60f8a8e66394f26a20ed6f5ef314c63
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<T>(string fileName, Func<CancellationToken, Task<string>> loadTextAsync)
|
||||
where T : class, IConfigRow
|
||||
{
|
||||
return new ConfigMapping(typeof(T), fileName, loadTextAsync);
|
||||
return Mapping<T>(
|
||||
fileName,
|
||||
new DelegateConfigSource(fileName, loadTextAsync),
|
||||
new JsonConfigParser());
|
||||
}
|
||||
|
||||
public static ConfigMapping Mapping<T>(
|
||||
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<string, object> _parse;
|
||||
private readonly MethodInfo _parseRowsMethod;
|
||||
|
||||
internal ConfigMapping(Type rowType, string fileName, Func<CancellationToken, Task<string>> 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<CancellationToken, Task<string>> LoadTextAsync { get; }
|
||||
public IConfigSource Source { get; }
|
||||
public IConfigParser Parser { get; }
|
||||
|
||||
internal object Parse(string json) => _parse(json);
|
||||
|
||||
private static Func<string, object> 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<T>(string json) where T : class, IConfigRow
|
||||
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)
|
||||
{
|
||||
var rows = ReactivePropertyJsonConverter.DeserializeArray<T>(json);
|
||||
var byId = new Dictionary<int, T>();
|
||||
foreach (var row in rows)
|
||||
if (byId.Contains(row.Id))
|
||||
{
|
||||
if (byId.ContainsKey(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<CancellationToken, Task<string>> _loadTextAsync;
|
||||
|
||||
public DelegateConfigSource(
|
||||
string fileName,
|
||||
Func<CancellationToken, Task<string>> loadTextAsync)
|
||||
{
|
||||
Name = fileName;
|
||||
_loadTextAsync = loadTextAsync ?? throw new ArgumentNullException(nameof(loadTextAsync));
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public Task<string> LoadTextAsync(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
return _loadTextAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(T data)
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(SerializeToString(data));
|
||||
@@ -29,19 +45,75 @@ namespace FlowScope.Save
|
||||
|
||||
public string SerializeToString<T>(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<T>(string json)
|
||||
{
|
||||
var instance = (T)Activator.CreateInstance(typeof(T), true);
|
||||
PopulateFromString(json, instance);
|
||||
PopulateFromString(ResolvePayload<T>(json), instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void PopulateFromString<T>(string json, T target)
|
||||
{
|
||||
ReactivePropertyJsonConverter.PopulateObject(json, target);
|
||||
ReactivePropertyJsonConverter.PopulateObject(ResolvePayload<T>(json), target);
|
||||
}
|
||||
|
||||
private string ResolvePayload<T>(string json)
|
||||
{
|
||||
if (!TryReadEnvelope(json, out var envelope))
|
||||
{
|
||||
return _currentVersion > 1
|
||||
? _migrations.Migrate(GetMigrationKey<T>(), 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<T>(),
|
||||
envelope.Version,
|
||||
_currentVersion,
|
||||
envelope.Payload);
|
||||
}
|
||||
|
||||
private static bool TryReadEnvelope(string json, out SaveEnvelope envelope)
|
||||
{
|
||||
envelope = null;
|
||||
try
|
||||
{
|
||||
envelope = ReactivePropertyJsonConverter.DeserializeObject<SaveEnvelope>(json);
|
||||
return envelope.Version > 0 && envelope.Payload != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetMigrationKey<T>()
|
||||
{
|
||||
return typeof(T).FullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
My project/Assets/FlowScope/Runtime/Save/SaveEnvelope.cs
Normal file
8
My project/Assets/FlowScope/Runtime/Save/SaveEnvelope.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace FlowScope.Save
|
||||
{
|
||||
internal sealed class SaveEnvelope
|
||||
{
|
||||
public int Version { get; set; }
|
||||
public string Payload { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 67ada4b73d47420db150f34ab12a236a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FlowScope.Save
|
||||
{
|
||||
public sealed class SaveMigrationRegistry
|
||||
{
|
||||
private readonly Dictionary<MigrationKey, ISaveMigration> _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<MigrationKey>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f2c3dd694c94ed59d08bcdf88d830a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<string, string>
|
||||
{
|
||||
["player.json"] = "[{\"id\":7,\"name\":\"Starter\"}]"
|
||||
});
|
||||
var provider = new JsonConfigProvider(new[]
|
||||
{
|
||||
JsonConfigProvider.Mapping<PlayerConfig>("player.json", source, new JsonConfigParser())
|
||||
});
|
||||
|
||||
provider.LoadAllAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(provider.Get<PlayerConfig>(7).Name, Is.EqualTo("Starter"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LoadAllAsync_WhenSourceFails_IncludesSourceAndFileName()
|
||||
{
|
||||
var source = new InMemoryConfigSource(new Dictionary<string, string>(), "MemoryA");
|
||||
var provider = new JsonConfigProvider(new[]
|
||||
{
|
||||
JsonConfigProvider.Mapping<PlayerConfig>("missing.json", source, new JsonConfigParser())
|
||||
});
|
||||
|
||||
var exception = ThrowsAsync<InvalidOperationException>(
|
||||
() => 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<string, string>
|
||||
{
|
||||
["player.json"] = "[{\"id\":7,\"name\":\"A\"},{\"id\":7,\"name\":\"B\"}]"
|
||||
});
|
||||
var provider = new JsonConfigProvider(new[]
|
||||
{
|
||||
JsonConfigProvider.Mapping<PlayerConfig>("player.json", source, new JsonConfigParser())
|
||||
});
|
||||
|
||||
var exception = ThrowsAsync<InvalidOperationException>(
|
||||
() => provider.LoadAllAsync(CancellationToken.None));
|
||||
|
||||
Assert.That(exception.Message, Does.Contain("player.json"));
|
||||
Assert.That(exception.Message, Does.Contain("7"));
|
||||
}
|
||||
|
||||
private static TException ThrowsAsync<TException>(Func<Task> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 58935522df66446e93252f079376b603
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<PlayerSaveV2>(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<InvalidOperationException>(
|
||||
() => serializer.DeserializeFromString<PlayerSaveV2>(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<int> Gold { get; set; } = new();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e9fca0294774fed9d4532f3f1e5c58b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user