mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
Merge pull request #185 from GeWuYou/feat/game-save-data-system
feat(game): 添加数据与存档系统核心功能
This commit is contained in:
commit
fb3bf49a12
@ -193,6 +193,14 @@ bash scripts/validate-csharp-naming.sh
|
||||
- If a framework abstraction changes meaning or intended usage, update the explanatory comments in code as part of the
|
||||
same change.
|
||||
|
||||
### Task Tracking
|
||||
|
||||
- When working from a tracked implementation plan, contributors MUST update the corresponding tracking document under
|
||||
`local-plan/todos/` in the same change.
|
||||
- Tracking updates MUST reflect completed work, newly discovered issues, validation results, and the next recommended
|
||||
recovery point.
|
||||
- Completing code changes without updating the active tracking document is considered incomplete work.
|
||||
|
||||
### Repository Documentation
|
||||
|
||||
- Update the relevant `README.md` or `docs/` page when behavior, setup steps, architecture guidance, or user-facing
|
||||
|
||||
44
GFramework.Game.Abstractions/Data/ISaveMigration.cs
Normal file
44
GFramework.Game.Abstractions/Data/ISaveMigration.cs
Normal file
@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 GeWuYou
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
|
||||
namespace GFramework.Game.Abstractions.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 定义存档数据迁移接口,用于将旧版本存档升级到较新的版本。
|
||||
/// </summary>
|
||||
/// <typeparam name="TSaveData">
|
||||
/// 存档数据类型。该类型通常需要实现 <see cref="IVersionedData" />,
|
||||
/// 以便仓库在加载时判断当前版本并串联迁移链。
|
||||
/// </typeparam>
|
||||
public interface ISaveMigration<TSaveData>
|
||||
where TSaveData : class, IData
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取迁移前的版本号。
|
||||
/// </summary>
|
||||
int FromVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取迁移后的目标版本号。
|
||||
/// </summary>
|
||||
int ToVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 将旧版本存档转换为新版本存档。
|
||||
/// </summary>
|
||||
/// <param name="oldData">待升级的旧版本存档数据。</param>
|
||||
/// <returns>迁移完成后的存档数据。</returns>
|
||||
TSaveData Migrate(TSaveData oldData);
|
||||
}
|
||||
@ -11,6 +11,9 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Abstractions.Utility;
|
||||
|
||||
namespace GFramework.Game.Abstractions.Data;
|
||||
@ -22,6 +25,20 @@ namespace GFramework.Game.Abstractions.Data;
|
||||
public interface ISaveRepository<TSaveData> : IUtility
|
||||
where TSaveData : class, IData, new()
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册存档迁移器。
|
||||
/// </summary>
|
||||
/// <param name="migration">
|
||||
/// 负责将某个旧版本存档升级到新版本的迁移器。
|
||||
/// 仅当 <typeparamref name="TSaveData" /> 实现 <see cref="IVersionedData" /> 时该功能才有效。
|
||||
/// </param>
|
||||
/// <returns>当前存档仓库实例,便于链式注册多个迁移器。</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="migration" /> 为 <see langword="null" />。</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <typeparamref name="TSaveData" /> 未实现 <see cref="IVersionedData" />,因此无法建立版本迁移管线。
|
||||
/// </exception>
|
||||
ISaveRepository<TSaveData> RegisterMigration(ISaveMigration<TSaveData> migration);
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定槽位是否存在存档
|
||||
/// </summary>
|
||||
|
||||
@ -1,20 +1,103 @@
|
||||
using System;
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
using GFramework.Game.Abstractions.Enums;
|
||||
|
||||
namespace GFramework.Game.Tests.Data;
|
||||
|
||||
internal sealed record TestDataLocation(
|
||||
string Key,
|
||||
StorageKinds Kinds = StorageKinds.Local,
|
||||
string? Namespace = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null) : IDataLocation;
|
||||
/// <summary>
|
||||
/// 为持久化测试提供稳定的测试数据位置实现。
|
||||
/// </summary>
|
||||
internal sealed class TestDataLocation : IDataLocation
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化测试数据位置。
|
||||
/// </summary>
|
||||
/// <param name="key">测试使用的存储键。</param>
|
||||
/// <param name="kinds">测试使用的存储类型。</param>
|
||||
/// <param name="namespaceValue">测试使用的命名空间。</param>
|
||||
/// <param name="metadata">附加测试元数据。</param>
|
||||
public TestDataLocation(
|
||||
string key,
|
||||
StorageKinds kinds = StorageKinds.Local,
|
||||
string? namespaceValue = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
Key = key;
|
||||
Kinds = kinds;
|
||||
Namespace = namespaceValue;
|
||||
Metadata = metadata;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取测试数据对应的存储键。
|
||||
/// </summary>
|
||||
public string Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取测试数据使用的存储类型。
|
||||
/// </summary>
|
||||
public StorageKinds Kinds { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取测试数据使用的命名空间。
|
||||
/// </summary>
|
||||
public string? Namespace { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取附加到测试位置上的元数据。
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为基础存档仓库测试提供的简单存档模型。
|
||||
/// </summary>
|
||||
internal sealed class TestSaveData : IData
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置测试存档中的名称字段。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为存档迁移测试提供的版本化存档模型。
|
||||
/// </summary>
|
||||
internal sealed class TestVersionedSaveData : IVersionedData
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置测试存档中的名称字段。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置测试存档中的等级字段。
|
||||
/// </summary>
|
||||
public int Level { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置测试存档中的经验字段。
|
||||
/// </summary>
|
||||
public int Experience { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置当前测试存档的版本号。
|
||||
/// </summary>
|
||||
public int Version { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置测试存档的最后修改时间。
|
||||
/// </summary>
|
||||
public DateTime LastModified { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为通用持久化测试提供的简单数据模型。
|
||||
/// </summary>
|
||||
internal sealed class TestSimpleData : IData
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置测试数据中的整数值。
|
||||
/// </summary>
|
||||
public int Value { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,9 @@ using GFramework.Game.Storage;
|
||||
|
||||
namespace GFramework.Game.Tests.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖文件存储、槽位存档仓库和统一设置仓库的持久化行为测试。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class PersistenceTests
|
||||
{
|
||||
@ -16,6 +19,10 @@ public class PersistenceTests
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证文件存储能够持久化数据,并拒绝包含路径逃逸的非法键。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task FileStorage_PersistsDataAndRejectsIllegalKeys()
|
||||
{
|
||||
@ -31,6 +38,10 @@ public class PersistenceTests
|
||||
Assert.ThrowsAsync<ArgumentException>(async () => await storage.WriteAsync("../escape", new TestSimpleData()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证槽位存档仓库的保存、加载、列举和删除行为。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task SaveRepository_ManagesSlots()
|
||||
{
|
||||
@ -59,6 +70,121 @@ public class PersistenceTests
|
||||
Assert.That(await repository.ExistsAsync(1), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证存档仓库在加载旧版本数据时会执行迁移链并回写升级后的最新版本。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task SaveRepository_LoadAsync_Should_Apply_Migrations_And_Persist_Upgraded_Save()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer());
|
||||
var config = new SaveConfiguration
|
||||
{
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save"
|
||||
};
|
||||
|
||||
var writer = new SaveRepository<TestVersionedSaveData>(storage, config);
|
||||
await writer.SaveAsync(1, new TestVersionedSaveData
|
||||
{
|
||||
Name = "hero",
|
||||
Level = 5,
|
||||
Experience = 0,
|
||||
Version = 1
|
||||
});
|
||||
|
||||
var repository = new SaveRepository<TestVersionedSaveData>(storage, config)
|
||||
.RegisterMigration(new TestSaveMigrationV1ToV2())
|
||||
.RegisterMigration(new TestSaveMigrationV2ToV3());
|
||||
|
||||
var loaded = await repository.LoadAsync(1);
|
||||
var persisted = await storage.ReadAsync<TestVersionedSaveData>("saves/slot_1/save");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(loaded.Version, Is.EqualTo(3));
|
||||
Assert.That(loaded.Experience, Is.EqualTo(500));
|
||||
Assert.That(loaded.Name, Is.EqualTo("hero-v2"));
|
||||
Assert.That(persisted.Version, Is.EqualTo(3));
|
||||
Assert.That(persisted.Experience, Is.EqualTo(500));
|
||||
Assert.That(persisted.Name, Is.EqualTo("hero-v2"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证非版本化存档类型不能注册迁移器,避免构建无效迁移管线。
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">当存档类型未实现 <see cref="IVersionedData" /> 时抛出。</exception>
|
||||
[Test]
|
||||
public void SaveRepository_RegisterMigration_For_NonVersioned_Save_Should_Throw()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer());
|
||||
var config = new SaveConfiguration();
|
||||
var repository = new SaveRepository<TestSaveData>(storage, config);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => repository.RegisterMigration(new TestNonVersionedMigration()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证同一源版本不能重复注册迁移器,避免迁移链配置被静默覆盖。
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">当同一源版本重复注册迁移器时抛出。</exception>
|
||||
[Test]
|
||||
public void SaveRepository_RegisterMigration_Should_Reject_Duplicate_FromVersion()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer());
|
||||
var config = new SaveConfiguration();
|
||||
var repository = new SaveRepository<TestVersionedSaveData>(storage, config);
|
||||
|
||||
repository.RegisterMigration(new TestSaveMigrationV1ToV2());
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => repository.RegisterMigration(new TestDuplicateSaveMigrationV1ToV2()));
|
||||
|
||||
Assert.That(exception!.Message, Does.Contain("Duplicate save migration registration"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当迁移链缺少中间版本时,加载旧存档会明确失败而不是静默返回不完整数据。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
/// <exception cref="InvalidOperationException">当从旧版本到当前版本的迁移链不完整时抛出。</exception>
|
||||
[Test]
|
||||
public async Task SaveRepository_LoadAsync_Should_Throw_When_Migration_Chain_Is_Incomplete()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer());
|
||||
var config = new SaveConfiguration
|
||||
{
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save"
|
||||
};
|
||||
|
||||
var writer = new SaveRepository<TestVersionedSaveData>(storage, config);
|
||||
await writer.SaveAsync(1, new TestVersionedSaveData
|
||||
{
|
||||
Name = "legacy",
|
||||
Level = 3,
|
||||
Experience = 0,
|
||||
Version = 1
|
||||
});
|
||||
|
||||
var repository = new SaveRepository<TestVersionedSaveData>(storage, config)
|
||||
.RegisterMigration(new TestSaveMigrationV1ToV2());
|
||||
|
||||
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await repository.LoadAsync(1));
|
||||
Assert.That(exception!.Message, Does.Contain("from version 2"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库能够保存、重新加载并批量读取已注册的设置数据。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task UnifiedSettingsDataRepository_RoundTripsDataAndLoadAll()
|
||||
{
|
||||
@ -92,4 +218,76 @@ public class PersistenceTests
|
||||
Assert.That(all.Keys, Contains.Item(location.Key));
|
||||
Assert.That(all[location.Key], Is.TypeOf<TestSimpleData>());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSaveMigrationV1ToV2 : ISaveMigration<TestVersionedSaveData>
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
|
||||
{
|
||||
return new TestVersionedSaveData
|
||||
{
|
||||
Name = $"{oldData.Name}-v2",
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Level * 100,
|
||||
Version = 2,
|
||||
LastModified = oldData.LastModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSaveMigrationV2ToV3 : ISaveMigration<TestVersionedSaveData>
|
||||
{
|
||||
public int FromVersion => 2;
|
||||
|
||||
public int ToVersion => 3;
|
||||
|
||||
public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
|
||||
{
|
||||
return new TestVersionedSaveData
|
||||
{
|
||||
Name = oldData.Name,
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Experience,
|
||||
Version = 3,
|
||||
LastModified = oldData.LastModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestDuplicateSaveMigrationV1ToV2 : ISaveMigration<TestVersionedSaveData>
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
|
||||
{
|
||||
return new TestVersionedSaveData
|
||||
{
|
||||
Name = $"{oldData.Name}-duplicate",
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Experience,
|
||||
Version = 2,
|
||||
LastModified = oldData.LastModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestNonVersionedMigration : ISaveMigration<TestSaveData>
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public TestSaveData Migrate(TestSaveData oldData)
|
||||
{
|
||||
return new TestSaveData
|
||||
{
|
||||
Name = oldData.Name
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Core.Utility;
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
@ -27,6 +31,8 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
|
||||
where TSaveData : class, IData, new()
|
||||
{
|
||||
private readonly SaveConfiguration _config;
|
||||
private readonly Dictionary<int, ISaveMigration<TSaveData>> _migrations = new();
|
||||
private readonly object _migrationsLock = new();
|
||||
private readonly IStorage _rootStorage;
|
||||
|
||||
/// <summary>
|
||||
@ -43,6 +49,47 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
|
||||
_rootStorage = new ScopedStorage(storage, config.SaveRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册存档迁移器,使仓库在加载旧版本存档时自动执行升级。
|
||||
/// </summary>
|
||||
/// <param name="migration">要注册的存档迁移器。</param>
|
||||
/// <returns>当前存档仓库实例,支持链式调用。</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="migration" /> 为 <see langword="null" />。</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <typeparamref name="TSaveData" /> 未实现 <see cref="IVersionedData" />,无法使用版本化迁移。
|
||||
/// 或者同一个源版本已经注册过迁移器,导致迁移链配置存在歧义。
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentException">迁移器的目标版本不大于源版本。</exception>
|
||||
/// <remarks>
|
||||
/// 迁移注册表是可变共享状态。注册与加载可以并发发生,因此所有访问都通过 <see cref="_migrationsLock" />
|
||||
/// 串行化,避免读写竞争和“部分可见”的迁移链。
|
||||
/// </remarks>
|
||||
public ISaveRepository<TSaveData> RegisterMigration(ISaveMigration<TSaveData> migration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migration);
|
||||
EnsureVersionedSaveType();
|
||||
|
||||
if (migration.ToVersion <= migration.FromVersion)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Migration for {typeof(TSaveData).Name} must advance the version number.",
|
||||
nameof(migration));
|
||||
}
|
||||
|
||||
lock (_migrationsLock)
|
||||
{
|
||||
if (_migrations.ContainsKey(migration.FromVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate save migration registration for {typeof(TSaveData).Name} from version {migration.FromVersion}.");
|
||||
}
|
||||
|
||||
_migrations.Add(migration.FromVersion, migration);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定槽位是否存在存档
|
||||
/// </summary>
|
||||
@ -64,7 +111,10 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
|
||||
var storage = GetSlotStorage(slot);
|
||||
|
||||
if (await storage.ExistsAsync(_config.SaveFileName))
|
||||
return await storage.ReadAsync<TSaveData>(_config.SaveFileName);
|
||||
{
|
||||
var loaded = await storage.ReadAsync<TSaveData>(_config.SaveFileName);
|
||||
return await MigrateIfNeededAsync(slot, storage, loaded);
|
||||
}
|
||||
|
||||
return new TSaveData();
|
||||
}
|
||||
@ -137,10 +187,121 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
|
||||
return new ScopedStorage(_rootStorage, $"{_config.SaveSlotPrefix}{slot}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在加载旧版本存档时按注册顺序执行迁移,并在成功后自动回写升级结果。
|
||||
/// </summary>
|
||||
/// <param name="slot">当前加载的存档槽位。</param>
|
||||
/// <param name="storage">对应槽位的存储对象。</param>
|
||||
/// <param name="data">原始加载出来的存档数据。</param>
|
||||
/// <returns>迁移后的最新存档;如果无需迁移则返回原始对象。</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 当前运行时缺少必要的迁移链、读取到更高版本的存档,或迁移器返回了非法版本。
|
||||
/// </exception>
|
||||
private async Task<TSaveData> MigrateIfNeededAsync(int slot, IStorage storage, TSaveData data)
|
||||
{
|
||||
if (data is not IVersionedData versionedData)
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
var latestTemplate = new TSaveData();
|
||||
if (latestTemplate is not IVersionedData latestVersionedData)
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
var currentVersion = versionedData.Version;
|
||||
var targetVersion = latestVersionedData.Version;
|
||||
|
||||
if (currentVersion > targetVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save slot {slot} for {typeof(TSaveData).Name} is version {currentVersion}, " +
|
||||
$"which is newer than the current runtime version {targetVersion}.");
|
||||
}
|
||||
|
||||
if (currentVersion == targetVersion)
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
EnsureVersionedSaveType();
|
||||
|
||||
var migrated = data;
|
||||
|
||||
// 迁移链按“当前版本 -> 下一个已注册迁移器”推进;任何缺口都表示运行时无法安全解释旧存档。
|
||||
// 读取迁移表时使用同一把锁,保证并发注册不会让加载线程看到不一致的链路状态。
|
||||
while (currentVersion < targetVersion)
|
||||
{
|
||||
ISaveMigration<TSaveData>? migration;
|
||||
lock (_migrationsLock)
|
||||
{
|
||||
_migrations.TryGetValue(currentVersion, out migration);
|
||||
}
|
||||
|
||||
if (migration is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No save migration is registered for {typeof(TSaveData).Name} from version {currentVersion}.");
|
||||
}
|
||||
|
||||
migrated = migration.Migrate(migrated) ??
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} from version {currentVersion} returned null.");
|
||||
|
||||
if (migrated is not IVersionedData migratedVersionedData)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} must return data implementing {nameof(IVersionedData)}.");
|
||||
}
|
||||
|
||||
// 显式校验迁移器声明与实际结果,避免版本号不前进导致死循环或把旧数据错误回写为“已升级”。
|
||||
if (migration.ToVersion != migratedVersionedData.Version)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} declared target version {migration.ToVersion} " +
|
||||
$"but returned version {migratedVersionedData.Version}.");
|
||||
}
|
||||
|
||||
if (migratedVersionedData.Version <= currentVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} must advance beyond version {currentVersion}.");
|
||||
}
|
||||
|
||||
if (migratedVersionedData.Version > targetVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} produced version {migratedVersionedData.Version}, " +
|
||||
$"which exceeds the current runtime version {targetVersion}.");
|
||||
}
|
||||
|
||||
currentVersion = migratedVersionedData.Version;
|
||||
}
|
||||
|
||||
await storage.WriteAsync(_config.SaveFileName, migrated);
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当前存档类型支持基于版本号的迁移流程。
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <typeparamref name="TSaveData" /> 未实现 <see cref="IVersionedData" />。
|
||||
/// </exception>
|
||||
private static void EnsureVersionedSaveType()
|
||||
{
|
||||
if (!typeof(IVersionedData).IsAssignableFrom(typeof(TSaveData)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{typeof(TSaveData).Name} must implement {nameof(IVersionedData)} to use save migrations.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化逻辑
|
||||
/// </summary>
|
||||
protected override void OnInit()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -136,9 +136,4 @@
|
||||
<AdditionalFiles Remove="AnalyzerReleases.Shipped.md"/>
|
||||
<AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="local-plan\docs\"/>
|
||||
<Folder Include="local-plan\todos\"/>
|
||||
<Folder Include="local-plan\评估\"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@ -56,6 +56,7 @@ public interface IDataRepository : IUtility
|
||||
public interface ISaveRepository<TSaveData> : IUtility
|
||||
where TSaveData : class, IData, new()
|
||||
{
|
||||
ISaveRepository<TSaveData> RegisterMigration(ISaveMigration<TSaveData> migration);
|
||||
Task<bool> ExistsAsync(int slot);
|
||||
Task<TSaveData> LoadAsync(int slot);
|
||||
Task SaveAsync(int slot, TSaveData data);
|
||||
@ -64,6 +65,18 @@ public interface ISaveRepository<TSaveData> : IUtility
|
||||
}
|
||||
```
|
||||
|
||||
`ISaveMigration<TSaveData>` 定义单步迁移:
|
||||
|
||||
```csharp
|
||||
public interface ISaveMigration<TSaveData>
|
||||
where TSaveData : class, IData
|
||||
{
|
||||
int FromVersion { get; }
|
||||
int ToVersion { get; }
|
||||
TSaveData Migrate(TSaveData oldData);
|
||||
}
|
||||
```
|
||||
|
||||
### 版本化数据
|
||||
|
||||
`IVersionedData` 支持数据版本管理:
|
||||
@ -265,67 +278,77 @@ public partial class AutoSaveController : IController
|
||||
|
||||
### 数据版本迁移
|
||||
|
||||
`SaveRepository<TSaveData>` 当前负责槽位存档的读取、写入、删除和列举,并没有内建“注册迁移器后自动升级存档”的统一迁移管线。
|
||||
`SaveRepository<TSaveData>` 现在支持注册正式的迁移器,并在 `LoadAsync(slot)` 时自动升级旧版本存档。
|
||||
|
||||
下面示例展示的是应用层迁移策略:加载后检查版本,调用你自己的迁移逻辑,再决定是否回写新版本数据。
|
||||
迁移规则如下:
|
||||
|
||||
- `TSaveData` 需要实现 `IVersionedData`
|
||||
- 仓库以 `new TSaveData().Version` 作为当前运行时目标版本
|
||||
- 每个迁移器负责一个 `FromVersion -> ToVersion` 跳转
|
||||
- 加载时仓库会按链路连续执行迁移,并在成功后自动回写升级后的存档
|
||||
- 如果缺少中间迁移器,或者读到了比当前运行时更高的版本,`LoadAsync` 会抛出异常,避免静默加载错误数据
|
||||
|
||||
```csharp
|
||||
// 版本 1 的数据
|
||||
public class SaveDataV1 : IVersionedData
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
public string PlayerName { get; set; }
|
||||
public int Level { get; set; }
|
||||
}
|
||||
|
||||
// 版本 2 的数据(添加了新字段)
|
||||
public class SaveDataV2 : IVersionedData
|
||||
public sealed class SaveData : IVersionedData
|
||||
{
|
||||
// 当前运行时代码支持的最新版本
|
||||
public int Version { get; set; } = 2;
|
||||
public string PlayerName { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int Experience { get; set; } // 新增字段
|
||||
public DateTime LastPlayTime { get; set; } // 新增字段
|
||||
public int Experience { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
}
|
||||
|
||||
// 数据迁移器
|
||||
public class SaveDataMigrator
|
||||
public sealed class SaveDataMigrationV1ToV2 : ISaveMigration<SaveData>
|
||||
{
|
||||
public SaveDataV2 Migrate(SaveDataV1 oldData)
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public SaveData Migrate(SaveData oldData)
|
||||
{
|
||||
return new SaveDataV2
|
||||
return new SaveData
|
||||
{
|
||||
Version = 2,
|
||||
PlayerName = oldData.PlayerName,
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Level * 100, // 根据等级计算经验
|
||||
LastPlayTime = DateTime.Now
|
||||
Experience = oldData.Level * 100,
|
||||
LastModified = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 加载后由应用层决定是否迁移
|
||||
public async Task<SaveDataV2> LoadWithMigration(int slot)
|
||||
public sealed class SaveModule : AbstractModule
|
||||
{
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveDataV2>>();
|
||||
var data = await saveRepo.LoadAsync(slot);
|
||||
|
||||
if (data.Version < 2)
|
||||
public override void Install(IArchitecture architecture)
|
||||
{
|
||||
// 需要迁移:此处调用应用层迁移器
|
||||
var oldData = data as SaveDataV1;
|
||||
var migrator = new SaveDataMigrator();
|
||||
var newData = migrator.Migrate(oldData);
|
||||
var storage = architecture.GetUtility<IStorage>();
|
||||
var saveConfig = new SaveConfiguration
|
||||
{
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save"
|
||||
};
|
||||
|
||||
// 保存迁移后的数据
|
||||
await saveRepo.SaveAsync(slot, newData);
|
||||
return newData;
|
||||
var saveRepo = new SaveRepository<SaveData>(storage, saveConfig)
|
||||
.RegisterMigration(new SaveDataMigrationV1ToV2());
|
||||
|
||||
architecture.RegisterUtility<ISaveRepository<SaveData>>(saveRepo);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
public async Task<SaveData> LoadGame(int slot)
|
||||
{
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveData>>();
|
||||
|
||||
// 如果槽位里是 v1,仓库会自动迁移到 v2,并把新版本重新写回存储。
|
||||
return await saveRepo.LoadAsync(slot);
|
||||
}
|
||||
```
|
||||
|
||||
`ISaveMigration<TSaveData>` 接收和返回的是同一个存档类型。也就是说,框架提供的是“当前类型内的版本升级管线”,
|
||||
而不是跨 CLR 类型的双模型反序列化系统。如果旧版本缺失了新字段,反序列化会先使用当前类型的默认值,再由迁移器补齐。
|
||||
|
||||
### 使用数据仓库
|
||||
|
||||
```csharp
|
||||
@ -514,15 +537,14 @@ await saveRepo.SaveAsync(3, saveData); // 槽位 3
|
||||
### 问题:如何处理数据版本升级?
|
||||
|
||||
**解答**:
|
||||
实现 `IVersionedData` 并在加载后检查版本。当前框架不会自动为 `ISaveRepository<T>` 执行迁移,需要由业务层决定迁移规则与回写时机:
|
||||
实现 `IVersionedData`,并在仓库初始化阶段注册 `ISaveMigration<TSaveData>`。之后 `LoadAsync(slot)` 会自动执行迁移并回写:
|
||||
|
||||
```csharp
|
||||
var saveRepo = new SaveRepository<SaveData>(storage, saveConfig)
|
||||
.RegisterMigration(new SaveDataMigrationV1ToV2())
|
||||
.RegisterMigration(new SaveDataMigrationV2ToV3());
|
||||
|
||||
var data = await saveRepo.LoadAsync(slot);
|
||||
if (data.Version < CurrentVersion)
|
||||
{
|
||||
data = MigrateData(data);
|
||||
await saveRepo.SaveAsync(slot, data);
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:存档数据保存在哪里?
|
||||
|
||||
@ -196,4 +196,4 @@ public interface ISettingsMigration
|
||||
- 设置迁移是内建能力
|
||||
- 设置持久化是内建能力
|
||||
- 设置如何应用到具体引擎由 applicator 决定
|
||||
- 存档系统的迁移能力不等同于设置系统;`ISaveRepository<T>` 当前仍需要业务层自己实现迁移策略
|
||||
- 存档系统也支持内建版本迁移,但入口位于 `ISaveRepository<T>.RegisterMigration(...)`,语义是槽位存档升级而不是设置节初始化
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user