From 7ad80f54d3f872186c13763ab34c099ba3cf9dc2 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:09:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8E=E5=AD=98=E6=A1=A3=E7=B3=BB=E7=BB=9F=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 ISaveRepository 接口提供存档管理功能 - 添加 SaveRepository 实现类支持槽位存档管理 - 实现数据版本迁移机制支持存档版本升级 - 添加完整的存档测试用例验证功能正确性 - 创建数据与存档系统中文文档说明使用方法 - 移除项目中不再需要的本地计划文件夹配置 --- .../Data/ISaveMigration.cs | 44 ++++++ .../Data/ISaveRepository.cs | 17 +++ .../Data/PersistenceTestUtilities.cs | 16 +- .../Data/PersistenceTests.cs | 133 +++++++++++++++- GFramework.Game/Data/SaveRepository.cs | 142 +++++++++++++++++- GFramework.csproj | 5 - docs/zh-CN/game/data.md | 102 ++++++++----- docs/zh-CN/game/setting.md | 2 +- 8 files changed, 411 insertions(+), 50 deletions(-) create mode 100644 GFramework.Game.Abstractions/Data/ISaveMigration.cs diff --git a/GFramework.Game.Abstractions/Data/ISaveMigration.cs b/GFramework.Game.Abstractions/Data/ISaveMigration.cs new file mode 100644 index 00000000..4c11e470 --- /dev/null +++ b/GFramework.Game.Abstractions/Data/ISaveMigration.cs @@ -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; + +/// +/// 定义存档数据迁移接口,用于将旧版本存档升级到较新的版本。 +/// +/// +/// 存档数据类型。该类型通常需要实现 , +/// 以便仓库在加载时判断当前版本并串联迁移链。 +/// +public interface ISaveMigration + where TSaveData : class, IData +{ + /// + /// 获取迁移前的版本号。 + /// + int FromVersion { get; } + + /// + /// 获取迁移后的目标版本号。 + /// + int ToVersion { get; } + + /// + /// 将旧版本存档转换为新版本存档。 + /// + /// 待升级的旧版本存档数据。 + /// 迁移完成后的存档数据。 + TSaveData Migrate(TSaveData oldData); +} diff --git a/GFramework.Game.Abstractions/Data/ISaveRepository.cs b/GFramework.Game.Abstractions/Data/ISaveRepository.cs index 52e160ac..363ae62e 100644 --- a/GFramework.Game.Abstractions/Data/ISaveRepository.cs +++ b/GFramework.Game.Abstractions/Data/ISaveRepository.cs @@ -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 : IUtility where TSaveData : class, IData, new() { + /// + /// 注册存档迁移器。 + /// + /// + /// 负责将某个旧版本存档升级到新版本的迁移器。 + /// 仅当 实现 时该功能才有效。 + /// + /// 当前存档仓库实例,便于链式注册多个迁移器。 + /// + /// + /// 未实现 ,因此无法建立版本迁移管线。 + /// + ISaveRepository RegisterMigration(ISaveMigration migration); + /// /// 检查指定槽位是否存在存档 /// diff --git a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs index 9324b7b8..440df286 100644 --- a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs +++ b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs @@ -1,3 +1,4 @@ +using System; using GFramework.Game.Abstractions.Data; using GFramework.Game.Abstractions.Enums; @@ -14,7 +15,20 @@ internal sealed class TestSaveData : IData public string Name { get; set; } = string.Empty; } +internal sealed class TestVersionedSaveData : IVersionedData +{ + public string Name { get; set; } = string.Empty; + + public int Level { get; set; } + + public int Experience { get; set; } + + public int Version { get; set; } = 3; + + public DateTime LastModified { get; set; } = DateTime.UtcNow; +} + internal sealed class TestSimpleData : IData { public int Value { get; set; } -} \ No newline at end of file +} diff --git a/GFramework.Game.Tests/Data/PersistenceTests.cs b/GFramework.Game.Tests/Data/PersistenceTests.cs index c5937034..68f64edb 100644 --- a/GFramework.Game.Tests/Data/PersistenceTests.cs +++ b/GFramework.Game.Tests/Data/PersistenceTests.cs @@ -59,6 +59,84 @@ public class PersistenceTests Assert.That(await repository.ExistsAsync(1), Is.False); } + [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(storage, config); + await writer.SaveAsync(1, new TestVersionedSaveData + { + Name = "hero", + Level = 5, + Experience = 0, + Version = 1 + }); + + var repository = new SaveRepository(storage, config) + .RegisterMigration(new TestSaveMigrationV1ToV2()) + .RegisterMigration(new TestSaveMigrationV2ToV3()); + + var loaded = await repository.LoadAsync(1); + var persisted = await storage.ReadAsync("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")); + }); + } + + [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(storage, config); + + Assert.Throws(() => repository.RegisterMigration(new TestNonVersionedMigration())); + } + + [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(storage, config); + await writer.SaveAsync(1, new TestVersionedSaveData + { + Name = "legacy", + Level = 3, + Experience = 0, + Version = 1 + }); + + var repository = new SaveRepository(storage, config) + .RegisterMigration(new TestSaveMigrationV1ToV2()); + + var exception = Assert.ThrowsAsync(async () => await repository.LoadAsync(1)); + Assert.That(exception!.Message, Does.Contain("from version 2")); + } + [Test] public async Task UnifiedSettingsDataRepository_RoundTripsDataAndLoadAll() { @@ -92,4 +170,57 @@ public class PersistenceTests Assert.That(all.Keys, Contains.Item(location.Key)); Assert.That(all[location.Key], Is.TypeOf()); } -} \ No newline at end of file + + private sealed class TestSaveMigrationV1ToV2 : ISaveMigration + { + 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 + { + 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 TestNonVersionedMigration : ISaveMigration + { + public int FromVersion => 1; + + public int ToVersion => 2; + + public TestSaveData Migrate(TestSaveData oldData) + { + return new TestSaveData + { + Name = oldData.Name + }; + } + } +} diff --git a/GFramework.Game/Data/SaveRepository.cs b/GFramework.Game/Data/SaveRepository.cs index f7eb57f9..b916c79b 100644 --- a/GFramework.Game/Data/SaveRepository.cs +++ b/GFramework.Game/Data/SaveRepository.cs @@ -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,7 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository where TSaveData : class, IData, new() { private readonly SaveConfiguration _config; + private readonly Dictionary> _migrations = new(); private readonly IStorage _rootStorage; /// @@ -43,6 +48,32 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository _rootStorage = new ScopedStorage(storage, config.SaveRoot); } + /// + /// 注册存档迁移器,使仓库在加载旧版本存档时自动执行升级。 + /// + /// 要注册的存档迁移器。 + /// 当前存档仓库实例,支持链式调用。 + /// + /// + /// 未实现 ,无法使用版本化迁移。 + /// + /// 迁移器的目标版本不大于源版本。 + public ISaveRepository RegisterMigration(ISaveMigration 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)); + } + + _migrations[migration.FromVersion] = migration; + return this; + } + /// /// 检查指定槽位是否存在存档 /// @@ -64,7 +95,10 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository var storage = GetSlotStorage(slot); if (await storage.ExistsAsync(_config.SaveFileName)) - return await storage.ReadAsync(_config.SaveFileName); + { + var loaded = await storage.ReadAsync(_config.SaveFileName); + return await MigrateIfNeededAsync(slot, storage, loaded); + } return new TSaveData(); } @@ -137,10 +171,114 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository return new ScopedStorage(_rootStorage, $"{_config.SaveSlotPrefix}{slot}"); } + /// + /// 在加载旧版本存档时按注册顺序执行迁移,并在成功后自动回写升级结果。 + /// + /// 当前加载的存档槽位。 + /// 对应槽位的存储对象。 + /// 原始加载出来的存档数据。 + /// 迁移后的最新存档;如果无需迁移则返回原始对象。 + /// + /// 当前运行时缺少必要的迁移链、读取到更高版本的存档,或迁移器返回了非法版本。 + /// + private async Task 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) + { + if (!_migrations.TryGetValue(currentVersion, out var migration)) + { + 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; + } + + /// + /// 验证当前存档类型支持基于版本号的迁移流程。 + /// + /// + /// 未实现 。 + /// + private static void EnsureVersionedSaveType() + { + if (!typeof(IVersionedData).IsAssignableFrom(typeof(TSaveData))) + { + throw new InvalidOperationException( + $"{typeof(TSaveData).Name} must implement {nameof(IVersionedData)} to use save migrations."); + } + } + /// /// 初始化逻辑 /// protected override void OnInit() { } -} \ No newline at end of file +} diff --git a/GFramework.csproj b/GFramework.csproj index b34d43f5..76f9a088 100644 --- a/GFramework.csproj +++ b/GFramework.csproj @@ -136,9 +136,4 @@ - - - - - diff --git a/docs/zh-CN/game/data.md b/docs/zh-CN/game/data.md index 607f7fb0..100e30eb 100644 --- a/docs/zh-CN/game/data.md +++ b/docs/zh-CN/game/data.md @@ -56,6 +56,7 @@ public interface IDataRepository : IUtility public interface ISaveRepository : IUtility where TSaveData : class, IData, new() { + ISaveRepository RegisterMigration(ISaveMigration migration); Task ExistsAsync(int slot); Task LoadAsync(int slot); Task SaveAsync(int slot, TSaveData data); @@ -64,6 +65,18 @@ public interface ISaveRepository : IUtility } ``` +`ISaveMigration` 定义单步迁移: + +```csharp +public interface ISaveMigration + 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` 当前负责槽位存档的读取、写入、删除和列举,并没有内建“注册迁移器后自动升级存档”的统一迁移管线。 +`SaveRepository` 现在支持注册正式的迁移器,并在 `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 { - 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 LoadWithMigration(int slot) +public sealed class SaveModule : AbstractModule { - var saveRepo = this.GetUtility>(); - 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(); + var saveConfig = new SaveConfiguration + { + SaveRoot = "saves", + SaveSlotPrefix = "slot_", + SaveFileName = "save" + }; - // 保存迁移后的数据 - await saveRepo.SaveAsync(slot, newData); - return newData; + var saveRepo = new SaveRepository(storage, saveConfig) + .RegisterMigration(new SaveDataMigrationV1ToV2()); + + architecture.RegisterUtility>(saveRepo); } +} - return data; +public async Task LoadGame(int slot) +{ + var saveRepo = this.GetUtility>(); + + // 如果槽位里是 v1,仓库会自动迁移到 v2,并把新版本重新写回存储。 + return await saveRepo.LoadAsync(slot); } ``` +`ISaveMigration` 接收和返回的是同一个存档类型。也就是说,框架提供的是“当前类型内的版本升级管线”, +而不是跨 CLR 类型的双模型反序列化系统。如果旧版本缺失了新字段,反序列化会先使用当前类型的默认值,再由迁移器补齐。 + ### 使用数据仓库 ```csharp @@ -514,15 +537,14 @@ await saveRepo.SaveAsync(3, saveData); // 槽位 3 ### 问题:如何处理数据版本升级? **解答**: -实现 `IVersionedData` 并在加载后检查版本。当前框架不会自动为 `ISaveRepository` 执行迁移,需要由业务层决定迁移规则与回写时机: +实现 `IVersionedData`,并在仓库初始化阶段注册 `ISaveMigration`。之后 `LoadAsync(slot)` 会自动执行迁移并回写: ```csharp +var saveRepo = new SaveRepository(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); -} ``` ### 问题:存档数据保存在哪里? diff --git a/docs/zh-CN/game/setting.md b/docs/zh-CN/game/setting.md index bbf16f2d..33a4313d 100644 --- a/docs/zh-CN/game/setting.md +++ b/docs/zh-CN/game/setting.md @@ -196,4 +196,4 @@ public interface ISettingsMigration - 设置迁移是内建能力 - 设置持久化是内建能力 - 设置如何应用到具体引擎由 applicator 决定 -- 存档系统的迁移能力不等同于设置系统;`ISaveRepository` 当前仍需要业务层自己实现迁移策略 +- 存档系统也支持内建版本迁移,但入口位于 `ISaveRepository.RegisterMigration(...)`,语义是槽位存档升级而不是设置节初始化 From 25e49658173afea51c7d8271e5d5a3f22850f822 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:33:35 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E5=AD=98?= =?UTF-8?q?=E6=A1=A3=E4=BB=93=E5=BA=93=E5=AE=9E=E7=8E=B0=E5=92=8C=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现基于槽位的存档仓库功能,支持存档的保存、加载、删除和列举操作 - 添加存档版本迁移机制,支持自动升级旧版本存档数据 - 实现文件存储和统一设置仓库的基础持久化功能 - 添加完整的单元测试覆盖存档仓库的各种使用场景 - 创建测试工具类和测试数据模型用于验证持久化行为 - 添加项目AI代理行为规范文档,确保代码质量和一致性 --- AGENTS.md | 8 ++ .../Data/PersistenceTestUtilities.cs | 79 +++++++++++++++++-- .../Data/PersistenceTests.cs | 67 ++++++++++++++++ GFramework.Game/Data/SaveRepository.cs | 27 ++++++- 4 files changed, 174 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d806c5a5..7f9fb3c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs index 440df286..00ab134a 100644 --- a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs +++ b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs @@ -4,31 +4,100 @@ using GFramework.Game.Abstractions.Enums; namespace GFramework.Game.Tests.Data; -internal sealed record TestDataLocation( - string Key, - StorageKinds Kinds = StorageKinds.Local, - string? Namespace = null, - IReadOnlyDictionary? Metadata = null) : IDataLocation; +/// +/// 为持久化测试提供稳定的测试数据位置实现。 +/// +internal sealed class TestDataLocation : IDataLocation +{ + /// + /// 初始化测试数据位置。 + /// + /// 测试使用的存储键。 + /// 测试使用的存储类型。 + /// 测试使用的命名空间。 + /// 附加测试元数据。 + public TestDataLocation( + string key, + StorageKinds kinds = StorageKinds.Local, + string? namespaceValue = null, + IReadOnlyDictionary? metadata = null) + { + Key = key; + Kinds = kinds; + Namespace = namespaceValue; + Metadata = metadata; + } + /// + /// 获取测试数据对应的存储键。 + /// + public string Key { get; } + + /// + /// 获取测试数据使用的存储类型。 + /// + public StorageKinds Kinds { get; } + + /// + /// 获取测试数据使用的命名空间。 + /// + public string? Namespace { get; } + + /// + /// 获取附加到测试位置上的元数据。 + /// + public IReadOnlyDictionary? Metadata { get; } +} + +/// +/// 为基础存档仓库测试提供的简单存档模型。 +/// internal sealed class TestSaveData : IData { + /// + /// 获取或设置测试存档中的名称字段。 + /// public string Name { get; set; } = string.Empty; } +/// +/// 为存档迁移测试提供的版本化存档模型。 +/// internal sealed class TestVersionedSaveData : IVersionedData { + /// + /// 获取或设置测试存档中的名称字段。 + /// public string Name { get; set; } = string.Empty; + /// + /// 获取或设置测试存档中的等级字段。 + /// public int Level { get; set; } + /// + /// 获取或设置测试存档中的经验字段。 + /// public int Experience { get; set; } + /// + /// 获取或设置当前测试存档的版本号。 + /// public int Version { get; set; } = 3; + /// + /// 获取或设置测试存档的最后修改时间。 + /// public DateTime LastModified { get; set; } = DateTime.UtcNow; } +/// +/// 为通用持久化测试提供的简单数据模型。 +/// internal sealed class TestSimpleData : IData { + /// + /// 获取或设置测试数据中的整数值。 + /// public int Value { get; set; } } diff --git a/GFramework.Game.Tests/Data/PersistenceTests.cs b/GFramework.Game.Tests/Data/PersistenceTests.cs index 68f64edb..ef2a5e85 100644 --- a/GFramework.Game.Tests/Data/PersistenceTests.cs +++ b/GFramework.Game.Tests/Data/PersistenceTests.cs @@ -6,6 +6,9 @@ using GFramework.Game.Storage; namespace GFramework.Game.Tests.Data; +/// +/// 覆盖文件存储、槽位存档仓库和统一设置仓库的持久化行为测试。 +/// [TestFixture] public class PersistenceTests { @@ -16,6 +19,10 @@ public class PersistenceTests return path; } + /// + /// 验证文件存储能够持久化数据,并拒绝包含路径逃逸的非法键。 + /// + /// 表示异步测试完成的任务。 [Test] public async Task FileStorage_PersistsDataAndRejectsIllegalKeys() { @@ -31,6 +38,10 @@ public class PersistenceTests Assert.ThrowsAsync(async () => await storage.WriteAsync("../escape", new TestSimpleData())); } + /// + /// 验证槽位存档仓库的保存、加载、列举和删除行为。 + /// + /// 表示异步测试完成的任务。 [Test] public async Task SaveRepository_ManagesSlots() { @@ -59,6 +70,10 @@ public class PersistenceTests Assert.That(await repository.ExistsAsync(1), Is.False); } + /// + /// 验证存档仓库在加载旧版本数据时会执行迁移链并回写升级后的最新版本。 + /// + /// 表示异步测试完成的任务。 [Test] public async Task SaveRepository_LoadAsync_Should_Apply_Migrations_And_Persist_Upgraded_Save() { @@ -98,6 +113,10 @@ public class PersistenceTests }); } + /// + /// 验证非版本化存档类型不能注册迁移器,避免构建无效迁移管线。 + /// + /// 当存档类型未实现 时抛出。 [Test] public void SaveRepository_RegisterMigration_For_NonVersioned_Save_Should_Throw() { @@ -109,6 +128,31 @@ public class PersistenceTests Assert.Throws(() => repository.RegisterMigration(new TestNonVersionedMigration())); } + /// + /// 验证同一源版本不能重复注册迁移器,避免迁移链配置被静默覆盖。 + /// + /// 当同一源版本重复注册迁移器时抛出。 + [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(storage, config); + + repository.RegisterMigration(new TestSaveMigrationV1ToV2()); + + var exception = Assert.Throws( + () => repository.RegisterMigration(new TestDuplicateSaveMigrationV1ToV2())); + + Assert.That(exception!.Message, Does.Contain("Duplicate save migration registration")); + } + + /// + /// 验证当迁移链缺少中间版本时,加载旧存档会明确失败而不是静默返回不完整数据。 + /// + /// 表示异步测试完成的任务。 + /// 当从旧版本到当前版本的迁移链不完整时抛出。 [Test] public async Task SaveRepository_LoadAsync_Should_Throw_When_Migration_Chain_Is_Incomplete() { @@ -137,6 +181,10 @@ public class PersistenceTests Assert.That(exception!.Message, Does.Contain("from version 2")); } + /// + /// 验证统一设置仓库能够保存、重新加载并批量读取已注册的设置数据。 + /// + /// 表示异步测试完成的任务。 [Test] public async Task UnifiedSettingsDataRepository_RoundTripsDataAndLoadAll() { @@ -209,6 +257,25 @@ public class PersistenceTests } } + private sealed class TestDuplicateSaveMigrationV1ToV2 : ISaveMigration + { + 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 { public int FromVersion => 1; diff --git a/GFramework.Game/Data/SaveRepository.cs b/GFramework.Game/Data/SaveRepository.cs index b916c79b..e0d440d4 100644 --- a/GFramework.Game/Data/SaveRepository.cs +++ b/GFramework.Game/Data/SaveRepository.cs @@ -32,6 +32,7 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository { private readonly SaveConfiguration _config; private readonly Dictionary> _migrations = new(); + private readonly object _migrationsLock = new(); private readonly IStorage _rootStorage; /// @@ -56,8 +57,13 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository /// /// /// 未实现 ,无法使用版本化迁移。 + /// 或者同一个源版本已经注册过迁移器,导致迁移链配置存在歧义。 /// /// 迁移器的目标版本不大于源版本。 + /// + /// 迁移注册表是可变共享状态。注册与加载可以并发发生,因此所有访问都通过 + /// 串行化,避免读写竞争和“部分可见”的迁移链。 + /// public ISaveRepository RegisterMigration(ISaveMigration migration) { ArgumentNullException.ThrowIfNull(migration); @@ -70,7 +76,17 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository nameof(migration)); } - _migrations[migration.FromVersion] = 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; } @@ -214,9 +230,16 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository var migrated = data; // 迁移链按“当前版本 -> 下一个已注册迁移器”推进;任何缺口都表示运行时无法安全解释旧存档。 + // 读取迁移表时使用同一把锁,保证并发注册不会让加载线程看到不一致的链路状态。 while (currentVersion < targetVersion) { - if (!_migrations.TryGetValue(currentVersion, out var migration)) + ISaveMigration? 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}.");