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] =?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(...)`,语义是槽位存档升级而不是设置节初始化