feat(packages): 内置 SDK 包并完善 ContextLoader 集成

- 将 ShrinkEventBus、ShrinkDataSaver 及其 EventBus 集成从 gitlink 转为仓库直接维护的完整 UPM 包,补齐运行时、编辑器工具、测试与文档
- 新增 Command 和 Network 的 App 集成组件,支持 ContextLoader 服务发布、可逆注销及 Network Loopback 生命周期管理
- 更新 Starter 与演示组合逻辑,缺失模块时可注册、已有兼容安装器时可覆盖,并补充宿主启动断言
- 升级内部包依赖与 Shared CodeGen 包定义,放宽 Integration.App 包的 Git 忽略规则
- 将独立服务器生成器改为基于已编译程序集的语义扫描,支持 partial、复杂泛型、命名冲突检测及模板 SHA-256 覆写保护
- 新增 Network 语义扫描、模板保护和 App 组件生命周期测试
- 新增真实 UPM 消费工程验证脚本,校验内部版本一致性、程序集加载及 EditMode 测试
- 重构当前架构文档并归档已完成的 Cordis 迁移与旧代码地图
This commit is contained in:
2026-08-18 18:06:34 +08:00
parent 517c4cf46e
commit d74c2f08ca
240 changed files with 13647 additions and 545 deletions
+4 -4
View File
@@ -74,10 +74,10 @@ mono_crash.*
*.unitypackage.meta
*.app
# Windows Git matches the macOS bundle pattern case-insensitively. Keep the
# namespace-suffixed Unity package in source control.
!Assets/Modules/ShrinkDataSaver.Integration.App/
!Assets/Modules/ShrinkDataSaver.Integration.App/**
# Windows Git matches the macOS bundle pattern case-insensitively. Keep all
# namespace-suffixed Unity Integration.App packages in source control.
!Assets/Modules/*.Integration.App/
!Assets/Modules/*.Integration.App/**
# Crashlytics generated file
crashlytics-build.properties
@@ -56,8 +56,19 @@ namespace Demo2.Runtime
ShrinkAppLoaderBootstrapper.DefaultComposition = host =>
{
previous?.Invoke(host);
if (host.ModuleIds.Contains("demo2.rule-factory")) host.OverrideModuleComponent("demo2.rule-factory", static () => new Demo2AppComponent());
RegisterOrReplace(host, "demo2.rule-factory", static () => new Demo2AppComponent());
};
}
private static void RegisterOrReplace(
ShrinkAppLoaderHost host,
string moduleId,
System.Func<IShrinkComponent> componentFactory)
{
if (host.ModuleIds.Contains(moduleId, System.StringComparer.OrdinalIgnoreCase))
host.OverrideModuleComponent(moduleId, componentFactory);
else
host.AddModuleComponent(moduleId, componentFactory);
}
}
}
@@ -5,6 +5,8 @@
"Demo2.Domain",
"Demo2.ECS",
"Demo2.Runtime",
"ShrinkApp.Core.Runtime",
"ShrinkContext.AppAdapter.Runtime",
"Unity.Entities",
"Unity.Collections"
],
@@ -6,6 +6,7 @@ using Demo2.Domain;
using Demo2.ECS;
using Demo2.Runtime;
using NUnit.Framework;
using ShrinkContext.AppAdapter;
using Unity.Entities;
using UnityEngine;
using UnityEngine.SceneManagement;
@@ -23,6 +24,16 @@ namespace Demo2.Tests
yield return null;
var controller = Object.FindObjectOfType<Demo2DemoController>();
Assert.That(controller, Is.Not.Null);
for (var frame = 0; frame < 30 &&
(ShrinkAppLoaderBootstrapper.Instance == null || !ShrinkAppLoaderBootstrapper.Instance.Host.IsRunning);
frame++)
yield return null;
var appHost = ShrinkAppLoaderBootstrapper.Instance;
Assert.That(appHost, Is.Not.Null);
Assert.That(appHost!.Host.IsRunning, Is.True);
Assert.That(appHost.Host.ActiveModuleIds, Does.Contain("demo2.rule-factory"));
Assert.That(appHost.Host.Services.TryGet<Demo2GameService>(out var appService), Is.True);
Assert.That(appService, Is.Not.Null);
var document = controller!.GetComponentInChildren<UIDocument>();
Assert.That(document, Is.Not.Null);
var solo = document!.rootVisualElement.Q<Button>("solo-button");
@@ -67,9 +67,19 @@ namespace ReplacedPerson.Runtime
ShrinkAppLoaderBootstrapper.DefaultComposition = host =>
{
previous?.Invoke(host);
if (host.ModuleIds.Contains("demo.replaced-person"))
host.OverrideModuleComponent("demo.replaced-person", static () => new ReplacedPersonAppComponent());
RegisterOrReplace(host, "demo.replaced-person", static () => new ReplacedPersonAppComponent());
};
}
private static void RegisterOrReplace(
ShrinkAppLoaderHost host,
string moduleId,
Func<IShrinkComponent> componentFactory)
{
if (host.ModuleIds.Contains(moduleId, StringComparer.OrdinalIgnoreCase))
host.OverrideModuleComponent(moduleId, componentFactory);
else
host.AddModuleComponent(moduleId, componentFactory);
}
}
}
@@ -1,7 +1,7 @@
{
"name": "ReplacedPerson.PlayMode.Tests",
"rootNamespace": "ReplacedPerson.Tests",
"references": ["ReplacedPerson.Core", "ReplacedPerson.Runtime", "UnityEngine.UI", "Unity.TextMeshPro"],
"references": ["ReplacedPerson.Core", "ReplacedPerson.Runtime", "ShrinkApp.Core.Runtime", "ShrinkContext.AppAdapter.Runtime", "UnityEngine.UI", "Unity.TextMeshPro"],
"optionalUnityReferences": ["TestAssemblies"],
"includePlatforms": [],
"excludePlatforms": [],
@@ -4,6 +4,7 @@ using System.Collections;
using System.Linq;
using NUnit.Framework;
using ReplacedPerson.Runtime;
using ShrinkContext.AppAdapter;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
@@ -22,6 +23,16 @@ namespace ReplacedPerson.Tests
yield return null;
var root = GameObject.Find("ReplacedPersonDemo");
Assert.That(root, Is.Not.Null);
for (var frame = 0; frame < 30 &&
(ShrinkAppLoaderBootstrapper.Instance == null || !ShrinkAppLoaderBootstrapper.Instance.Host.IsRunning);
frame++)
yield return null;
var appHost = ShrinkAppLoaderBootstrapper.Instance;
Assert.That(appHost, Is.Not.Null);
Assert.That(appHost!.Host.IsRunning, Is.True);
Assert.That(appHost.Host.ActiveModuleIds, Does.Contain("demo.replaced-person"));
Assert.That(appHost.Host.Services.TryGet<ReplacedPersonGameService>(out var appService), Is.True);
Assert.That(appService, Is.Not.Null);
var camera = Camera.main;
Assert.That(camera, Is.Not.Null);
Assert.That(camera!.enabled, Is.True);
+2 -1
View File
@@ -5,7 +5,8 @@
"description": "Shrink 系列统一宿主层,提供模块安装器、服务容器与统一启动流程。",
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-eventbus": "1.1.5",
"com.cneicy.shrink-eventbus": "1.3.0",
"com.cneicy.shrink-shared-codegen": "0.1.0",
"com.cysharp.unitask": "2.5.10"
},
"keywords": [
@@ -1,6 +1,7 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using ShrinkCommand.Integration.App;
using ShrinkCommand.Integration;
using ShrinkContext.AppAdapter;
@@ -42,9 +43,9 @@ namespace ShrinkApp.Starter.Basic
if (host == null)
throw new System.ArgumentNullException(nameof(host));
host.OverrideModuleComponent("shrink.network", static () => new ShrinkNetworkAppComponent());
host.OverrideModuleComponent("shrink.command", static () => new ShrinkCommandAppComponent());
host.OverrideModuleComponent("shrink.datasaver", static () => new ShrinkDataSaverAppComponent());
RegisterOrReplace(host, "shrink.network", static () => new ShrinkNetworkAppComponent());
RegisterOrReplace(host, "shrink.command", static () => new ShrinkCommandAppComponent());
RegisterOrReplace(host, "shrink.datasaver", static () => new ShrinkDataSaverAppComponent());
host.AddModuleComponent("shrink.integration.command-network",
static () => new ShrinkCommandNetworkIntegrationComponent());
host.AddModuleComponent("shrink.integration.command-eventbus",
@@ -55,6 +56,17 @@ namespace ShrinkApp.Starter.Basic
static () => new ShrinkNetworkEventBusComponent());
}
private static void RegisterOrReplace(
ShrinkAppLoaderHost host,
string moduleId,
System.Func<ShrinkContext.IShrinkComponent> componentFactory)
{
if (host.ModuleIds.Any(id => string.Equals(id, moduleId, System.StringComparison.OrdinalIgnoreCase)))
host.OverrideModuleComponent(moduleId, componentFactory);
else
host.AddModuleComponent(moduleId, componentFactory);
}
public static ShrinkAppCompositionDocument CreateDefaultDocument()
{
var entries = new List<ShrinkAppCompositionEntry>(DefaultModuleIdValues.Length);
@@ -5,7 +5,7 @@
"description": "ShrinkApp 最小起盘 Starter,提供向导、入口场景和 DataSaver 示例。",
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-app-core": "0.1.0",
"com.cneicy.shrink-app-core": "0.1.1",
"com.cneicy.shrink-context-core": "0.1.0",
"com.cneicy.shrink-context-app-adapter": "0.1.0",
"com.cneicy.shrink-command": "0.2.0",
@@ -0,0 +1,11 @@
# Changelog
本文件记录 `ShrinkCommand.Integration.App` 的包内变更。
## [0.1.0] - 2026-05-18
### Added
- 新增 `ShrinkCommand``ShrinkApp.Core` 的桥接包。
- 新增 `ShrinkCommandAppInstaller`,把默认命令服务挂入宿主服务容器。
- 新增 `ShrinkCommandAppService` 作为宿主侧命令门面。
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1754b3bc0377768409187131b594bda6
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
# ShrinkCommand.Integration.App
`ShrinkCommand.Integration.App``ShrinkCommand``ShrinkApp.Core` 的桥接层。它的职责很简单:把默认命令服务挂进 `ShrinkApp` 的服务容器里,让项目在进入统一宿主模式后,仍能用熟悉的命令系统,而不需要到处手动拿 `ShrinkCommandRuntime.Default`
## 当前能力
- 提供原生 `ShrinkCommandAppComponent`,发布 `shrink.service.command`
-`ShrinkCommandRuntime.Default` 包装为 `ShrinkCommandAppService`
- ContextLoader 下由 Starter 组合根装配,服务门面随组件停用可逆注销
- `ShrinkCommandAppInstaller` 仅保留给 ClassicHost 兼容路径,已标记过时
## 使用示例
```csharp
var command = ShrinkApp.ShrinkApp.Services.GetRequired<ShrinkCommand.Integration.App.ShrinkCommandAppService>();
var result = await command.ExecuteAsync(source, "help");
```
## 说明
- 本包不改变 `ShrinkCommand` 的原有 API。
- 静态命令的默认注册仍由 `ShrinkCommandRuntime.Default` 完成。
- 运行时实例命令仍然走 `RegisterCommands(object target)`
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: da6f74ed70ed86e48bc9309983a690b9
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 79640806d61572d44b10ae9bbb0ace79
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
{
"name": "ShrinkCommand.Integration.App",
"rootNamespace": "ShrinkCommand.Integration.App",
"references": [
"ShrinkCommand.Runtime",
"ShrinkApp.Core.Runtime",
"ShrinkContext.Core.Runtime",
"UniTask"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: e5805c0a2f81e6f47818b1dac7d15e6c
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
#nullable enable
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using ShrinkApp;
using ShrinkContext;
using ShrinkCommand;
namespace ShrinkCommand.Integration.App
{
/// <summary>
/// ShrinkCommand 的原生 Cordis 组件(阶段 3):
/// 提供 <c>shrink.service.command</c> 余效应键(默认命令服务),依赖者按键注入;
/// 与经典 ShrinkCommandAppInstallerModuleId "shrink.command")二选一使用(同键供给冲突保护)。
/// </summary>
public sealed class ShrinkCommandAppComponent : IShrinkComponent
{
public const string ServiceKey = "shrink.service.command";
public const string ModuleKey = "app.module.shrink.command";
private static readonly string[] ProvideKeys = { ModuleKey, ServiceKey };
public string Name => "shrink.command";
public IReadOnlyList<string> Inject => Array.Empty<string>();
public IReadOnlyList<string> Provide => ProvideKeys;
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
{
var service = ShrinkCommandRuntime.Default;
ctx.Set(ServiceKey, service);
ctx.Set(ModuleKey, Name);
if (config is ShrinkAppServices appServices)
{
var facade = new ShrinkCommandAppService(service);
appServices.Register(facade);
ctx.EffectInverse(() =>
{
appServices.TryUnregister(facade);
return UniTask.CompletedTask;
});
}
return UniTask.CompletedTask;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 507e23e02986281488456a63dc8ffdbe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,48 @@
#nullable enable
using System;
using Cysharp.Threading.Tasks;
using ShrinkApp;
namespace ShrinkCommand.Integration.App
{
[ShrinkAppModuleInstaller]
[Obsolete("ClassicHost compatibility only. ContextLoader projects should use ShrinkCommandAppComponent from a composition root.")]
public sealed class ShrinkCommandAppInstaller : IShrinkAppModuleInstaller
{
public string ModuleId => "shrink.command";
public int Order => -1500;
public System.Collections.Generic.IReadOnlyList<string> DependsOn => Array.Empty<string>();
public void RegisterServices(ShrinkAppContext context)
{
context.Services.Register(new ShrinkCommandAppService(ShrinkCommandRuntime.Default));
}
public UniTask InitializeAsync(ShrinkAppContext context)
{
return UniTask.CompletedTask;
}
}
public sealed class ShrinkCommandAppService
{
public ShrinkCommandAppService(ShrinkCommandService service)
{
Service = service ?? throw new ArgumentNullException(nameof(service));
}
public ShrinkCommandService Service { get; }
public UniTask<ShrinkCommandExecutionResult> ExecuteAsync(
IShrinkCommandSource source,
string rawInput,
System.Threading.CancellationToken cancellationToken = default)
=> Service.ExecuteAsync(source, rawInput, cancellationToken);
public string BuildHelp(IShrinkCommandSource? source, string? prefix = null)
=> Service.BuildHelp(source, prefix);
public System.Collections.Generic.IReadOnlyList<ShrinkCommandDescriptor> Commands => Service.Commands;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a71473a2ecd04c3459903841fee6da7d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
{
"name": "com.cneicy.shrink-command-integration-app",
"version": "0.1.0",
"displayName": "ShrinkCommand - App Integration",
"description": "ShrinkCommand 与 ShrinkApp 的桥接层,把默认命令服务纳入统一宿主服务容器。",
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-command": "0.2.0",
"com.cneicy.shrink-app-core": "0.1.1",
"com.cysharp.unitask": "2.5.10",
"com.cneicy.shrink-context-core": "0.1.0"
},
"keywords": [
"command",
"integration",
"app",
"console"
],
"author": {
"name": "cneicy",
"url": "https://github.com/cneicy"
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 126abcc047b44714593c2f94aa587c75
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -5,9 +5,9 @@
"description": "ShrinkCommand 与 ShrinkEventBus 的桥接层,支持事件请求执行命令,以及命令执行生命周期事件发布。",
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-command": "0.1.0",
"com.cneicy.shrink-command": "0.2.0",
"com.cneicy.shrink-context-core": "0.1.0",
"com.cneicy.shrink-eventbus": "1.1.5"
"com.cneicy.shrink-eventbus": "1.3.0"
},
"keywords": ["command", "eventbus", "integration", "bridge"],
"author": {
@@ -5,8 +5,8 @@
"description": "ShrinkCommand 与 ShrinkNetwork 的桥接层,允许远程会话通过 RPC 执行命令。",
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-command": "0.1.0",
"com.cneicy.shrink-network": "0.1.0",
"com.cneicy.shrink-command": "0.2.0",
"com.cneicy.shrink-network": "0.2.0",
"com.cneicy.shrink-context-core": "0.1.0"
},
"keywords": [
@@ -19,4 +19,4 @@
"name": "cneicy",
"url": "https://github.com/cneicy"
}
}
}
@@ -5,6 +5,7 @@
"description": "独立于网络层的 Unity 命令系统,提供路径式命令、权限、帮助输出与属性式注册。",
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-shared-codegen": "0.1.0",
"com.cysharp.unitask": "2.5.10"
},
"keywords": [
@@ -1,6 +1,6 @@
# ShrinkContext.AppAdapter
ShrinkApp ↔ ShrinkContext 的桥接层:把现有 `IShrinkAppModuleInstaller` 生态接入 Cordis 时空可组合性范式(阶段 2 过渡产物,方案见仓库根 `CORDIS_MIGRATION.md`
ShrinkApp ↔ ShrinkContext 的桥接层:提供当前默认的 ContextLoader 宿主、声明式组合 Profile、诊断工具,并保留 `IShrinkAppModuleInstaller` 兼容适配。当前架构与边界见仓库根 `DESIGN.md`
## 组成
@@ -62,11 +62,11 @@ Profile 的 JSON 结构如下:
| 依赖缺失 | 排序期抛错 | 安装器保持 Waiting,依赖出现自动激活 |
| 重复 ModuleId | 宿主级异常 | 构造期抛错(一致);运行期替换退化为供给冲突失败 |
| 运行中禁用模块 | 不支持(需重启) | `SetModuleDisabledAsync` 增量协调,重启用会重新执行安装器初始化 |
| `ShrinkApp.IsRunning` | Host 驱动 | `ShrinkAppLoaderHost.IsRunning` 为准(静态 IsRunning 为 false,过渡期已知差异) |
| `ShrinkApp.IsRunning` | Host 驱动 | LoaderHost 通过 `SetExternalHostRunning` 同步静态门面状态 |
## 已知边界
- `ShrinkAppServices` 无注销能力:安装器停用不撤回已注册服务(重新启用会重跑 RegisterServices,同键覆盖)
- `ShrinkAppServices.TryUnregister(instance)` 支持按实例撤回服务;原生 Context 组件已使用该路径。旧 installer 包装器仍取决于安装器自身是否提供完整逆操作
- config/isolate 变化走条目重建;intercept metadata 可原位更新而不改变 fiber generation。
- 运行中不支持重新应用整份 Profile;资产修改在下一次宿主启动生效,运行中模块开关仍使用 `SetModuleDisabledAsync`
- Editor 基准的毫秒数和 GC 管理堆差值只用于同机前后对比;自动测试只断言索引候选规模和事务恢复结果。
@@ -14,8 +14,8 @@ namespace ShrinkContext.AppAdapter
/// - provide 发布模块键 <c>app.module.&lt;ModuleId&gt;</c>(安装器 Active 后对依赖者可见);
/// - apply 依次执行 RegisterServices + InitializeAsyncconfig 必须传入 <see cref="ShrinkAppServices"/>。
///
/// 已知边界(后续阶段补齐):ShrinkAppServices 尚无注销能力,
/// 安装器卸载时不会移除已注册的服务;重新激活会重复执行 RegisterServices(同键覆盖)
/// 兼容安装器只负责执行既有 RegisterServices + InitializeAsync。需要停用时撤回服务的模块应使用
/// 原生 Context 组件,并通过 ShrinkAppServices.TryUnregister(instance) 登记对应逆操作
/// </summary>
public sealed class ShrinkAppInstallerComponent : IShrinkComponent
{
@@ -17,7 +17,7 @@ namespace ShrinkContext.AppAdapter
/// <summary>
/// Starter/组合包在 SubsystemRegistration 阶段设置的默认装配表。
/// AppAdapter 保持不依赖具体业务模块,组合根负责原生组件挂到已发现的模块 id。
/// AppAdapter 保持不依赖具体业务模块,组合根负责注册原生组件;若兼容 installer 已被发现则覆盖同 id。
/// </summary>
public static System.Action<ShrinkAppLoaderHost>? DefaultComposition { get; set; }
@@ -6,7 +6,7 @@
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-context-core": "0.1.0",
"com.cneicy.shrink-app-core": "0.1.0",
"com.cneicy.shrink-app-core": "0.1.1",
"com.cysharp.unitask": "2.5.10"
},
"keywords": [
@@ -20,4 +20,4 @@
"name": "cneicy",
"url": "https://github.com/cneicy"
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
# ShrinkContext.Core
《Cordis: A Programming Paradigm for Spatiotemporal Composability》核心机制的 Unity/C# 实现(改造方案与当前边界见仓库根 `CORDIS_MIGRATION.md`
《Cordis: A Programming Paradigm for Spatiotemporal Composability》核心机制的 Unity/C# 实现。当前架构与边界见仓库根 `DESIGN.md`;已完成的迁移过程归档于 `Docs/Archive/CORDIS_MIGRATION.completed.md`
## 定位
@@ -61,7 +61,7 @@
- 强类型版本键是新增契约,现有字符串键不会在阶段 5 被一次性重写。
- Core 保持不依赖具体配置资产;`ShrinkContext.AppAdapter` 提供 ScriptableObject/JSON 组合、Editor 诊断窗和可重复容量基准。
- 主线程宿主与 Unity PlayerLoop 调度仍由上层应用负责。
- 跨进程/独立服务器上下文
- 当前上下文只协调单进程内组件;跨进程/独立服务器使用 Network 合同,不共享 fiber 或事务。
## 约定
Submodule Assets/Modules/ShrinkDataSaver deleted from 524a1fd11e
@@ -6,7 +6,7 @@
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-datasaver": "2.2.0",
"com.cneicy.shrink-app-core": "0.1.0",
"com.cneicy.shrink-app-core": "0.1.1",
"com.cysharp.unitask": "2.5.10",
"com.cneicy.shrink-context-core": "0.1.0"
},
@@ -20,4 +20,4 @@
"name": "cneicy",
"url": "https://github.com/cneicy"
}
}
}
Submodule Assets/Modules/ShrinkDataSaver.Integration.EventBus deleted from 6dbee4a992
@@ -0,0 +1,15 @@
# Changelog
本文件记录 `ShrinkDataSaver.Integration.EventBus` 这个独立 UPM 包的变更。
## [2.1.0] - 2026-05-18
### Changed
-`ShrinkDataSaverRuntime` / `ShrinkApp` 接管初始化链路兼容,保留零配置桥接方式不变。
## [2.0.1] - 2026-04-06
### Changed
- 添加了变更日志。
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 916d7f3a5759d4941823219888267177
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,107 @@
#nullable enable
using ShrinkEventBus;
using UnityEngine;
namespace ShrinkDataSaver.Integration
{
/// <summary>
/// DataSaver 原生事件到 EventBus 的显式桥接器。
/// 生命周期由 ShrinkDataSaverEventBusComponent 管理,不再因程序集存在而全局自动接入。
/// </summary>
public static class DataSaverEventBusBridge
{
private static bool _registered;
public static bool IsRegistered => _registered;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStaticState()
{
Unregister();
_registered = false;
}
public static void Register()
{
if (_registered)
return;
ShrinkSettings.OnChanged += HandleSettingsChanged;
ShrinkSave.OnSaveStarted += HandleSaveStarted;
ShrinkSave.OnSaveCompleted += HandleSaveCompleted;
ShrinkSave.OnSaveFailed += HandleSaveFailed;
ShrinkSave.OnLoadStarted += HandleLoadStarted;
ShrinkSave.OnLoadCompleted += HandleLoadCompleted;
ShrinkSave.OnLoadFailed += HandleLoadFailed;
ShrinkSave.OnMigrationCompleted += HandleMigrationCompleted;
ShrinkSave.OnDeleteCompleted += HandleSlotDeleted;
_registered = true;
}
public static void Unregister()
{
ShrinkSettings.OnChanged -= HandleSettingsChanged;
ShrinkSave.OnSaveStarted -= HandleSaveStarted;
ShrinkSave.OnSaveCompleted -= HandleSaveCompleted;
ShrinkSave.OnSaveFailed -= HandleSaveFailed;
ShrinkSave.OnLoadStarted -= HandleLoadStarted;
ShrinkSave.OnLoadCompleted -= HandleLoadCompleted;
ShrinkSave.OnLoadFailed -= HandleLoadFailed;
ShrinkSave.OnMigrationCompleted -= HandleMigrationCompleted;
ShrinkSave.OnDeleteCompleted -= HandleSlotDeleted;
_registered = false;
}
private static void HandleSettingsChanged(string key, object value) =>
EventBus.TriggerEvent(new SettingsChangedEvent { Key = key, Value = value });
private static void HandleSaveStarted(SaveStartedEventArgs args) =>
EventBus.TriggerEvent(new SaveStartedEvent { SlotIndex = args.SlotIndex, Timestamp = args.Timestamp });
private static void HandleSaveCompleted(SaveCompletedEventArgs args) =>
EventBus.TriggerEvent(new SaveCompletedEvent
{
SlotIndex = args.SlotIndex,
ModuleNames = args.ModuleNames,
Timestamp = args.Timestamp
});
private static void HandleSaveFailed(SaveFailedEventArgs args) =>
EventBus.TriggerEvent(new SaveFailedEvent
{
SlotIndex = args.SlotIndex,
ErrorMessage = args.ErrorMessage
});
private static void HandleLoadStarted(LoadStartedEventArgs args) =>
EventBus.TriggerEvent(new LoadStartedEvent { SlotIndex = args.SlotIndex, Timestamp = args.Timestamp });
private static void HandleLoadCompleted(LoadCompletedEventArgs args) =>
EventBus.TriggerEvent(new LoadCompletedEvent
{
SlotIndex = args.SlotIndex,
ModuleNames = args.ModuleNames,
Version = args.Version,
Timestamp = args.Timestamp
});
private static void HandleLoadFailed(LoadFailedEventArgs args) =>
EventBus.TriggerEvent(new LoadFailedEvent
{
SlotIndex = args.SlotIndex,
ErrorMessage = args.ErrorMessage
});
private static void HandleMigrationCompleted(MigrationCompletedEventArgs args) =>
EventBus.TriggerEvent(new MigrationCompletedEvent
{
SlotIndex = args.SlotIndex,
FromVersion = args.FromVersion,
ToVersion = args.ToVersion
});
private static void HandleSlotDeleted(SlotDeletedEventArgs args) =>
EventBus.TriggerEvent(new SlotDeletedEvent { SlotIndex = args.SlotIndex });
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d41cba0389b740dc80169c21db9eb259
timeCreated: 1773053776
@@ -0,0 +1,63 @@
using System;
using ShrinkEventBus;
namespace ShrinkDataSaver.Integration
{
public class SettingsChangedEvent : EventBase
{
public string Key { get; internal set; }
public object Value { get; internal set; }
public T Get<T>() => (T)Convert.ChangeType(Value, typeof(T));
}
public class SaveStartedEvent : EventBase
{
public int SlotIndex { get; internal set; }
public long Timestamp { get; internal set; }
}
public class SaveCompletedEvent : EventBase
{
public int SlotIndex { get; internal set; }
public string[] ModuleNames { get; internal set; }
public long Timestamp { get; internal set; }
}
public class SaveFailedEvent : EventBase
{
public int SlotIndex { get; internal set; }
public string ErrorMessage { get; internal set; }
}
public class LoadStartedEvent : EventBase
{
public int SlotIndex { get; internal set; }
public long Timestamp { get; internal set; }
}
public class LoadCompletedEvent : EventBase
{
public int SlotIndex { get; internal set; }
public string[] ModuleNames { get; internal set; }
public int Version { get; internal set; }
public long Timestamp { get; internal set; }
}
public class LoadFailedEvent : EventBase
{
public int SlotIndex { get; internal set; }
public string ErrorMessage { get; internal set; }
}
public class MigrationCompletedEvent : EventBase
{
public int SlotIndex { get; internal set; }
public int FromVersion { get; internal set; }
public int ToVersion { get; internal set; }
}
public class SlotDeletedEvent : EventBase
{
public int SlotIndex { get; internal set; }
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 154ed134a6cb40c4be44560f08362a2a
timeCreated: 1773053752
@@ -0,0 +1,155 @@
# ShrinkDataSaver.Integration.EventBus
[ShrinkDataSaver](https://github.com/cneicy/ShrinkDataSaver) 与 [ShrinkEventBus](https://github.com/cneicy/ShrinkEventBus) 的桥接层。
ContextLoader 下由 `ShrinkDataSaverEventBusComponent` 注入 `shrink.service.datasaver` 后接桥,依赖撤回时拆桥;独立使用时调用 `DataSaverEventBusBridge.Register()` / `Unregister()`。仅安装程序集不会再产生全局副作用。
## ✨ 特性概览
| 特性 | 说明 |
|------|------|
| 🌉 **可逆桥接** | 由 Cordis 组件或显式 API 管理注册与注销 |
| 📡 **完整事件覆盖** | 9 种事件类型,覆盖设置变更 + 存档保存/加载/删除/迁移全生命周期 |
| 🔒 **类型安全** | 所有事件继承 `EventBase`,支持 `[EventBusSubscriber]` 自动注册 |
| 📦 **丰富载荷** | 事件携带 SlotIndex、ModuleNames、Version、Timestamp、ErrorMessage 等完整数据 |
## 📦 依赖
- [ShrinkDataSaver](https://github.com/cneicy/ShrinkDataSaver) `2.0.0+`
- [ShrinkEventBus](https://github.com/cneicy/ShrinkEventBus) `1.0.0+`
## ⚙️ 安装
在项目的 `Packages/manifest.json` 中添加:
```json
{
"dependencies": {
"com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask",
"com.cneicy.shrink-datasaver": "https://github.com/cneicy/ShrinkDataSaver.git",
"com.cneicy.shrink-datasaver-integration-eventbus": "https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus.git"
}
}
```
或通过 Package Manager → `+``Add package from git URL` 输入:
```
https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus.git
```
## 📡 事件类型
| 事件类 | 对应原生事件 | 载荷字段 |
|--------|-------------|----------|
| `SettingsChangedEvent` | `ShrinkSettings.OnChanged` | Key, Value |
| `SaveStartedEvent` | `ShrinkSave.OnSaveStarted` | SlotIndex, Timestamp |
| `SaveCompletedEvent` | `ShrinkSave.OnSaveCompleted` | SlotIndex, ModuleNames[], Timestamp |
| `SaveFailedEvent` | `ShrinkSave.OnSaveFailed` | SlotIndex, ErrorMessage |
| `LoadStartedEvent` | `ShrinkSave.OnLoadStarted` | SlotIndex, Timestamp |
| `LoadCompletedEvent` | `ShrinkSave.OnLoadCompleted` | SlotIndex, ModuleNames[], Version, Timestamp |
| `LoadFailedEvent` | `ShrinkSave.OnLoadFailed` | SlotIndex, ErrorMessage |
| `MigrationCompletedEvent` | `ShrinkSave.OnMigrationCompleted` | SlotIndex, FromVersion, ToVersion |
| `SlotDeletedEvent` | `ShrinkSave.OnDeleteCompleted` | SlotIndex |
## 🚀 使用示例
### 使用 `[EventBusSubscriber]` 自动注册(推荐)
```csharp
[EventBusSubscriber]
public class SaveUIManager : MonoBehaviour
{
[EventSubscribe(EventPriority.NORMAL)]
private void OnSaveCompleted(SaveCompletedEvent e)
{
Debug.Log($"槽位 {e.SlotIndex} 保存成功,模块: {string.Join(", ", e.ModuleNames)}");
ShowSaveIndicator();
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnLoadCompleted(LoadCompletedEvent e)
{
Debug.Log($"槽位 {e.SlotIndex} 加载完成 (v{e.Version})");
TransitionToGame();
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnSaveFailed(SaveFailedEvent e)
{
ShowErrorDialog($"保存失败: {e.ErrorMessage}");
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnLoadFailed(LoadFailedEvent e)
{
ShowErrorDialog($"加载失败: {e.ErrorMessage}");
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnMigration(MigrationCompletedEvent e)
{
Debug.Log($"存档已从 v{e.FromVersion} 迁移到 v{e.ToVersion}");
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnSettingsChanged(SettingsChangedEvent e)
{
if (e.Key == "MasterVolume")
ApplyVolume(e.Get<float>());
}
}
```
### 手动注册
```csharp
public class AnalyticsTracker : IDisposable
{
public AnalyticsTracker()
{
EventBus.RegisterEvent<SaveCompletedEvent>(OnSave, EventPriority.LOWEST);
EventBus.RegisterEvent<LoadCompletedEvent>(OnLoad, EventPriority.LOWEST);
}
private void OnSave(SaveCompletedEvent e)
=> Analytics.Track("save", new { slot = e.SlotIndex, modules = e.ModuleNames.Length });
private void OnLoad(LoadCompletedEvent e)
=> Analytics.Track("load", new { slot = e.SlotIndex, version = e.Version });
public void Dispose()
=> EventBus.UnregisterAllEventsForObject(this);
}
```
## 🏗️ 架构
```
ShrinkDataSaver.Integration.EventBus/
├── DataSaverEvents.cs 9 个 EventBase 子类(事件定义)
├── DataSaverEventBusBridge.cs 显式、可注销的静态桥接器
├── ShrinkDataSaverEventBusComponent.cs 按服务键管理桥生命周期
└── ShrinkDataSaver.Integration.EventBus.asmdef
```
**桥接原理:**
```
ShrinkDataSaver 原生事件 (Action<XxxEventArgs>)
└─ ShrinkDataSaverEventBusComponent / 显式 Register
└─ EventBus.TriggerEvent(new XxxEvent { ... })
└─ ShrinkEventBus 分发到所有订阅者
```
桥接器是单向的:DataSaver → EventBus。业务代码只需订阅 EventBus 事件,无需直接引用 ShrinkDataSaver 的原生事件。
## ⚠️ 注意事项
- **生命周期**ContextLoader 组合根负责接桥;Standalone 必须在 DataSaver 初始化后显式 `Register()`,并在退出时 `Unregister()`
- **不影响原生事件**:桥接是附加行为,ShrinkDataSaver 的原生 C# 事件仍然正常触发,两种订阅方式可并存。
- **SettingsChangedEvent.Get\<T\>()**:提供泛型辅助方法获取强类型值,内部使用 `Convert.ChangeType`
## 📄 License
[MIT](LICENSE)
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: d6a7cdf0e3e46ae418fd90acc11d6d48
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,19 @@
{
"name": "ShrinkDataSaver.Integration.EventBus",
"rootNamespace": "ShrinkDataSaver.Integration",
"references": [
"ShrinkDataSaver.Runtime",
"ShrinkContext.Core.Runtime",
"UniTask",
"ShrinkEventBus.Runtime"
],
"optionalUnityReferences": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d3277e39d0a8439db56bfb6774183165
timeCreated: 1773053684
@@ -0,0 +1,35 @@
#nullable enable
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using ShrinkContext;
namespace ShrinkDataSaver.Integration
{
/// <summary>注入 DataSaver 服务键并把其原生事件显式接到 EventBus。</summary>
public sealed class ShrinkDataSaverEventBusComponent : IShrinkComponent
{
public const string DataSaverServiceKey = "shrink.service.datasaver";
public const string ProvideKey = "shrink.integration.datasaver-eventbus";
private static readonly string[] InjectKeys = { DataSaverServiceKey };
private static readonly string[] ProvideKeys = { ProvideKey };
public string Name => "shrink.integration.datasaver-eventbus";
public IReadOnlyList<string> Inject => InjectKeys;
public IReadOnlyList<string> Provide => ProvideKeys;
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
{
_ = ctx.Get<object>(DataSaverServiceKey);
DataSaverEventBusBridge.Register();
ctx.EffectInverse(() =>
{
DataSaverEventBusBridge.Unregister();
return UniTask.CompletedTask;
});
ctx.Set(ProvideKey, Name);
return UniTask.CompletedTask;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ee66c2e8bc4958946b7c0849b11545ba
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,21 @@
{
"name": "com.cneicy.shrink-datasaver-integration-eventbus",
"version": "2.1.0",
"displayName": "ShrinkDataSaver - EventBus Integration",
"description": "ShrinkDataSaver 与 ShrinkEventBus 的桥接层,自动将存档/设置事件映射到事件总线。",
"unity": "2022.3",
"documentationUrl": "https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus",
"changelogUrl": "https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus/blob/main/CHANGELOG.md",
"licensesUrl": "https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus/blob/main/LICENSE",
"dependencies": {
"com.cneicy.shrink-datasaver": "2.2.0",
"com.cneicy.shrink-context-core": "0.1.0",
"com.cysharp.unitask": "2.5.10",
"com.cneicy.shrink-eventbus": "1.3.0"
},
"keywords": ["save", "eventbus", "integration", "bridge"],
"author": {
"name": "cneicy",
"url": "https://github.com/cneicy"
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3d8e28bc2bab56447aab597955ca683b
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,35 @@
# Changelog
本文件记录 `ShrinkDataSaver` 在当前工作区中的包内变更。
## [2.2.0] - 2026-05-18
### Added
- 新增 `ShrinkDataSaverRuntime`,把真实初始化入口与 autosave 生命周期驱动下沉到运行时层。
### Changed
- `ShrinkDataSaverBootstrap` 现主要承担旧项目兼容入口职责;实际初始化改为委托给 `ShrinkDataSaverRuntime`
- 该调整为 `ShrinkApp` 等统一宿主接管初始化顺序铺路,同时保留旧项目直接挂 bootstrap 的使用方式。
## [2.1.0] - 2026-05-15
### Added
- `ShrinkSave.GetRecentSlotIndex()``ShrinkSave.GetRecommendedContinueSlotAsync()`,用于把“最近游玩槽位 / 继续游戏”能力下沉到包内。
- 包内测试补充了主文件损坏时的备份回退、最近槽位回退、以及本地存储双副本轮换验证。
### Changed
- `LocalStorageProvider` 升级为异步文件流读写,并在提交时使用主文件原子替换。
- 本地存储新增 `.bak1` / `.bak2` 双副本轮换备份。
- `ShrinkSave``ShrinkSettings` 在主文件损坏或缺失时,会自动从备份恢复并修复主文件。
- `ShrinkSave.DeleteSlotAsync(...)` 现在会同时清理主文件、备份文件和临时文件,并在删除最近游玩槽位后自动回退记录。
## [2.0.1] - 2026-04-07
### Changed
- 数据查看器编辑器入口迁移到 `ShrinkSDK/存档/数据查看器`
- README 同步更新新的菜单路径,统一到 `ShrinkSDK` 顶栏下。
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 78b8bd668657cd546a75bf4ae7cb3b8f
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9e02de79ba4c4595bc36097d194fdf52
timeCreated: 1773047471
@@ -0,0 +1,15 @@
{
"name": "ShrinkDataSaver.Editor",
"rootNamespace": "ShrinkDataSaver.Editor",
"references": [
"ShrinkDataSaver.Runtime",
"UniTask"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": false
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 78cf16cc0efe432a939df0e90a4cdb14
timeCreated: 1773047512
@@ -0,0 +1,378 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace ShrinkDataSaver.Editor
{
public class ShrinkDataSaverEditorWindow : EditorWindow
{
private int _selectedTab;
private readonly string[] _tabs = { "设置", "存档槽", "工具" };
private Vector2 _settingsScroll;
private Vector2 _slotsScroll;
private string _setKey = "";
private string _setValue = "";
private int _setType; // 0=string, 1=int, 2=float, 3=bool
private readonly string[] _typeNames = { "字符串", "整数", "浮点数", "布尔值" };
private int _saveSlot;
private string _slotName = "新存档";
private bool _encrypt;
private string _encryptKey = "";
private SaveMeta[] _metaCache;
[MenuItem("ShrinkSDK/存档/数据查看器")]
public static void ShowWindow()
{
var w = GetWindow<ShrinkDataSaverEditorWindow>("ShrinkDataSaver");
w.minSize = new Vector2(460, 500);
}
private void OnGUI()
{
DrawHeader();
_selectedTab = GUILayout.Toolbar(_selectedTab, _tabs, EditorStyles.toolbarButton);
EditorGUILayout.Space(4);
switch (_selectedTab)
{
case 0: DrawSettingsTab(); break;
case 1: DrawSaveSlotsTab(); break;
case 2: DrawToolsTab(); break;
}
}
private void OnInspectorUpdate() => Repaint();
private void DrawHeader()
{
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
GUILayout.Label("ShrinkDataSaver", EditorStyles.boldLabel);
GUILayout.FlexibleSpace();
var color = GUI.color;
GUI.color = Application.isPlaying ? Color.green : Color.gray;
GUILayout.Label(Application.isPlaying ? "● 运行中" : "○ 未运行", EditorStyles.miniLabel);
GUI.color = color;
EditorGUILayout.EndHorizontal();
}
private void DrawSettingsTab()
{
if (!Application.isPlaying)
{
EditorGUILayout.HelpBox("设置查看器仅在运行模式下可用。", MessageType.Info);
DrawSettingsFilePath();
return;
}
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("重新加载", EditorStyles.miniButton))
ShrinkSettings.LoadAsync().Forget();
if (GUILayout.Button("立即保存", EditorStyles.miniButton))
ShrinkSettings.SaveAsync().Forget();
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(6);
var allSettings = ShrinkSettings.GetAllRaw();
EditorGUILayout.LabelField($"当前设置项({allSettings.Count} 项)", EditorStyles.boldLabel);
if (allSettings.Count == 0)
{
EditorGUILayout.HelpBox("暂无设置项。", MessageType.None);
}
else
{
_settingsScroll = EditorGUILayout.BeginScrollView(_settingsScroll, GUILayout.MaxHeight(240));
string keyToRemove = null;
foreach (var kvp in allSettings.OrderBy(k => k.Key))
{
EditorGUILayout.BeginHorizontal("box");
EditorGUILayout.LabelField(kvp.Key, EditorStyles.boldLabel, GUILayout.Width(140));
var valueStr = FormatJToken(kvp.Value);
EditorGUILayout.LabelField(valueStr, EditorStyles.wordWrappedMiniLabel);
var prevColor = GUI.backgroundColor;
GUI.backgroundColor = new Color(1f, 0.6f, 0.6f);
if (GUILayout.Button("删除", EditorStyles.miniButton, GUILayout.Width(40)))
keyToRemove = kvp.Key;
GUI.backgroundColor = prevColor;
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.EndScrollView();
if (keyToRemove != null)
ShrinkSettings.Remove(keyToRemove);
}
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("新增 / 修改", EditorStyles.boldLabel);
_setKey = EditorGUILayout.TextField("键", _setKey);
_setType = EditorGUILayout.Popup("类型", _setType, _typeNames);
_setValue = EditorGUILayout.TextField("值", _setValue);
EditorGUI.BeginDisabledGroup(string.IsNullOrWhiteSpace(_setKey));
if (GUILayout.Button("设置", EditorStyles.miniButton))
SetTypedValue(_setKey, _setValue, _setType);
EditorGUI.EndDisabledGroup();
}
private static void SetTypedValue(string key, string value, int type)
{
switch (type)
{
case 0: // string
ShrinkSettings.Set(key, value);
break;
case 1: // int
if (int.TryParse(value, out var intVal))
ShrinkSettings.Set(key, intVal);
else
Debug.LogWarning($"[ShrinkDataSaver] 无法将 \"{value}\" 解析为整数。");
break;
case 2: // float
if (float.TryParse(value, out var floatVal))
ShrinkSettings.Set(key, floatVal);
else
Debug.LogWarning($"[ShrinkDataSaver] 无法将 \"{value}\" 解析为浮点数。");
break;
case 3: // bool
if (bool.TryParse(value, out var boolVal))
ShrinkSettings.Set(key, boolVal);
else
ShrinkSettings.Set(key, value is "1" or "true" or "True");
break;
}
}
private static string FormatJToken(JToken token)
{
if (token == null) return "null";
return token.Type switch
{
JTokenType.String => $"\"{token}\"",
JTokenType.Boolean => token.Value<bool>() ? "true" : "false",
JTokenType.Array or JTokenType.Object => token.ToString(Newtonsoft.Json.Formatting.None),
_ => token.ToString()
};
}
private void DrawSettingsFilePath()
{
var cfg = ShrinkDataSaverSettings.Instance;
var root = string.IsNullOrEmpty(cfg.customSavePath)
? Application.persistentDataPath
: cfg.customSavePath;
EditorGUILayout.LabelField("设置文件路径:", Path.Combine(root, cfg.settingsFileName),
EditorStyles.wordWrappedMiniLabel);
if (GUILayout.Button("打开目录", EditorStyles.miniButton))
EditorUtility.RevealInFinder(root);
}
private void DrawSaveSlotsTab()
{
if (!Application.isPlaying)
{
EditorGUILayout.HelpBox("存档操作仅在运行模式下可用。", MessageType.Info);
DrawSavesFolderPath();
return;
}
EditorGUILayout.LabelField("快速操作", EditorStyles.boldLabel);
_saveSlot = EditorGUILayout.IntField("槽位索引", _saveSlot);
_slotName = EditorGUILayout.TextField("存档名称", _slotName);
_encrypt = EditorGUILayout.Toggle("加密", _encrypt);
if (_encrypt)
_encryptKey = EditorGUILayout.TextField("密钥", _encryptKey);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("保存", EditorStyles.miniButton))
{
ShrinkSave.SaveSlotAsync(_saveSlot, new SaveOptions
{
SlotName = _slotName,
Encrypt = _encrypt,
EncryptionKey = _encryptKey
}).Forget();
RefreshMetaDelayed().Forget();
}
if (GUILayout.Button("加载", EditorStyles.miniButton))
{
ShrinkSave.LoadSlotAsync(_saveSlot, _encrypt ? _encryptKey : null).Forget();
}
if (GUILayout.Button("删除", EditorStyles.miniButton))
{
if (EditorUtility.DisplayDialog("删除存档",
$"确认删除槽位 {_saveSlot}", "删除", "取消"))
{
ShrinkSave.DeleteSlotAsync(_saveSlot).Forget();
RefreshMetaDelayed().Forget();
}
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(8);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("所有存档", EditorStyles.boldLabel);
if (GUILayout.Button("刷新", EditorStyles.miniButton, GUILayout.Width(50)))
RefreshMeta().Forget();
EditorGUILayout.EndHorizontal();
if (_metaCache == null)
{
EditorGUILayout.HelpBox("点击「刷新」加载存档列表。", MessageType.None);
}
else if (_metaCache.Length == 0)
{
EditorGUILayout.HelpBox("暂无存档。", MessageType.None);
}
else
{
DrawMetaList();
}
}
private async UniTask RefreshMeta()
{
_metaCache = await ShrinkSave.GetAllMetaAsync();
Repaint();
}
private async UniTaskVoid RefreshMetaDelayed()
{
await UniTask.Delay(300);
await RefreshMeta();
}
private void DrawMetaList()
{
_slotsScroll = EditorGUILayout.BeginScrollView(_slotsScroll, GUILayout.MaxHeight(360));
foreach (var meta in _metaCache)
{
EditorGUILayout.BeginVertical("box");
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField($"槽位 {meta.SlotIndex} — {meta.SlotName}", EditorStyles.boldLabel);
GUILayout.FlexibleSpace();
if (GUILayout.Button("加载", EditorStyles.miniButton, GUILayout.Width(40)))
{
var key = meta.IsEncrypted ? _encryptKey : null;
ShrinkSave.LoadSlotAsync(meta.SlotIndex, key).Forget();
}
var prevColor = GUI.backgroundColor;
GUI.backgroundColor = new Color(1f, 0.6f, 0.6f);
if (GUILayout.Button("删除", EditorStyles.miniButton, GUILayout.Width(40)))
{
if (EditorUtility.DisplayDialog("删除存档",
$"确认删除槽位 {meta.SlotIndex}{meta.SlotName})?", "删除", "取消"))
{
ShrinkSave.DeleteSlotAsync(meta.SlotIndex).Forget();
RefreshMetaDelayed().Forget();
}
}
GUI.backgroundColor = prevColor;
EditorGUILayout.EndHorizontal();
EditorGUILayout.LabelField("最后修改", meta.LastModifiedTime.ToString("yyyy-MM-dd HH:mm:ss"));
EditorGUILayout.LabelField("游戏时长", TimeSpan.FromSeconds(meta.PlaytimeSeconds).ToString(@"hh\:mm\:ss"));
EditorGUILayout.LabelField("存档版本", meta.SaveVersion.ToString());
EditorGUILayout.LabelField("已加密", meta.IsEncrypted ? "是" : "否");
EditorGUILayout.LabelField("截图", meta.ScreenshotBase64 != null ? "有" : "无");
EditorGUILayout.EndVertical();
EditorGUILayout.Space(2);
}
EditorGUILayout.EndScrollView();
}
private void DrawSavesFolderPath()
{
var cfg = ShrinkDataSaverSettings.Instance;
var root = string.IsNullOrEmpty(cfg.customSavePath)
? Application.persistentDataPath
: cfg.customSavePath;
var saves = Path.Combine(root, "saves");
EditorGUILayout.LabelField("存档目录:", saves, EditorStyles.wordWrappedMiniLabel);
if (GUILayout.Button("打开目录", EditorStyles.miniButton))
EditorUtility.RevealInFinder(saves);
}
private void DrawToolsTab()
{
EditorGUILayout.LabelField("实用工具", EditorStyles.boldLabel);
if (GUILayout.Button("打开持久化数据目录"))
EditorUtility.RevealInFinder(Application.persistentDataPath);
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("配置资源", EditorStyles.boldLabel);
var settings = ShrinkDataSaverSettings.Instance;
if (settings)
{
EditorGUILayout.ObjectField("资源文件", settings, typeof(ShrinkDataSaverSettings), false);
}
else
{
EditorGUILayout.HelpBox(
"未找到 ShrinkDataSaverSettings 资源文件。\n" +
"请通过菜单 Assets → Create → ShrinkDataSaver → Settings 创建。",
MessageType.Warning);
}
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("危险操作", EditorStyles.boldLabel);
GUI.backgroundColor = new Color(1f, 0.4f, 0.4f);
if (GUILayout.Button("删除所有存档文件"))
{
var cfg = ShrinkDataSaverSettings.Instance;
var root = string.IsNullOrEmpty(cfg?.customSavePath)
? Application.persistentDataPath
: cfg.customSavePath;
var saves = Path.Combine(root, "saves");
if (EditorUtility.DisplayDialog("删除所有存档",
$"将删除以下目录中的所有内容:\n{saves}\n\n此操作不可撤销!",
"全部删除", "取消"))
{
if (Directory.Exists(saves))
Directory.Delete(saves, true);
_metaCache = null;
Debug.Log("[ShrinkDataSaver] 所有存档文件已删除。");
}
}
GUI.backgroundColor = Color.white;
}
}
}
#endif
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0c285b35e7094111b6ea986ea5af391e
timeCreated: 1773047478
+538
View File
@@ -0,0 +1,538 @@
# ShrinkDataSaver
一个为 Unity C# 项目设计的模块化存档与设置管理系统。支持多存档槽、链式版本迁移、可选 AES-256 加密、关键模块保护、跨模块只读查询,以及完整的事件驱动架构。
## ✨ 特性概览
| 特性 | 说明 |
|------|------|
| 💾 **模块化存档** | 按模块拆分存档数据,注册即用,读写隔离 |
| 🔢 **多存档槽** | 槽位数量可配置,支持元数据轻量查询 |
| 🔄 **链式版本迁移** | 注册迁移规则后自动链式执行,失败时整体回滚 |
| 🔒 **可选加密** | AES-256-CBC + PBKDF2-SHA256,随机 Salt/IV,密钥由使用者管理 |
| ⚡ **事件驱动** | 8 种原生事件,覆盖保存/加载/删除/迁移的完整生命周期 |
| 🔍 **跨模块查询** | `QueryModule<T>` 只读查询其他模块的运行时数据 |
| 🛡️ **关键模块保护** | `CriticalModule` 标记的模块序列化失败将中止整个保存操作 |
| ⏱️ **自动保存** | 按模块配置的最小间隔自动触发保存 |
| ⚙️ **Settings 系统** | 独立于存档的键值对设置,防抖写入,本地持久化 |
| 🧷 **最近游玩槽位** | 自动记录最近一次成功进入的槽位,可用于“继续游戏” |
| 🛠️ **双副本备份** | 主文件原子替换,自动保留 `.bak1` / `.bak2` 双副本轮换 |
| ☁️ **云存档兼容** | 每槽单文件 `.sav`,模块级 `EnableCloudSync` 开关 |
| 🔗 **EventBus 集成** | 可选接入 ShrinkEventBus,所有事件自动桥接到事件总线 |
| 🖥️ **Editor 工具** | 中文可视化调试窗口,实时查看/操作设置与存档 |
## 📦 依赖
- Unity 2022.3+
- [UniTask](https://github.com/Cysharp/UniTask) `2.x`
- [Newtonsoft.Json](https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2/manual/index.html)`com.unity.nuget.newtonsoft-json`
## ⚙️ 安装
在项目的 `Packages/manifest.json` 中添加:
```json
{
"dependencies": {
"com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask",
"com.cneicy.shrink-datasaver": "https://github.com/cneicy/ShrinkDataSaver.git"
}
}
```
或通过 Package Manager → `+``Add package from git URL` 输入:
```
https://github.com/cneicy/ShrinkDataSaver.git
```
## 🚀 快速上手
### 第一步:创建配置资产
菜单 `Assets``Create``ShrinkDataSaver``Settings`
配置文件可放置在项目**任意目录**下,编辑器会通过 `AssetDatabase` 自动搜索。也可放在 `Resources/` 下供运行时加载,或在 Bootstrap 组件上手动指定。
> ⚠️ 未找到配置文件时,控制台会输出警告并使用默认配置。
### 第二步:放置 Bootstrap
在首场景中创建 GameObject,挂载 `ShrinkDataSaverBootstrap` 组件:
- **Settings Override**:可选,手动拖入配置资产(留空则自动查找)
- **Current Save Version**:当前存档版本号(从 `1` 开始)
Bootstrap 会自动 `DontDestroyOnLoad`,并在应用退出 / 移动端切后台时自动写入 Settings。
从当前版本开始,真正的初始化逻辑已下沉到 `ShrinkDataSaverRuntime`。这意味着:
- 旧项目继续挂 `ShrinkDataSaverBootstrap` 也能正常工作
- 如果项目接入了 `ShrinkApp`,则可由宿主统一调用 `ShrinkDataSaverRuntime.Initialize(...)`
- 宿主已接管时,旧 bootstrap 会自动幂等退出,不重复初始化
### 第三步:注册存档模块
```csharp
// 方式 ALambda(轻量)
ShrinkSave.RegisterModule(
key: "inventory",
serialize: () => inventoryManager.GetData(),
deserialize: data => inventoryManager.LoadData(data)
);
// 方式 BLambda + 模块配置
ShrinkSave.RegisterModule(
key: "quests",
serialize: () => questSystem.GetData(),
deserialize: data => questSystem.LoadData(data),
config: new ModuleConfig
{
EnableCloudSync = true,
AutoSaveIntervalSeconds = 60f, // 每 60 秒自动保存
CriticalModule = true // 序列化失败将中止保存
}
);
// 方式 C:接口(结构化)
public class InventoryModule : ISaveModule<InventoryData>
{
public string Key => "inventory";
public InventoryData Serialize() => inventoryManager.GetData();
public void Deserialize(InventoryData data) => inventoryManager.LoadData(data);
}
ShrinkSave.RegisterModule(new InventoryModule());
```
### 第四步:保存与加载
```csharp
// 保存
await ShrinkSave.SaveSlotAsync(0, new SaveOptions
{
SlotName = "第一周目",
CaptureScreenshot = true
});
// 加载
await ShrinkSave.LoadSlotAsync(0);
// 删除
await ShrinkSave.DeleteSlotAsync(0);
```
---
## 📖 核心概念
### 事件系统
系统在关键操作时触发原生 C# 事件,所有事件携带完整载荷数据:
| 事件 | 触发时机 | 载荷 |
|------|----------|------|
| `OnSaveStarted` | 开始保存前 | SlotIndex, Timestamp |
| `OnSaveCompleted` | 保存成功后 | SlotIndex, ModuleNames[], Timestamp |
| `OnSaveFailed` | 保存失败时 | SlotIndex, ErrorMessage |
| `OnLoadStarted` | 开始加载前 | SlotIndex, Timestamp |
| `OnLoadCompleted` | 加载成功后 | SlotIndex, ModuleNames[], Version, Timestamp |
| `OnLoadFailed` | 加载失败时 | SlotIndex, ErrorMessage |
| `OnMigrationCompleted` | 版本迁移完成后 | SlotIndex, FromVersion, ToVersion |
| `OnDeleteCompleted` | 删除存档后 | SlotIndex |
```csharp
ShrinkSave.OnSaveCompleted += args =>
Debug.Log($"槽位 {args.SlotIndex} 保存成功,模块: {string.Join(", ", args.ModuleNames)}");
ShrinkSave.OnLoadFailed += args =>
ShowErrorDialog($"加载失败: {args.ErrorMessage}");
ShrinkSave.OnMigrationCompleted += args =>
Debug.Log($"存档从 v{args.FromVersion} 迁移到 v{args.ToVersion}");
```
### 模块配置
注册模块时可传入 `ModuleConfig` 控制行为:
```csharp
var config = new ModuleConfig
{
EnableCloudSync = false, // 不参与云存档(如本地设置)
AutoSaveIntervalSeconds = 30f, // 自动保存间隔(0 = 禁用)
CriticalModule = true // 序列化失败中止整个保存
};
ShrinkSave.RegisterModule("settings", () => data, d => data = d, config);
```
- **EnableCloudSync**:标记该模块是否参与云同步(供业务层查询)
- **AutoSaveIntervalSeconds**Bootstrap 会取所有模块中最小的非零间隔,定时自动保存到当前已加载的槽位
- **CriticalModule**:标记为关键模块后,序列化异常会触发 `OnSaveFailed` 并中止保存;非关键模块异常仅跳过该模块
### 跨模块只读查询
模块间需要共享数据时,通过存档管理器提供的只读接口查询,避免直接耦合:
```csharp
// 查询其他模块的当前运行时数据(序列化快照)
var playerStats = ShrinkSave.QueryModule<PlayerStatsData>("playerStats");
if (playerStats != null)
Debug.Log($"玩家等级: {playerStats.Level}");
// 检查已加载存档中某模块是否包含特定键
bool hasCoins = ShrinkSave.HasKey("inventory", "coins");
// 检查模块是否已注册
bool registered = ShrinkSave.HasModule("quests");
```
### 最近游玩槽位
包内会在 `LoadSlotAsync(...)` 成功后自动记录最近一次成功进入的槽位,并在删除该槽位时自动回退到其他有效槽位:
```csharp
int recentSlot = ShrinkSave.GetRecentSlotIndex();
int continueSlot = await ShrinkSave.GetRecommendedContinueSlotAsync();
if (continueSlot >= 0)
{
await ShrinkSave.LoadSlotAsync(continueSlot);
}
```
### 版本迁移
每次存档结构变化时,注册迁移规则。**必须在 `LoadSlotAsync` 调用之前注册。**
```csharp
// 注册迁移(在游戏初始化时)
MigrationChain.Register(fromVersion: 1, toVersion: 2, data =>
{
// data 是整个存档的 JObject,包含所有模块
if (data["inventory"] is JObject inv)
{
inv["gold"] = inv["coins"];
inv.Remove("coins");
}
return data;
});
MigrationChain.Register(fromVersion: 2, toVersion: 3, data =>
{
if (data["quests"] is JObject q)
q["dailyQuestReset"] = 0;
return data;
});
// 同步更新 Bootstrap 上的 Current Save Version = 3
ShrinkSave.SetCurrentSaveVersion(3);
```
加载时自动检测版本差异,链式执行所有中间迁移(v1 → v2 → v3)。**迁移失败时自动回滚到迁移前的数据,不会丢失原始存档。**
### Settings 系统
独立于存档的键值对设置,本地持久化,不参与云存档:
```csharp
// 读写
ShrinkSettings.Set("MasterVolume", 0.8f);
ShrinkSettings.Set("Language", "zh-CN");
float volume = ShrinkSettings.Get("MasterVolume", defaultValue: 1f);
bool exists = ShrinkSettings.Has("MasterVolume");
ShrinkSettings.Remove("MasterVolume");
// 查看所有设置
var all = ShrinkSettings.GetAllRaw(); // IReadOnlyDictionary<string, JToken>
// 监听变更
ShrinkSettings.OnChanged += (key, value) => Debug.Log($"{key} = {value}");
ShrinkSettings.Watch<float>("MasterVolume", vol => ApplyVolume(vol));
```
变更时立刻触发回调,写入磁盘有防抖延迟(默认 300ms),防止高频调用产生大量 IO。
### 加密
可选加密,默认关闭。使用 AES-256-CBC + PBKDF2-SHA25610000 次迭代),每次加密随机生成 Salt 和 IV:
```csharp
// 加密保存
await ShrinkSave.SaveSlotAsync(0, new SaveOptions
{
Encrypt = true,
EncryptionKey = "your-secret-key"
});
// 解密加载(需传入相同密钥)
await ShrinkSave.LoadSlotAsync(0, decryptionKey: "your-secret-key");
// 通过元数据判断是否加密
var meta = await ShrinkSave.GetMetaAsync(0);
if (meta.IsEncrypted)
ShowPasswordPrompt();
```
---
## 🔗 ShrinkEventBus 集成
> 详细文档见 [ShrinkDataSaver.Integration.EventBus](https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus)
项目中同时包含 `ShrinkDataSaver.Integration.EventBus` 时,桥接器会在游戏启动时自动初始化,将所有原生事件映射到 EventBus:
```csharp
[EventBusSubscriber]
public class SaveUIManager : MonoBehaviour
{
[EventSubscribe(EventPriority.NORMAL)]
private void OnSaveCompleted(SaveCompletedEvent e)
{
ShowSaveIndicator(e.SlotIndex, e.ModuleNames);
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnLoadFailed(LoadFailedEvent e)
{
ShowErrorDialog(e.ErrorMessage);
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnSettingsChanged(SettingsChangedEvent e)
{
if (e.Key == "MasterVolume")
ApplyVolume(e.Get<float>());
}
}
```
**EventBus 事件类型完整列表:**
`SettingsChangedEvent` · `SaveStartedEvent` · `SaveCompletedEvent` · `SaveFailedEvent` · `LoadStartedEvent` · `LoadCompletedEvent` · `LoadFailedEvent` · `MigrationCompletedEvent` · `SlotDeletedEvent`
---
## 🖥️ Editor 调试工具
菜单 `ShrinkSDK``存档``数据查看器`
| 标签页 | 功能 |
|--------|------|
| **设置** | 运行时查看所有设置项(键、值、类型),支持新增/修改/删除,一键保存/加载 |
| **存档槽** | 查看所有槽位元数据,每个槽位可直接加载/删除,支持加密参数 |
| **工具** | 打开持久化数据目录,定位配置资源文件,一键删除所有存档 |
---
## ☁️ Steam Auto-Cloud 配置
每个逻辑存档槽默认包含一个主文件和最多两个轮换备份文件:`.sav``.sav.bak1``.sav.bak2`
在 Steamworks 后台(App Admin → Steam Cloud)配置同步路径:
| 字段 | 值 |
|------|-----|
| Root Path | `{userdata}` |
| Subdirectory | 指向 `persistentDataPath` 的相对路径 |
| File Pattern | `saves/*.sav*` |
通过 `ModuleConfig.EnableCloudSync = false` 可将特定模块(如本地设置)排除在云同步之外,供业务层在合并逻辑中过滤。
---
## 🔧 API 参考
### ShrinkSave(静态门面)
#### 模块注册
```csharp
ShrinkSave.RegisterModule(ISaveModule module, ModuleConfig config = null)
ShrinkSave.RegisterModule<T>(string key, Func<T> serialize, Action<T> deserialize, ModuleConfig config = null)
ShrinkSave.UnregisterModule(string key)
ShrinkSave.HasModule(string moduleName) bool
ShrinkSave.GetModuleConfig(string key) ModuleConfig
ShrinkSave.GetRegisteredModuleNames() IReadOnlyCollection<string>
```
#### 存档操作
```csharp
ShrinkSave.SaveSlotAsync(int slotIndex, SaveOptions, CancellationToken) UniTask
ShrinkSave.LoadSlotAsync(int slotIndex, string decryptionKey, ct) UniTask
ShrinkSave.DeleteSlotAsync(int slotIndex, CancellationToken) UniTask
ShrinkSave.SlotExistsAsync(int slotIndex, CancellationToken) UniTask<bool>
ShrinkSave.LoadedSlot int (-1 = )
```
#### 元数据查询
```csharp
ShrinkSave.GetAllMetaAsync(CancellationToken) UniTask<SaveMeta[]>
ShrinkSave.GetMetaAsync(int slotIndex, ct) UniTask<SaveMeta>
ShrinkSave.GetRecentSlotIndex() int
ShrinkSave.GetRecommendedContinueSlotAsync(ct) UniTask<int>
```
#### 跨模块查询
```csharp
ShrinkSave.QueryModule<T>(string moduleName) T
ShrinkSave.HasKey(string moduleName, string key) bool
```
#### 版本
```csharp
ShrinkSave.SetCurrentSaveVersion(int version)
ShrinkSave.GetMinAutoSaveInterval() float
MigrationChain.Register(int from, int to, Func<JObject, JObject>)
```
#### 事件
```csharp
ShrinkSave.OnSaveStarted += Action<SaveStartedEventArgs>
ShrinkSave.OnSaveCompleted += Action<SaveCompletedEventArgs>
ShrinkSave.OnSaveFailed += Action<SaveFailedEventArgs>
ShrinkSave.OnLoadStarted += Action<LoadStartedEventArgs>
ShrinkSave.OnLoadCompleted += Action<LoadCompletedEventArgs>
ShrinkSave.OnLoadFailed += Action<LoadFailedEventArgs>
ShrinkSave.OnMigrationCompleted += Action<MigrationCompletedEventArgs>
ShrinkSave.OnDeleteCompleted += Action<SlotDeletedEventArgs>
```
### ShrinkSettings(静态门面)
```csharp
ShrinkSettings.Set<T>(string key, T value)
ShrinkSettings.Get<T>(string key, T defaultValue = default) T
ShrinkSettings.Has(string key) bool
ShrinkSettings.Remove(string key)
ShrinkSettings.GetAllRaw() IReadOnlyDictionary<string, JToken>
ShrinkSettings.Watch<T>(string key, Action<T> callback)
ShrinkSettings.Unwatch(string key, Action<object> callback)
ShrinkSettings.SaveAsync(CancellationToken) UniTask
ShrinkSettings.LoadAsync(CancellationToken) UniTask
ShrinkSettings.OnChanged += Action<string, object>
```
---
## 🏗️ 架构说明
```
ShrinkDataSaver/
├── Runtime/
│ ├── ShrinkSave 存档系统静态门面(保存/加载/删除/事件/查询)
│ ├── ShrinkSettings 设置系统静态门面(键值对/防抖写入/监听)
│ ├── ShrinkDataSaverBootstrap 兼容旧入口的初始化组件
│ ├── ShrinkDataSaverRuntime 可重入运行时初始化入口 + 生命周期驱动
│ ├── ShrinkDataSaverSettings ScriptableObject 全局配置(支持任意目录)
│ ├── SaveTypes 核心类型(SaveMeta/ISaveModule/ModuleConfig/EventArgs
│ ├── MigrationChain 版本迁移链(注册/链式执行/回滚)
│ ├── DataSerializer JSON 序列化(Newtonsoft.Json
│ ├── SaveEncryptor AES-256-CBC 加密/解密
│ ├── IStorageProvider 存储后端接口
│ └── LocalStorageProvider 本地文件系统实现(异步读写 + 原子替换 + 双副本轮换)
├── Editor/
│ └── ShrinkDataSaverEditorWindow 中文可视化调试窗口
└── Tests/
├── MockStorageProvider 内存模拟存储
└── *Tests.cs NUnit 单元测试(69 个用例)
ShrinkDataSaver.Integration.EventBus/ (可选)
├── DataSaverEvents 9 个 EventBus 事件类
└── DataSaverEventBusBridge 自动桥接(RuntimeInitializeOnLoadMethod
```
**保存流程:**
```
SaveSlotAsync(slotIndex, options)
├─ ValidateSlotIndex
├─ OnSaveStarted ← 事件
├─ 序列化所有模块
│ ├─ CriticalModule 失败 → OnSaveFailed ← 事件,中止
│ └─ 普通模块失败 → LogError,跳过继续
├─ 加密(可选)
├─ 写入存储
└─ OnSaveCompleted ← 事件(含 ModuleNames[]
```
**加载流程:**
```
LoadSlotAsync(slotIndex, decryptionKey)
├─ OnLoadStarted ← 事件
├─ 读取存储 → 解密(可选)
├─ 版本迁移(如需)
│ ├─ DeepClone 备份
│ ├─ 链式执行迁移
│ ├─ 失败 → 回滚到备份
│ └─ OnMigrationCompleted ← 事件
├─ 反序列化到各模块
├─ 缓存模块数据(供 QueryModule/HasKey
└─ OnLoadCompleted ← 事件(含 ModuleNames[], Version
```
---
## ✅ 最佳实践
**设置模块标记 `EnableCloudSync = false`**
```csharp
// ✅ 本地设置不上传云端,避免跨设备覆盖
ShrinkSave.RegisterModule("settings", () => localPrefs, d => localPrefs = d,
new ModuleConfig { EnableCloudSync = false });
```
**关键模块标记 `CriticalModule = true`**
```csharp
// ✅ 玩家核心数据序列化失败时中止保存,防止存档损坏
ShrinkSave.RegisterModule("playerStats", () => stats, d => stats = d,
new ModuleConfig { CriticalModule = true });
```
**迁移注册必须在加载之前**
```csharp
// ✅ 游戏启动时立即注册所有迁移
MigrationChain.Register(1, 2, MigrateV1ToV2);
MigrationChain.Register(2, 3, MigrateV2ToV3);
ShrinkSave.SetCurrentSaveVersion(3);
// 之后才能调用 LoadSlotAsync
```
**加密存档先查询元数据**
```csharp
// ✅ 通过 Meta 判断是否需要密钥,避免盲目加载
var meta = await ShrinkSave.GetMetaAsync(slotIndex);
string key = meta?.IsEncrypted == true ? AskPlayerForPassword() : null;
await ShrinkSave.LoadSlotAsync(slotIndex, key);
```
---
## ⚠️ 注意事项
- **模块注册顺序无关**`SaveSlotAsync` 序列化所有已注册模块,`LoadSlotAsync` 分发到对应模块,缺失的模块会跳过但不会导致加载失败。
- **宿主接管兼容**:如果项目接入了 `ShrinkApp`,推荐通过 `ShrinkDataSaver.Integration.App` 让宿主统一初始化,而不是继续依赖场景 bootstrap。
- **写入原子性**`LocalStorageProvider` 先异步写入 `.tmp` 临时文件,再通过原子替换提交主文件,并自动轮换 `.bak1` / `.bak2` 两份备份。
- **读取恢复**`ShrinkSave``ShrinkSettings` 读取主文件失败时,会自动回退到 `.bak1``.bak2`,并在成功后修复主文件。
- **Settings 防抖**`Set()` 调用后不立刻写磁盘,在 300ms(可配置)内连续调用只触发一次写入。退出时强制跳过防抖直接写入。
- **加密密钥管理**:框架不存储密钥。密钥丢失则对应存档无法解密,建议在 UI 层给玩家明确提示。
- **截图与 Steam Cloud**:截图压缩为 JPG 并限制最大宽度(默认 256px),仍需注意模块数据体积。Steam Cloud 默认单文件限制 1MB。
- **配置文件查找顺序**`Resources.Load` → 编辑器 `AssetDatabase` 全局搜索 → 创建默认实例并输出控制台警告。
- **自动保存**:仅在有槽位已加载(`LoadedSlot >= 0`)且存在 `AutoSaveIntervalSeconds > 0` 的模块时生效。
---
## 📄 License
[MIT](LICENSE)
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a0a2b66f2bb84ff1bfe75303700753dd
timeCreated: 1773048087
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4927a1e86fef478f9ab5e203485c50ce
timeCreated: 1773046045
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ShrinkDataSaver.Tests")]
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1e21adcea9de62441b55c09908f22530
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,28 @@
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ShrinkDataSaver
{
public static class DataSerializer
{
private static readonly JsonSerializerSettings Settings = new()
{
NullValueHandling = NullValueHandling.Ignore,
DefaultValueHandling = DefaultValueHandling.Include,
Formatting = Formatting.None
};
public static byte[] Serialize<T>(T obj)
=> Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(obj, Settings));
public static T Deserialize<T>(byte[] data)
=> JsonConvert.DeserializeObject<T>(Encoding.UTF8.GetString(data), Settings);
public static JObject ToJObject(byte[] data)
=> JObject.Parse(Encoding.UTF8.GetString(data));
public static byte[] FromJObject(JObject obj)
=> Encoding.UTF8.GetBytes(obj.ToString(Formatting.None));
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 19531b42fff04fd9b2fde3cfb450881e
timeCreated: 1773046107
@@ -0,0 +1,14 @@
using System.Threading;
using Cysharp.Threading.Tasks;
namespace ShrinkDataSaver
{
public interface IStorageProvider
{
UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default);
UniTask<byte[]> ReadAsync(string path, CancellationToken ct = default);
UniTask<bool> ExistsAsync(string path, CancellationToken ct = default);
UniTask DeleteAsync(string path, CancellationToken ct = default);
UniTask<string[]> ListAsync(string prefix = "", CancellationToken ct = default);
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0ea76ef6ec0a455aabd1fe2346be4ddf
timeCreated: 1773046056
@@ -0,0 +1,197 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace ShrinkDataSaver
{
public class LocalStorageProvider : IStorageProvider
{
private const string BackupPrimarySuffix = ".bak1";
private const string BackupSecondarySuffix = ".bak2";
private const string TempWriteSuffix = ".tmp";
private static readonly ConcurrentDictionary<string, SemaphoreSlim> PathLocks =
new(StringComparer.OrdinalIgnoreCase);
private readonly string _rootPath;
public LocalStorageProvider(string rootPath = null)
{
_rootPath = rootPath ?? Application.persistentDataPath;
}
private string Resolve(string path) =>
Path.IsPathRooted(path) ? path : Path.Combine(_rootPath, path);
public async UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default)
{
var fullPath = Resolve(path);
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
await UniTask.RunOnThreadPool(() => Directory.CreateDirectory(dir), cancellationToken: ct);
}
var tempPath = fullPath + TempWriteSuffix;
var backupPath = fullPath + BackupPrimarySuffix;
var secondaryBackupPath = fullPath + BackupSecondarySuffix;
var pathLock = GetPathLock(fullPath);
await pathLock.WaitAsync(ct);
try
{
await WriteFileBytesAsync(tempPath, data, ct);
await UniTask.RunOnThreadPool(() =>
{
if (File.Exists(fullPath))
{
if (File.Exists(secondaryBackupPath))
{
File.Delete(secondaryBackupPath);
}
if (File.Exists(backupPath))
{
File.Move(backupPath, secondaryBackupPath);
}
File.Replace(tempPath, fullPath, backupPath, true);
}
else
{
File.Move(tempPath, fullPath);
}
}, cancellationToken: ct);
}
catch
{
await UniTask.RunOnThreadPool(() =>
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}, cancellationToken: CancellationToken.None);
throw;
}
finally
{
pathLock.Release();
}
}
public async UniTask<byte[]> ReadAsync(string path, CancellationToken ct = default)
{
var fullPath = Resolve(path);
var pathLock = GetPathLock(fullPath);
await pathLock.WaitAsync(ct);
try
{
if (!File.Exists(fullPath))
{
throw new FileNotFoundException($"ShrinkDataSaver: file not found: {fullPath}");
}
return await ReadFileBytesAsync(fullPath, ct);
}
finally
{
pathLock.Release();
}
}
public UniTask<bool> ExistsAsync(string path, CancellationToken ct = default) =>
UniTask.RunOnThreadPool(() => File.Exists(Resolve(path)), cancellationToken: ct);
public async UniTask DeleteAsync(string path, CancellationToken ct = default)
{
var fullPath = Resolve(path);
var pathLock = GetPathLock(fullPath);
await pathLock.WaitAsync(ct);
try
{
await UniTask.RunOnThreadPool(() =>
{
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
}, cancellationToken: ct);
}
finally
{
pathLock.Release();
}
}
public async UniTask<string[]> ListAsync(string prefix = "", CancellationToken ct = default)
{
return await UniTask.RunOnThreadPool(() =>
{
var dir = string.IsNullOrEmpty(prefix) ? _rootPath : Path.Combine(_rootPath, prefix);
if (!Directory.Exists(dir))
{
return Array.Empty<string>();
}
return Directory.GetFiles(dir)
.Select(f => Path.GetRelativePath(_rootPath, f))
.ToArray();
}, cancellationToken: ct);
}
private static SemaphoreSlim GetPathLock(string fullPath)
{
return PathLocks.GetOrAdd(fullPath, _ => new SemaphoreSlim(1, 1));
}
private static async UniTask WriteFileBytesAsync(string fullPath, byte[] data, CancellationToken ct)
{
await using var stream = new FileStream(
fullPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
4096,
FileOptions.Asynchronous);
await stream.WriteAsync(data, 0, data.Length, ct);
stream.Flush(true);
}
private static async UniTask<byte[]> ReadFileBytesAsync(string fullPath, CancellationToken ct)
{
await using var stream = new FileStream(
fullPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
4096,
FileOptions.Asynchronous | FileOptions.SequentialScan);
var length = stream.Length;
if (length > int.MaxValue)
{
throw new IOException($"ShrinkDataSaver: file too large to read into memory: {fullPath}");
}
var buffer = new byte[length];
var offset = 0;
while (offset < buffer.Length)
{
var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, ct);
if (read <= 0)
{
throw new EndOfStreamException($"ShrinkDataSaver: unexpected EOF while reading {fullPath}");
}
offset += read;
}
return buffer;
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: dde6d5dd1c3f47db9453a3d0122d39d4
timeCreated: 1773046075
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace ShrinkDataSaver
{
public static class MigrationChain
{
private static readonly SortedDictionary<int, Migration> Migrations = new();
private struct Migration
{
public int ToVersion;
public Func<JObject, JObject> Migrate;
}
public static void Register(int fromVersion, int toVersion, Func<JObject, JObject> migrate)
{
if (fromVersion >= toVersion)
throw new ArgumentException(
$"[ShrinkDataSaver] fromVersion({fromVersion}) 必须小于 toVersion({toVersion})");
Migrations[fromVersion] = new Migration { ToVersion = toVersion, Migrate = migrate };
}
internal static (JObject data, int finalVersion) Apply(JObject data, int currentVersion, int targetVersion)
{
var version = currentVersion;
var backup = (JObject)data.DeepClone();
while (version < targetVersion)
{
if (!Migrations.TryGetValue(version, out var migration))
{
Debug.LogWarning($"[ShrinkDataSaver] 未找到 v{version} → v{version + 1} 的迁移逻辑,数据可能不完整。");
break;
}
try
{
var fromV = version;
data = migration.Migrate(data) ?? data;
version = migration.ToVersion;
Debug.Log($"[ShrinkDataSaver] 存档已迁移 v{fromV} → v{version}");
}
catch (Exception e)
{
Debug.LogError(
$"[ShrinkDataSaver] 迁移 v{version} → v{migration.ToVersion} 失败: {e.Message},已回滚至 v{currentVersion}");
return (backup, currentVersion);
}
}
return (data, version);
}
internal static void Clear() => Migrations.Clear();
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 56cc513596cc4b30b82412e080e8b716
timeCreated: 1773047408
@@ -0,0 +1,86 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace ShrinkDataSaver
{
public static class SaveEncryptor
{
private const int KeySize = 256;
private const int BlockSize = 128;
private const int IvBytes = 16;
private const int SaltBytes = 16;
private const int Iterations = 10000;
public static byte[] Encrypt(byte[] data, string password)
{
if (data == null) throw new ArgumentNullException(nameof(data));
if (string.IsNullOrEmpty(password)) throw new ArgumentException("Password must not be empty.");
var salt = GenerateRandom(SaltBytes);
var iv = GenerateRandom(IvBytes);
var key = DeriveKey(password, salt);
using var aes = CreateAes(key, iv);
using var encryptor = aes.CreateEncryptor();
var cipher = encryptor.TransformFinalBlock(data, 0, data.Length);
var result = new byte[SaltBytes + IvBytes + cipher.Length];
Buffer.BlockCopy(salt, 0, result, 0, SaltBytes);
Buffer.BlockCopy(iv, 0, result, SaltBytes, IvBytes);
Buffer.BlockCopy(cipher, 0, result, SaltBytes + IvBytes, cipher.Length);
return result;
}
public static byte[] Decrypt(byte[] data, string password)
{
if (data == null) throw new ArgumentNullException(nameof(data));
if (string.IsNullOrEmpty(password)) throw new ArgumentException("Password must not be empty.");
if (data.Length < SaltBytes + IvBytes)
throw new ArgumentException("Data is too short to be valid encrypted content.");
var salt = new byte[SaltBytes];
var iv = new byte[IvBytes];
var cipher = new byte[data.Length - SaltBytes - IvBytes];
Buffer.BlockCopy(data, 0, salt, 0, SaltBytes);
Buffer.BlockCopy(data, SaltBytes, iv, 0, IvBytes);
Buffer.BlockCopy(data, SaltBytes + IvBytes, cipher, 0, cipher.Length);
var key = DeriveKey(password, salt);
using var aes = CreateAes(key, iv);
using var decryptor = aes.CreateDecryptor();
return decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
}
private static byte[] DeriveKey(string password, byte[] salt)
{
using var deriveBytes = new Rfc2898DeriveBytes(
Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256);
return deriveBytes.GetBytes(KeySize / 8);
}
private static Aes CreateAes(byte[] key, byte[] iv)
{
var aes = Aes.Create();
aes.KeySize = KeySize;
aes.BlockSize = BlockSize;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = key;
aes.IV = iv;
return aes;
}
private static byte[] GenerateRandom(int length)
{
var bytes = new byte[length];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return bytes;
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 751846b93860451e8864bf71f593d4ff
timeCreated: 1773046087
@@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ShrinkDataSaver
{
[Serializable]
public class SaveMeta
{
public int SlotIndex { get; set; }
public string SlotName { get; set; } = "New Save";
public int SaveVersion { get; set; } = 1;
public long LastModified { get; set; }
public float PlaytimeSeconds { get; set; }
public string ScreenshotBase64 { get; set; }
public bool IsEncrypted { get; set; }
[JsonIgnore]
public DateTime LastModifiedTime => DateTimeOffset.FromUnixTimeSeconds(LastModified).LocalDateTime;
}
[Serializable]
internal class SavePacket
{
public SaveMeta Meta { get; set; }
public Dictionary<string, JToken> Modules { get; set; } = new();
public string EncryptedModules { get; set; }
}
public interface ISaveModule
{
string Key { get; }
object SerializeRaw();
void DeserializeRaw(JToken data);
}
public interface ISaveModule<T> : ISaveModule
{
T Serialize();
void Deserialize(T data);
object ISaveModule.SerializeRaw() => Serialize();
void ISaveModule.DeserializeRaw(JToken data) => Deserialize(data.ToObject<T>());
}
internal class LambdaSaveModule<T> : ISaveModule<T>
{
public string Key { get; }
private readonly Func<T> _serialize;
private readonly Action<T> _deserialize;
public LambdaSaveModule(string key, Func<T> serialize, Action<T> deserialize)
{
Key = key; _serialize = serialize; _deserialize = deserialize;
}
public T Serialize() => _serialize();
public void Deserialize(T data) => _deserialize(data);
}
public class SaveOptions
{
public string SlotName = "New Save";
public bool CaptureScreenshot = false;
public UnityEngine.Texture2D Screenshot = null;
public bool Encrypt = false;
public string EncryptionKey = null;
}
public class ModuleConfig
{
/// <summary>是否参与云存档同步(设置模块应设为 false</summary>
public bool EnableCloudSync { get; set; } = true;
/// <summary>自动保存间隔(秒),0 表示不自动保存</summary>
public float AutoSaveIntervalSeconds { get; set; } = 0f;
/// <summary>是否为关键模块(关键模块序列化失败将中止整个保存操作)</summary>
public bool CriticalModule { get; set; } = false;
}
public class SaveStartedEventArgs
{
public int SlotIndex { get; set; }
public long Timestamp { get; set; }
}
public class SaveCompletedEventArgs
{
public int SlotIndex { get; set; }
public string[] ModuleNames { get; set; }
public long Timestamp { get; set; }
}
public class SaveFailedEventArgs
{
public int SlotIndex { get; set; }
public string ErrorMessage { get; set; }
}
public class LoadStartedEventArgs
{
public int SlotIndex { get; set; }
public long Timestamp { get; set; }
}
public class LoadCompletedEventArgs
{
public int SlotIndex { get; set; }
public string[] ModuleNames { get; set; }
public int Version { get; set; }
public long Timestamp { get; set; }
}
public class LoadFailedEventArgs
{
public int SlotIndex { get; set; }
public string ErrorMessage { get; set; }
}
public class MigrationCompletedEventArgs
{
public int SlotIndex { get; set; }
public int FromVersion { get; set; }
public int ToVersion { get; set; }
}
public class SlotDeletedEventArgs
{
public int SlotIndex { get; set; }
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7cb5188d2a984a1b8f6a701789b8d0f1
timeCreated: 1773047394
@@ -0,0 +1,17 @@
{
"name": "ShrinkDataSaver.Runtime",
"rootNamespace": "ShrinkDataSaver",
"references": [
"UniTask",
"Newtonsoft.Json"
],
"optionalUnityReferences": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5b60ff3063fc45e39b61d1df0c2a6339
timeCreated: 1773047537
@@ -0,0 +1,44 @@
using UnityEngine;
namespace ShrinkDataSaver
{
/// <summary>
/// 兼容旧用法:仍可把该组件挂到首场景中,
/// 但真正初始化已下沉到 ShrinkDataSaverRuntime,便于宿主层统一接管。
/// </summary>
[DefaultExecutionOrder(-2000)]
public class ShrinkDataSaverBootstrap : MonoBehaviour
{
[Header("Override (留空使用 ScriptableObject 设置)")] [SerializeField]
private ShrinkDataSaverSettings settingsOverride;
[Header("Save Version")] [SerializeField]
private int currentSaveVersion = 1;
private static bool _initialized;
private void Awake()
{
if (ShrinkDataSaverRuntime.IsInitialized)
{
Destroy(gameObject);
return;
}
if (_initialized)
{
Destroy(gameObject);
return;
}
_initialized = true;
DontDestroyOnLoad(gameObject);
ShrinkDataSaverRuntime.Initialize(new ShrinkDataSaverRuntimeConfig
{
SettingsOverride = settingsOverride,
CurrentSaveVersion = currentSaveVersion,
DontDestroyOnLoadDriver = true
});
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 30e24d3823c349c99bd78fa0992be0e7
timeCreated: 1773047454
@@ -0,0 +1,98 @@
#nullable enable
using System.IO;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace ShrinkDataSaver
{
public sealed class ShrinkDataSaverRuntimeConfig
{
public ShrinkDataSaverSettings? SettingsOverride { get; set; }
public int CurrentSaveVersion { get; set; } = 1;
public bool DontDestroyOnLoadDriver { get; set; } = true;
}
public static class ShrinkDataSaverRuntime
{
public static bool IsInitialized { get; private set; }
public static void Initialize(ShrinkDataSaverRuntimeConfig? config = null)
{
if (IsInitialized)
return;
config ??= new ShrinkDataSaverRuntimeConfig();
var cfg = config.SettingsOverride ?? ShrinkDataSaverSettings.Instance;
if (config.SettingsOverride)
ShrinkDataSaverSettings.Instance = config.SettingsOverride;
if (cfg == null)
throw new System.InvalidOperationException("ShrinkDataSaverSettings instance is not available.");
var rootPath = string.IsNullOrEmpty(cfg.customSavePath)
? Application.persistentDataPath
: cfg.customSavePath;
var savesDir = Path.Combine(rootPath, "saves");
var settingsPath = Path.Combine(rootPath, cfg.settingsFileName);
var storage = new LocalStorageProvider(rootPath);
ShrinkSettings.Initialize(storage, settingsPath);
ShrinkSave.Initialize(storage, savesDir, cfg.saveFileExtension, config.CurrentSaveVersion);
ShrinkSettings.LoadAsync().Forget();
ShrinkDataSaverLifecycleDriver.EnsureCreated(config.DontDestroyOnLoadDriver);
IsInitialized = true;
Debug.Log($"[ShrinkDataSaver] Runtime initialized. Root: {rootPath}");
}
internal static void ResetForTesting()
{
IsInitialized = false;
}
}
[DefaultExecutionOrder(-1999)]
internal sealed class ShrinkDataSaverLifecycleDriver : MonoBehaviour
{
private static ShrinkDataSaverLifecycleDriver? _instance;
private float _autoSaveTimer;
public static void EnsureCreated(bool dontDestroyOnLoad)
{
if (_instance != null)
return;
var go = new GameObject("ShrinkDataSaverRuntimeDriver");
_instance = go.AddComponent<ShrinkDataSaverLifecycleDriver>();
if (dontDestroyOnLoad)
DontDestroyOnLoad(go);
}
private void Update()
{
var interval = ShrinkSave.GetMinAutoSaveInterval();
if (interval <= 0f || ShrinkSave.LoadedSlot < 0)
return;
_autoSaveTimer += Time.unscaledDeltaTime;
if (_autoSaveTimer < interval)
return;
_autoSaveTimer = 0f;
ShrinkSave.SaveSlotAsync(ShrinkSave.LoadedSlot).Forget();
}
private async void OnApplicationQuit()
{
await ShrinkSettings.SaveAsync();
}
private async void OnApplicationPause(bool paused)
{
if (paused)
await ShrinkSettings.SaveAsync();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d9b14a9eecf6eb746b24241eac07c939
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,61 @@
using UnityEngine;
namespace ShrinkDataSaver
{
[CreateAssetMenu(fileName = "ShrinkDataSaverSettings", menuName = "ShrinkDataSaver/Settings")]
public class ShrinkDataSaverSettings : ScriptableObject
{
private static ShrinkDataSaverSettings _instance;
public static ShrinkDataSaverSettings Instance
{
get
{
if (_instance) return _instance;
_instance = Resources.Load<ShrinkDataSaverSettings>("ShrinkDataSaverSettings");
#if UNITY_EDITOR
if (!_instance)
{
var guids = UnityEditor.AssetDatabase.FindAssets("t:ShrinkDataSaverSettings");
if (guids.Length > 0)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]);
_instance = UnityEditor.AssetDatabase.LoadAssetAtPath<ShrinkDataSaverSettings>(path);
}
}
#endif
if (!_instance)
{
_instance = CreateInstance<ShrinkDataSaverSettings>();
Debug.LogWarning(
"[ShrinkDataSaver] 未找到 ShrinkDataSaverSettings 配置文件!当前使用默认配置。\n" +
"请通过菜单 Assets → Create → ShrinkDataSaver → Settings 创建配置文件,\n" +
"可放置在项目任意目录下(编辑器会自动搜索),或在 ShrinkDataSaverBootstrap 组件上手动指定。");
}
return _instance;
}
internal set => _instance = value;
}
[Header("Storage")]
public string customSavePath = "";
[Header("Settings File")]
public string settingsFileName = "settings.json";
public bool syncSettingsToCloud; //todo
[Header("Save Slots")]
public int maxSlots;
public string saveFileExtension = ".sav";
[Header("Screenshot")]
public int screenshotMaxWidth = 256;
[Header("Settings Debounce")]
public float settingsWriteDebounceSeconds = 0.3f;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5df22787ec2544b9abbb40cf28536c7e
timeCreated: 1773047342
@@ -0,0 +1,673 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace ShrinkDataSaver
{
public static class ShrinkSave
{
private const string BackupPrimarySuffix = ".bak1";
private const string BackupSecondarySuffix = ".bak2";
private const string TempWriteSuffix = ".tmp";
private const string RecentSlotIndexSettingKey = "ShrinkDataSaver.RecentSlotIndex";
private static IStorageProvider _storage;
private static string _savesDir;
private static string _fileExt;
private static int _currentSaveVersion = 1;
private static readonly Dictionary<string, ISaveModule> _modules = new();
private static readonly Dictionary<string, ModuleConfig> _moduleConfigs = new();
private static int _loadedSlot = -1;
private static float _sessionStart;
private static float _storedPlaytime;
private static Dictionary<string, JToken> _loadedModuleData;
public static event Action<SaveStartedEventArgs> OnSaveStarted;
public static event Action<SaveCompletedEventArgs> OnSaveCompleted;
public static event Action<SaveFailedEventArgs> OnSaveFailed;
public static event Action<LoadStartedEventArgs> OnLoadStarted;
public static event Action<LoadCompletedEventArgs> OnLoadCompleted;
public static event Action<LoadFailedEventArgs> OnLoadFailed;
public static event Action<MigrationCompletedEventArgs> OnMigrationCompleted;
public static event Action<SlotDeletedEventArgs> OnDeleteCompleted;
public static int LoadedSlot => _loadedSlot;
public static int GetRecentSlotIndex() => ShrinkSettings.Get(RecentSlotIndexSettingKey, -1);
internal static void Initialize(IStorageProvider storage, string savesDir, string fileExt, int saveVersion)
{
_storage = storage;
_savesDir = savesDir;
_fileExt = fileExt;
_currentSaveVersion = saveVersion;
}
public static void SetCurrentSaveVersion(int version) => _currentSaveVersion = version;
public static void RegisterModule(ISaveModule module, ModuleConfig config = null)
{
_modules[module.Key] = module;
_moduleConfigs[module.Key] = config ?? new ModuleConfig();
}
public static void RegisterModule<T>(string key, Func<T> serialize, Action<T> deserialize, ModuleConfig config = null)
{
_modules[key] = new LambdaSaveModule<T>(key, serialize, deserialize);
_moduleConfigs[key] = config ?? new ModuleConfig();
}
public static void UnregisterModule(string key)
{
_modules.Remove(key);
_moduleConfigs.Remove(key);
}
public static ModuleConfig GetModuleConfig(string key)
=> _moduleConfigs.TryGetValue(key, out var cfg) ? cfg : null;
public static IReadOnlyCollection<string> GetRegisteredModuleNames() => _modules.Keys;
public static T QueryModule<T>(string moduleName)
{
if (!_modules.TryGetValue(moduleName, out var module))
{
return default;
}
try
{
var raw = module.SerializeRaw();
if (raw is T typed)
{
return typed;
}
return JToken.FromObject(raw).ToObject<T>();
}
catch
{
return default;
}
}
public static bool HasKey(string moduleName, string key)
{
if (_loadedModuleData == null)
{
return false;
}
if (!_loadedModuleData.TryGetValue(moduleName, out var token))
{
return false;
}
return token is JObject obj && obj.ContainsKey(key);
}
public static bool HasModule(string moduleName) => _modules.ContainsKey(moduleName);
public static async UniTask<SaveMeta[]> GetAllMetaAsync(CancellationToken ct = default)
{
var results = new List<SaveMeta>();
var files = await _storage.ListAsync(_savesDir, ct);
var discoveredSlots = new HashSet<int>();
foreach (var file in files)
{
if (TryExtractSlotIndex(file, out var slotIndex))
{
discoveredSlots.Add(slotIndex);
}
}
foreach (var slotIndex in discoveredSlots)
{
try
{
var readResult = await ReadPacketWithFallbackAsync(slotIndex, ct);
if (readResult.packet?.Meta != null)
{
results.Add(readResult.packet.Meta);
}
}
catch (Exception e)
{
Debug.LogWarning($"[ShrinkDataSaver] 读取元数据失败 slot_{slotIndex}: {e.Message}");
}
}
results.Sort((a, b) => a.SlotIndex.CompareTo(b.SlotIndex));
return results.ToArray();
}
public async static UniTask<bool> SlotExistsAsync(int slotIndex, CancellationToken ct = default)
=> await _storage.ExistsAsync(SlotPath(slotIndex), ct);
public static async UniTask<SaveMeta> GetMetaAsync(int slotIndex, CancellationToken ct = default)
{
try
{
var readResult = await ReadPacketWithFallbackAsync(slotIndex, ct);
return readResult.packet?.Meta;
}
catch (FileNotFoundException)
{
return null;
}
}
public static async UniTask<int> GetRecommendedContinueSlotAsync(CancellationToken ct = default)
{
var metas = await GetAllMetaAsync(ct);
var resolvedSlot = ResolveRecommendedContinueSlotIndex(GetRecentSlotIndex(), metas);
if (resolvedSlot != GetRecentSlotIndex())
{
await PersistRecentSlotIndexAsync(resolvedSlot, ct);
}
return resolvedSlot;
}
public static async UniTask SaveSlotAsync(int slotIndex, SaveOptions options = null, CancellationToken ct = default)
{
options ??= new SaveOptions();
ValidateSlotIndex(slotIndex);
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
OnSaveStarted?.Invoke(new SaveStartedEventArgs { SlotIndex = slotIndex, Timestamp = timestamp });
try
{
var packet = new SavePacket
{
Meta = new SaveMeta
{
SlotIndex = slotIndex,
SlotName = options.SlotName,
SaveVersion = _currentSaveVersion,
LastModified = timestamp,
PlaytimeSeconds = GetCurrentPlaytime(),
IsEncrypted = options.Encrypt
}
};
if (options.Screenshot || options.CaptureScreenshot)
{
packet.Meta.ScreenshotBase64 = await CaptureScreenshotAsync(options, ct);
}
var modulesDict = new Dictionary<string, JToken>();
var moduleNames = new List<string>();
foreach (var module in _modules.Values)
{
try
{
modulesDict[module.Key] = JToken.FromObject(module.SerializeRaw());
moduleNames.Add(module.Key);
}
catch (Exception e)
{
var isCritical = _moduleConfigs.TryGetValue(module.Key, out var cfg) && cfg.CriticalModule;
if (isCritical)
{
throw new InvalidOperationException($"关键模块 '{module.Key}' 序列化失败: {e.Message}", e);
}
Debug.LogError($"[ShrinkDataSaver] 模块 '{module.Key}' 序列化失败(已跳过): {e.Message}");
}
}
if (options.Encrypt)
{
if (string.IsNullOrEmpty(options.EncryptionKey))
{
throw new ArgumentException("EncryptionKey missing.");
}
var modulesBytes = DataSerializer.Serialize(modulesDict);
var encryptedBytes = SaveEncryptor.Encrypt(modulesBytes, options.EncryptionKey);
packet.EncryptedModules = Convert.ToBase64String(encryptedBytes);
}
else
{
packet.Modules = modulesDict;
}
var finalBytes = DataSerializer.Serialize(packet);
await _storage.WriteAsync(SlotPath(slotIndex), finalBytes, ct);
OnSaveCompleted?.Invoke(new SaveCompletedEventArgs
{
SlotIndex = slotIndex,
ModuleNames = moduleNames.ToArray(),
Timestamp = timestamp
});
Debug.Log($"[ShrinkDataSaver] 槽位 {slotIndex} 已保存。({moduleNames.Count} 个模块)");
}
catch (Exception e)
{
OnSaveFailed?.Invoke(new SaveFailedEventArgs
{
SlotIndex = slotIndex,
ErrorMessage = e.Message
});
throw;
}
}
public static async UniTask LoadSlotAsync(int slotIndex, string decryptionKey = null, CancellationToken ct = default)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
OnLoadStarted?.Invoke(new LoadStartedEventArgs { SlotIndex = slotIndex, Timestamp = timestamp });
try
{
var loadResult = await ReadLoadResultWithFallbackAsync(slotIndex, decryptionKey, ct);
var packet = loadResult.packet;
var loadedModules = loadResult.loadedModules;
_loadedModuleData = new Dictionary<string, JToken>(loadedModules);
var moduleNames = new List<string>();
foreach (var module in _modules.Values)
{
if (!loadedModules.TryGetValue(module.Key, out var token))
{
continue;
}
try
{
module.DeserializeRaw(token);
moduleNames.Add(module.Key);
}
catch (Exception e)
{
Debug.LogError($"[ShrinkDataSaver] 反序列化模块 '{module.Key}' 失败: {e.Message}");
}
}
_loadedSlot = slotIndex;
_storedPlaytime = packet.Meta.PlaytimeSeconds;
_sessionStart = Time.realtimeSinceStartup;
await PersistRecentSlotIndexAsync(slotIndex, ct);
OnLoadCompleted?.Invoke(new LoadCompletedEventArgs
{
SlotIndex = slotIndex,
ModuleNames = moduleNames.ToArray(),
Version = packet.Meta.SaveVersion,
Timestamp = timestamp
});
Debug.Log($"[ShrinkDataSaver] 槽位 {slotIndex} 已加载。(v{packet.Meta.SaveVersion}, {moduleNames.Count} 个模块)");
}
catch (Exception e)
{
OnLoadFailed?.Invoke(new LoadFailedEventArgs
{
SlotIndex = slotIndex,
ErrorMessage = e.Message
});
throw;
}
}
public static async UniTask DeleteSlotAsync(int slotIndex, CancellationToken ct = default)
{
foreach (var candidatePath in EnumerateAllSlotPaths(slotIndex))
{
await _storage.DeleteAsync(candidatePath, ct);
}
if (_loadedSlot == slotIndex)
{
_loadedSlot = -1;
_storedPlaytime = 0f;
_loadedModuleData = null;
}
if (GetRecentSlotIndex() == slotIndex)
{
var metas = await GetAllMetaAsync(ct);
var fallbackSlot = ResolveRecommendedContinueSlotIndex(-1, metas);
await PersistRecentSlotIndexAsync(fallbackSlot, ct);
}
OnDeleteCompleted?.Invoke(new SlotDeletedEventArgs { SlotIndex = slotIndex });
Debug.Log($"[ShrinkDataSaver] 槽位 {slotIndex} 已删除。");
}
public static float GetMinAutoSaveInterval()
{
var min = float.MaxValue;
var hasAny = false;
foreach (var cfg in _moduleConfigs.Values)
{
if (cfg.AutoSaveIntervalSeconds > 0f)
{
min = Mathf.Min(min, cfg.AutoSaveIntervalSeconds);
hasAny = true;
}
}
return hasAny ? min : 0f;
}
internal static void ResetForTesting()
{
_modules.Clear();
_moduleConfigs.Clear();
_loadedSlot = -1;
_sessionStart = 0f;
_storedPlaytime = 0f;
_loadedModuleData = null;
OnSaveStarted = null;
OnSaveCompleted = null;
OnSaveFailed = null;
OnLoadStarted = null;
OnLoadCompleted = null;
OnLoadFailed = null;
OnMigrationCompleted = null;
OnDeleteCompleted = null;
}
private static string SlotPath(int slotIndex) => Path.Combine(_savesDir, $"slot_{slotIndex}{_fileExt}");
private static IEnumerable<string> EnumerateAllSlotPaths(int slotIndex)
{
var primaryPath = SlotPath(slotIndex);
yield return primaryPath;
yield return primaryPath + BackupPrimarySuffix;
yield return primaryPath + BackupSecondarySuffix;
yield return primaryPath + TempWriteSuffix;
}
private static IEnumerable<string> EnumerateReadCandidatePaths(string primaryPath)
{
yield return primaryPath;
yield return primaryPath + BackupPrimarySuffix;
yield return primaryPath + BackupSecondarySuffix;
}
private static void ValidateSlotIndex(int slotIndex)
{
if (slotIndex < 0)
{
throw new ArgumentOutOfRangeException(nameof(slotIndex), "槽位索引不能为负数。");
}
var max = ShrinkDataSaverSettings.Instance.maxSlots;
if (max > 0 && slotIndex >= max)
{
throw new ArgumentOutOfRangeException(nameof(slotIndex), $"超出最大槽位数 ({max})。");
}
}
private static bool TryExtractSlotIndex(string path, out int slotIndex)
{
slotIndex = -1;
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName) || fileName.EndsWith(TempWriteSuffix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var normalizedName = fileName;
if (normalizedName.EndsWith(BackupSecondarySuffix, StringComparison.OrdinalIgnoreCase))
{
normalizedName = normalizedName[..^BackupSecondarySuffix.Length];
}
else if (normalizedName.EndsWith(BackupPrimarySuffix, StringComparison.OrdinalIgnoreCase))
{
normalizedName = normalizedName[..^BackupPrimarySuffix.Length];
}
if (!normalizedName.StartsWith("slot_", StringComparison.OrdinalIgnoreCase) ||
!normalizedName.EndsWith(_fileExt, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var indexText = normalizedName.Substring(5, normalizedName.Length - 5 - _fileExt.Length);
return int.TryParse(indexText, out slotIndex);
}
private static async UniTask PersistRecentSlotIndexAsync(int slotIndex, CancellationToken ct)
{
if (GetRecentSlotIndex() == slotIndex)
{
return;
}
ShrinkSettings.Set(RecentSlotIndexSettingKey, slotIndex);
await ShrinkSettings.SaveAsync(ct);
}
private static int ResolveRecommendedContinueSlotIndex(int rememberedSlotIndex, IReadOnlyList<SaveMeta> metas)
{
if (metas == null || metas.Count == 0)
{
return -1;
}
if (rememberedSlotIndex >= 0 && ContainsSlot(metas, rememberedSlotIndex))
{
return rememberedSlotIndex;
}
return PickMostRecentSlot(metas);
}
private static bool ContainsSlot(IReadOnlyList<SaveMeta> metas, int slotIndex)
{
for (var i = 0; i < metas.Count; i++)
{
if (metas[i] != null && metas[i].SlotIndex == slotIndex)
{
return true;
}
}
return false;
}
private static int PickMostRecentSlot(IReadOnlyList<SaveMeta> metas)
{
SaveMeta best = null;
for (var i = 0; i < metas.Count; i++)
{
var meta = metas[i];
if (meta == null)
{
continue;
}
if (best == null ||
meta.LastModified > best.LastModified ||
(meta.LastModified == best.LastModified && meta.SlotIndex < best.SlotIndex))
{
best = meta;
}
}
return best?.SlotIndex ?? -1;
}
private static async UniTask<(SavePacket packet, byte[] rawBytes, string sourcePath)> ReadPacketWithFallbackAsync(
int slotIndex,
CancellationToken ct)
{
var primaryPath = SlotPath(slotIndex);
Exception lastError = null;
foreach (var candidatePath in EnumerateReadCandidatePaths(primaryPath))
{
if (!await _storage.ExistsAsync(candidatePath, ct))
{
continue;
}
try
{
var bytes = await _storage.ReadAsync(candidatePath, ct);
var packet = DataSerializer.Deserialize<SavePacket>(bytes);
if (packet?.Meta == null)
{
throw new InvalidDataException($"槽位 {slotIndex} 的存档包缺少 Meta。");
}
if (!string.Equals(candidatePath, primaryPath, StringComparison.Ordinal))
{
Debug.LogWarning($"[ShrinkDataSaver] 槽位 {slotIndex} 主文件损坏或缺失,已从备份恢复:{candidatePath}");
await _storage.WriteAsync(primaryPath, bytes, ct);
}
return (packet, bytes, candidatePath);
}
catch (Exception ex)
{
lastError = ex;
Debug.LogWarning($"[ShrinkDataSaver] 读取槽位副本失败:{candidatePath} / {ex.Message}");
}
}
if (lastError != null)
{
throw new InvalidDataException($"槽位 {slotIndex} 的主文件及备份均不可读取。", lastError);
}
throw new FileNotFoundException($"槽位 {slotIndex} 不存在。");
}
private static async UniTask<(SavePacket packet, Dictionary<string, JToken> loadedModules)> ReadLoadResultWithFallbackAsync(
int slotIndex,
string decryptionKey,
CancellationToken ct)
{
var primaryPath = SlotPath(slotIndex);
Exception lastError = null;
foreach (var candidatePath in EnumerateReadCandidatePaths(primaryPath))
{
if (!await _storage.ExistsAsync(candidatePath, ct))
{
continue;
}
try
{
var bytes = await _storage.ReadAsync(candidatePath, ct);
var packet = DataSerializer.Deserialize<SavePacket>(bytes);
if (packet?.Meta == null)
{
throw new InvalidDataException($"反序列化槽位 {slotIndex} 失败。");
}
Dictionary<string, JToken> loadedModules;
if (packet.Meta.IsEncrypted)
{
if (string.IsNullOrEmpty(decryptionKey))
{
throw new ArgumentException("需要解密密钥。");
}
var encryptedBytes = Convert.FromBase64String(packet.EncryptedModules);
var decryptedBytes = SaveEncryptor.Decrypt(encryptedBytes, decryptionKey);
loadedModules = DataSerializer.Deserialize<Dictionary<string, JToken>>(decryptedBytes);
}
else
{
loadedModules = packet.Modules ?? new Dictionary<string, JToken>();
}
if (packet.Meta.SaveVersion < _currentSaveVersion)
{
var fromVersion = packet.Meta.SaveVersion;
var modulesObj = JObject.FromObject(loadedModules);
(modulesObj, packet.Meta.SaveVersion) = MigrationChain.Apply(modulesObj, fromVersion, _currentSaveVersion);
loadedModules = new Dictionary<string, JToken>();
foreach (var key in modulesObj.Properties())
{
loadedModules[key.Name] = modulesObj[key.Name];
}
OnMigrationCompleted?.Invoke(new MigrationCompletedEventArgs
{
SlotIndex = slotIndex,
FromVersion = fromVersion,
ToVersion = packet.Meta.SaveVersion
});
}
if (!string.Equals(candidatePath, primaryPath, StringComparison.Ordinal))
{
Debug.LogWarning($"[ShrinkDataSaver] 槽位 {slotIndex} 主文件损坏或缺失,已从备份恢复:{candidatePath}");
await _storage.WriteAsync(primaryPath, bytes, ct);
}
return (packet, loadedModules);
}
catch (Exception ex)
{
lastError = ex;
Debug.LogWarning($"[ShrinkDataSaver] 加载槽位副本失败:{candidatePath} / {ex.Message}");
}
}
if (lastError != null)
{
throw new InvalidDataException($"槽位 {slotIndex} 的主文件及备份均不可加载。", lastError);
}
throw new FileNotFoundException($"槽位 {slotIndex} 不存在。");
}
private static float GetCurrentPlaytime() => _loadedSlot < 0 ? 0f : _storedPlaytime + (Time.realtimeSinceStartup - _sessionStart);
private static async UniTask<string> CaptureScreenshotAsync(SaveOptions options, CancellationToken ct)
{
var tex = options.Screenshot;
if (!tex && options.CaptureScreenshot)
{
await UniTask.Yield(PlayerLoopTiming.PostLateUpdate, ct);
tex = ScreenCapture.CaptureScreenshotAsTexture();
}
if (!tex)
{
return null;
}
var settings = ShrinkDataSaverSettings.Instance;
var maxW = settings.screenshotMaxWidth;
if (tex.width > maxW)
{
var scale = (float)maxW / tex.width;
var resized = new Texture2D(maxW, Mathf.RoundToInt(tex.height * scale));
for (var y = 0; y < resized.height; y++)
{
for (var x = 0; x < resized.width; x++)
{
resized.SetPixel(x, y, tex.GetPixelBilinear((float)x / resized.width, (float)y / resized.height));
}
}
resized.Apply();
tex = resized;
}
return Convert.ToBase64String(tex.EncodeToJPG(75));
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d41b28112039451ca4fca14c901a91a9
timeCreated: 1773047436
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace ShrinkDataSaver
{
[Serializable]
internal class SettingsData
{
public Dictionary<string, JToken> Values = new();
}
public static class ShrinkSettings
{
private const string BackupPrimarySuffix = ".bak1";
private const string BackupSecondarySuffix = ".bak2";
private static SettingsData _data = new();
private static IStorageProvider _storage;
private static string _filePath;
private static CancellationTokenSource _debounceCts;
private static readonly Dictionary<string, List<Action<object>>> _watchers = new();
public static event Action<string, object> OnChanged;
internal static void Initialize(IStorageProvider storage, string filePath)
{
_storage = storage; _filePath = filePath;
}
public static void Set<T>(string key, T value)
{
_data.Values[key] = JToken.FromObject(value);
FireChanged(key, value);
ScheduleWrite().Forget();
}
public static T Get<T>(string key, T defaultValue = default)
{
if (_data.Values.TryGetValue(key, out var token))
{
try { return token.ToObject<T>(); } catch { /* ignore */ }
}
return defaultValue;
}
public static bool Has(string key) => _data.Values.ContainsKey(key);
public static IReadOnlyDictionary<string, JToken> GetAllRaw() => _data.Values;
public static void Remove(string key)
{
if (_data.Values.Remove(key))
{
FireChanged(key, null);
ScheduleWrite().Forget();
}
}
public static void Watch<T>(string key, Action<T> callback)
{
if (!_watchers.ContainsKey(key)) _watchers[key] = new List<Action<object>>();
_watchers[key].Add(raw => callback((T)Convert.ChangeType(raw, typeof(T))));
}
public static void Unwatch(string key, Action<object> callback)
{
if (_watchers.TryGetValue(key, out var list)) list.Remove(callback);
}
public static async UniTask SaveAsync(CancellationToken ct = default)
{
_debounceCts?.Cancel();
if (_storage == null || string.IsNullOrWhiteSpace(_filePath))
{
// 未初始化(独立宿主/单测环境)时跳过持久化,与 LoadAsync 的守卫保持一致
return;
}
var bytes = DataSerializer.Serialize(_data);
await _storage.WriteAsync(_filePath, bytes, ct);
}
public static async UniTask LoadAsync(CancellationToken ct = default)
{
if (_storage == null || string.IsNullOrWhiteSpace(_filePath))
{
_data = new SettingsData();
return;
}
var loadedData = await TryLoadWithFallbackAsync(ct);
_data = loadedData ?? new SettingsData();
}
internal static void ResetForTesting()
{
_data = new SettingsData();
_debounceCts?.Cancel();
_debounceCts = null;
_watchers.Clear();
OnChanged = null;
}
private static void FireChanged(string key, object value)
{
OnChanged?.Invoke(key, value);
if (_watchers.TryGetValue(key, out var list))
foreach (var cb in list)
try { cb(value); } catch (Exception e) { Debug.LogException(e); }
}
private static async UniTaskVoid ScheduleWrite()
{
_debounceCts?.Cancel();
_debounceCts = new CancellationTokenSource();
var token = _debounceCts.Token;
try
{
var delay = (int)(ShrinkDataSaverSettings.Instance.settingsWriteDebounceSeconds * 1000);
await UniTask.Delay(delay, cancellationToken: token);
await SaveAsync(token);
}
catch (OperationCanceledException) { }
catch (Exception e) { Debug.LogException(e); }
}
private static async UniTask<SettingsData> TryLoadWithFallbackAsync(CancellationToken ct)
{
var primaryPath = _filePath;
Exception lastError = null;
foreach (var candidatePath in EnumerateCandidatePaths(primaryPath))
{
if (!await _storage.ExistsAsync(candidatePath, ct))
{
continue;
}
try
{
var bytes = await _storage.ReadAsync(candidatePath, ct);
var loaded = DataSerializer.Deserialize<SettingsData>(bytes) ?? new SettingsData();
if (!string.Equals(candidatePath, primaryPath, StringComparison.Ordinal))
{
Debug.LogWarning($"[ShrinkDataSaver] Settings 主文件损坏或缺失,已从备份恢复:{candidatePath}");
await _storage.WriteAsync(primaryPath, bytes, ct);
}
return loaded;
}
catch (Exception ex)
{
lastError = ex;
Debug.LogWarning($"[ShrinkDataSaver] 读取 Settings 副本失败:{candidatePath} / {ex.Message}");
}
}
if (lastError != null)
{
Debug.LogWarning("[ShrinkDataSaver] 所有 Settings 副本均不可用,已回退为空设置。");
}
return new SettingsData();
}
private static IEnumerable<string> EnumerateCandidatePaths(string primaryPath)
{
yield return primaryPath;
yield return primaryPath + BackupPrimarySuffix;
yield return primaryPath + BackupSecondarySuffix;
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ffdd053c674640a9987f34ddd93080d8
timeCreated: 1773047370
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3ce4b0a2f2d4f304aa1f93b4ceccbf92
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,81 @@
using System;
using NUnit.Framework;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class DataSerializerTests
{
[Serializable]
private class SimpleData
{
public string Name = "test";
public int Score = 42;
}
[Serializable]
private class NestedData
{
public string Id = "root";
public SimpleData Child = new();
public int[] Numbers = { 1, 2, 3 };
}
[Test]
public void Serialize_Deserialize_SimpleObject()
{
var original = new SimpleData { Name = "Alice", Score = 100 };
var bytes = DataSerializer.Serialize(original);
var restored = DataSerializer.Deserialize<SimpleData>(bytes);
Assert.AreEqual("Alice", restored.Name);
Assert.AreEqual(100, restored.Score);
}
[Test]
public void Serialize_Deserialize_NestedObject()
{
var original = new NestedData
{
Id = "parent",
Child = new SimpleData { Name = "child", Score = 99 },
Numbers = new[] { 10, 20, 30 }
};
var bytes = DataSerializer.Serialize(original);
var restored = DataSerializer.Deserialize<NestedData>(bytes);
Assert.AreEqual("parent", restored.Id);
Assert.AreEqual("child", restored.Child.Name);
Assert.AreEqual(99, restored.Child.Score);
Assert.AreEqual(new[] { 10, 20, 30 }, restored.Numbers);
}
[Test]
public void ToJObject_FromJObject_RoundTrip()
{
var original = new SimpleData { Name = "Bob", Score = 77 };
var bytes = DataSerializer.Serialize(original);
var jObj = DataSerializer.ToJObject(bytes);
Assert.AreEqual("Bob", jObj["Name"].ToString());
Assert.AreEqual(77, (int)jObj["Score"]);
var bytesBack = DataSerializer.FromJObject(jObj);
var restored = DataSerializer.Deserialize<SimpleData>(bytesBack);
Assert.AreEqual("Bob", restored.Name);
Assert.AreEqual(77, restored.Score);
}
[Test]
public void Serialize_NullFields_Ignored()
{
var data = new SimpleData { Name = null, Score = 5 };
var bytes = DataSerializer.Serialize(data);
var json = System.Text.Encoding.UTF8.GetString(bytes);
Assert.IsFalse(json.Contains("Name"));
Assert.IsTrue(json.Contains("Score"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 818cff0034bbd7d4aa0c64c46fc28343
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,125 @@
using System;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class MigrationChainTests
{
[SetUp]
public void SetUp() => MigrationChain.Clear();
[TearDown]
public void TearDown() => MigrationChain.Clear();
[Test]
public void Register_ValidVersions_Succeeds()
{
Assert.DoesNotThrow(() =>
MigrationChain.Register(1, 2, data => data));
}
[Test]
public void Register_InvalidRange_Throws()
{
Assert.Throws<ArgumentException>(() =>
MigrationChain.Register(2, 1, data => data));
Assert.Throws<ArgumentException>(() =>
MigrationChain.Register(1, 1, data => data));
}
[Test]
public void Apply_SingleMigration()
{
MigrationChain.Register(1, 2, data =>
{
data["newField"] = "added";
return data;
});
var input = new JObject { ["existing"] = "value" };
var (result, version) = MigrationChain.Apply(input, 1, 2);
Assert.AreEqual(2, version);
Assert.AreEqual("value", result["existing"].ToString());
Assert.AreEqual("added", result["newField"].ToString());
}
[Test]
public void Apply_ChainMigration_1To3()
{
MigrationChain.Register(1, 2, data =>
{
data["coins"] = data["gold"];
data.Remove("gold");
return data;
});
MigrationChain.Register(2, 3, data =>
{
data["version3Field"] = 42;
return data;
});
var input = new JObject { ["gold"] = 100 };
var (result, version) = MigrationChain.Apply(input, 1, 3);
Assert.AreEqual(3, version);
Assert.AreEqual(100, result["coins"].Value<int>());
Assert.IsFalse(result.ContainsKey("gold"));
Assert.AreEqual(42, result["version3Field"].Value<int>());
}
[Test]
public void Apply_MissingStep_StopsEarly()
{
MigrationChain.Register(1, 2, data => data);
// 缺少 2→3 的迁移
var input = new JObject { ["data"] = "test" };
var (result, version) = MigrationChain.Apply(input, 1, 3);
// 应该停在 v2,因为没有 2→3 的迁移
Assert.AreEqual(2, version);
}
[Test]
public void Apply_FailedMigration_Rollback()
{
MigrationChain.Register(1, 2, data =>
{
data["step1"] = "done";
return data;
});
MigrationChain.Register(2, 3, data =>
{
throw new Exception("Migration failed!");
});
var input = new JObject { ["original"] = "data" };
LogAssert.Expect(LogType.Error, "[ShrinkDataSaver] 迁移 v2 → v3 失败: Migration failed!,已回滚至 v1");
var (result, version) = MigrationChain.Apply(input, 1, 3);
// 应该回滚到 v1 的备份数据
Assert.AreEqual(1, version);
Assert.AreEqual("data", result["original"].ToString());
// 回滚意味着 step1 的修改不会存在
Assert.IsFalse(result.ContainsKey("step1"));
}
[Test]
public void Apply_NoMigrationNeeded_ReturnsSameData()
{
var input = new JObject { ["data"] = "unchanged" };
var (result, version) = MigrationChain.Apply(input, 3, 3);
Assert.AreEqual(3, version);
Assert.AreEqual("unchanged", result["data"].ToString());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dba29246ec289aa4d9798abaef89a7bb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
namespace ShrinkDataSaver.Tests
{
/// <summary>
/// 内存模拟存储,用于单元测试,无需文件 I/O。
/// </summary>
public class MockStorageProvider : IStorageProvider
{
private readonly Dictionary<string, byte[]> _store = new();
public UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default)
{
_store[NormalizePath(path)] = data;
return UniTask.CompletedTask;
}
public UniTask<byte[]> ReadAsync(string path, CancellationToken ct = default)
{
var key = NormalizePath(path);
if (!_store.TryGetValue(key, out var data))
throw new FileNotFoundException($"MockStorage: {key}");
return UniTask.FromResult(data);
}
public UniTask<bool> ExistsAsync(string path, CancellationToken ct = default)
=> UniTask.FromResult(_store.ContainsKey(NormalizePath(path)));
public UniTask DeleteAsync(string path, CancellationToken ct = default)
{
_store.Remove(NormalizePath(path));
return UniTask.CompletedTask;
}
public UniTask<string[]> ListAsync(string prefix = "", CancellationToken ct = default)
{
var norm = NormalizePath(prefix);
var results = _store.Keys
.Where(k => string.IsNullOrEmpty(norm) || k.StartsWith(norm))
.ToArray();
return UniTask.FromResult(results);
}
public void Clear() => _store.Clear();
public int Count => _store.Count;
private static string NormalizePath(string path)
=> path.Replace('\\', '/');
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b20797d49fc7bfc47a22c7be5bbd6cf2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,89 @@
using System;
using System.Security.Cryptography;
using System.Text;
using NUnit.Framework;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class SaveEncryptorTests
{
[Test]
public void Encrypt_Decrypt_RoundTrip()
{
var original = Encoding.UTF8.GetBytes("Hello, ShrinkDataSaver!");
var password = "TestPassword123";
var encrypted = SaveEncryptor.Encrypt(original, password);
var decrypted = SaveEncryptor.Decrypt(encrypted, password);
Assert.AreEqual(original, decrypted);
}
[Test]
public void Encrypt_Decrypt_LargeData()
{
var original = new byte[10000];
new System.Random(42).NextBytes(original);
var password = "LargeDataKey";
var encrypted = SaveEncryptor.Encrypt(original, password);
var decrypted = SaveEncryptor.Decrypt(encrypted, password);
Assert.AreEqual(original, decrypted);
}
[Test]
public void Decrypt_WrongPassword_Throws()
{
var original = Encoding.UTF8.GetBytes("secret data");
var encrypted = SaveEncryptor.Encrypt(original, "correct");
Assert.Throws<CryptographicException>(() =>
SaveEncryptor.Decrypt(encrypted, "wrong"));
}
[Test]
public void Encrypt_ProducesDifferentOutput_EachTime()
{
var data = Encoding.UTF8.GetBytes("same data");
var enc1 = SaveEncryptor.Encrypt(data, "key");
var enc2 = SaveEncryptor.Encrypt(data, "key");
// 因为随机 salt/IV,两次加密结果不同
Assert.AreNotEqual(enc1, enc2);
// 但两次都能正确解密
Assert.AreEqual(data, SaveEncryptor.Decrypt(enc1, "key"));
Assert.AreEqual(data, SaveEncryptor.Decrypt(enc2, "key"));
}
[Test]
public void Encrypt_NullData_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
SaveEncryptor.Encrypt(null, "key"));
}
[Test]
public void Encrypt_EmptyPassword_Throws()
{
Assert.Throws<ArgumentException>(() =>
SaveEncryptor.Encrypt(new byte[] { 1, 2, 3 }, ""));
}
[Test]
public void Decrypt_NullData_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
SaveEncryptor.Decrypt(null, "key"));
}
[Test]
public void Decrypt_DataTooShort_Throws()
{
Assert.Throws<ArgumentException>(() =>
SaveEncryptor.Decrypt(new byte[] { 1, 2, 3 }, "key"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e5040f6e0d2203349aea6b1d2c85a46d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,134 @@
using System;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class SaveTypesTests
{
// ── SaveMeta ──
[Test]
public void SaveMeta_LastModifiedTime_ConvertsCorrectly()
{
var meta = new SaveMeta
{
LastModified = 1700000000 // 2023-11-14 22:13:20 UTC
};
var dt = meta.LastModifiedTime;
Assert.AreEqual(2023, dt.Year);
Assert.AreEqual(11, dt.Month);
}
[Test]
public void SaveMeta_Defaults()
{
var meta = new SaveMeta();
Assert.AreEqual("New Save", meta.SlotName);
Assert.AreEqual(1, meta.SaveVersion);
Assert.IsFalse(meta.IsEncrypted);
Assert.IsNull(meta.ScreenshotBase64);
}
// ── ModuleConfig ──
[Test]
public void ModuleConfig_Defaults()
{
var cfg = new ModuleConfig();
Assert.IsTrue(cfg.EnableCloudSync);
Assert.AreEqual(0f, cfg.AutoSaveIntervalSeconds);
Assert.IsFalse(cfg.CriticalModule);
}
// ── SaveOptions ──
[Test]
public void SaveOptions_Defaults()
{
var opt = new SaveOptions();
Assert.AreEqual("New Save", opt.SlotName);
Assert.IsFalse(opt.CaptureScreenshot);
Assert.IsNull(opt.Screenshot);
Assert.IsFalse(opt.Encrypt);
Assert.IsNull(opt.EncryptionKey);
}
// ── LambdaSaveModule ──
[Test]
public void LambdaSaveModule_Serialize_CallsFactory()
{
var data = new TestData { Value = 42 };
var module = new LambdaSaveModule<TestData>("test", () => data, d => { });
Assert.AreEqual("test", module.Key);
var result = module.Serialize();
Assert.AreEqual(42, result.Value);
}
[Test]
public void LambdaSaveModule_Deserialize_CallsConsumer()
{
TestData received = null;
var module = new LambdaSaveModule<TestData>("test", () => null, d => received = d);
((ISaveModule)module).DeserializeRaw(JToken.FromObject(new TestData { Value = 99 }));
Assert.IsNotNull(received);
Assert.AreEqual(99, received.Value);
}
// ── 事件参数 ──
[Test]
public void SaveCompletedEventArgs_Fields()
{
var args = new SaveCompletedEventArgs
{
SlotIndex = 2,
ModuleNames = new[] { "inventory", "quests" },
Timestamp = 1234567890
};
Assert.AreEqual(2, args.SlotIndex);
Assert.AreEqual(2, args.ModuleNames.Length);
Assert.AreEqual(1234567890, args.Timestamp);
}
[Test]
public void LoadCompletedEventArgs_Fields()
{
var args = new LoadCompletedEventArgs
{
SlotIndex = 1,
ModuleNames = new[] { "player" },
Version = 3,
Timestamp = 9999
};
Assert.AreEqual(1, args.SlotIndex);
Assert.AreEqual(3, args.Version);
}
[Test]
public void MigrationCompletedEventArgs_Fields()
{
var args = new MigrationCompletedEventArgs
{
SlotIndex = 0, FromVersion = 1, ToVersion = 3
};
Assert.AreEqual(1, args.FromVersion);
Assert.AreEqual(3, args.ToVersion);
}
[Serializable]
public class TestData
{
public int Value;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c4fec6ba5af68dd46ae44776da037c69
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
{
"name": "ShrinkDataSaver.Tests",
"rootNamespace": "ShrinkDataSaver.Tests",
"references": [
"ShrinkDataSaver.Runtime",
"UniTask",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"Newtonsoft.Json.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: ea4bb7f0bb4396d4e9454b19f4630613
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,629 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class ShrinkSaveTests
{
private MockStorageProvider _storage;
private ShrinkDataSaverSettings _settings;
[Serializable]
private class PlayerData
{
public string Name = "TestPlayer";
public int Level = 1;
public int Coins = 100;
}
private PlayerData _playerData;
[SetUp]
public void SetUp()
{
_storage = new MockStorageProvider();
_settings = ScriptableObject.CreateInstance<ShrinkDataSaverSettings>();
_settings.maxSlots = 10;
ShrinkDataSaverSettings.Instance = _settings;
ShrinkSave.ResetForTesting();
MigrationChain.Clear();
ShrinkSave.Initialize(_storage, "saves", ".sav", 1);
_playerData = new PlayerData();
}
[TearDown]
public void TearDown()
{
ShrinkSave.ResetForTesting();
MigrationChain.Clear();
if (_settings != null) Object.DestroyImmediate(_settings);
}
// ── 模块注册 ──
[Test]
public void RegisterModule_Lambda()
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
Assert.IsTrue(ShrinkSave.HasModule("player"));
}
[Test]
public void RegisterModule_WithConfig()
{
var config = new ModuleConfig
{
EnableCloudSync = false,
AutoSaveIntervalSeconds = 30f,
CriticalModule = true
};
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d, config);
var restored = ShrinkSave.GetModuleConfig("player");
Assert.IsFalse(restored.EnableCloudSync);
Assert.AreEqual(30f, restored.AutoSaveIntervalSeconds);
Assert.IsTrue(restored.CriticalModule);
}
[Test]
public void UnregisterModule_RemovesModuleAndConfig()
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d,
new ModuleConfig { CriticalModule = true });
ShrinkSave.UnregisterModule("player");
Assert.IsFalse(ShrinkSave.HasModule("player"));
Assert.IsNull(ShrinkSave.GetModuleConfig("player"));
}
[Test]
public void GetModuleConfig_NonExistent_ReturnsNull()
{
Assert.IsNull(ShrinkSave.GetModuleConfig("ghost"));
}
[Test]
public void HasModule_ReturnsFalse_WhenNotRegistered()
{
Assert.IsFalse(ShrinkSave.HasModule("nonexistent"));
}
[Test]
public void GetRegisteredModuleNames_ReturnsAll()
{
ShrinkSave.RegisterModule("a", () => 1, _ => { });
ShrinkSave.RegisterModule("b", () => 2, _ => { });
var names = ShrinkSave.GetRegisteredModuleNames();
Assert.AreEqual(2, names.Count);
Assert.IsTrue(names.Contains("a"));
Assert.IsTrue(names.Contains("b"));
}
// ── 保存 / 加载 / 删除 ──
[UnityTest]
public IEnumerator SaveSlotAsync_ThenExists() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "TestSave" });
Assert.IsTrue(await ShrinkSave.SlotExistsAsync(0));
});
[UnityTest]
public IEnumerator SaveLoad_RoundTrip_DataIntegrity() => UniTask.ToCoroutine(async () =>
{
_playerData = new PlayerData { Name = "Alice", Level = 10, Coins = 500 };
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "TestSave" });
// 清除内存数据
_playerData = new PlayerData();
Assert.AreEqual("TestPlayer", _playerData.Name);
// 重新加载
await ShrinkSave.LoadSlotAsync(0);
Assert.AreEqual("Alice", _playerData.Name);
Assert.AreEqual(10, _playerData.Level);
Assert.AreEqual(500, _playerData.Coins);
});
[UnityTest]
public IEnumerator SaveLoad_Encrypted_RoundTrip() => UniTask.ToCoroutine(async () =>
{
_playerData = new PlayerData { Name = "Encrypted", Level = 99, Coins = 9999 };
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0, new SaveOptions
{
SlotName = "Encrypted Save",
Encrypt = true,
EncryptionKey = "secret123"
});
_playerData = new PlayerData();
await ShrinkSave.LoadSlotAsync(0, "secret123");
Assert.AreEqual("Encrypted", _playerData.Name);
Assert.AreEqual(99, _playerData.Level);
});
[UnityTest]
public IEnumerator DeleteSlotAsync_RemovesSlot() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0);
Assert.IsTrue(await ShrinkSave.SlotExistsAsync(0));
await ShrinkSave.DeleteSlotAsync(0);
Assert.IsFalse(await ShrinkSave.SlotExistsAsync(0));
});
[UnityTest]
public IEnumerator LoadSlotAsync_NonExistent_ThrowsAndFiresLoadFailed() => UniTask.ToCoroutine(async () =>
{
LoadFailedEventArgs failArgs = null;
ShrinkSave.OnLoadFailed += args => failArgs = args;
try
{
await ShrinkSave.LoadSlotAsync(99);
Assert.Fail("Should have thrown");
}
catch (System.IO.FileNotFoundException) { }
Assert.IsNotNull(failArgs);
Assert.AreEqual(99, failArgs.SlotIndex);
Assert.IsTrue(failArgs.ErrorMessage.Contains("99"));
});
[UnityTest]
public IEnumerator SaveSlotAsync_NegativeIndex_Throws() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
try
{
await ShrinkSave.SaveSlotAsync(-1);
Assert.Fail("Should have thrown");
}
catch (ArgumentOutOfRangeException) { }
});
[UnityTest]
public IEnumerator SaveSlotAsync_ExceedsMaxSlots_Throws() => UniTask.ToCoroutine(async () =>
{
_settings.maxSlots = 3;
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
try
{
await ShrinkSave.SaveSlotAsync(5);
Assert.Fail("Should have thrown");
}
catch (ArgumentOutOfRangeException) { }
});
// ── 事件 ──
[UnityTest]
public IEnumerator Events_SaveStarted_FiredBeforeSave() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
SaveStartedEventArgs startArgs = null;
ShrinkSave.OnSaveStarted += args => startArgs = args;
await ShrinkSave.SaveSlotAsync(0);
Assert.IsNotNull(startArgs);
Assert.AreEqual(0, startArgs.SlotIndex);
Assert.IsTrue(startArgs.Timestamp > 0);
});
[UnityTest]
public IEnumerator Events_SaveCompleted_ContainsModuleNames() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
ShrinkSave.RegisterModule("settings", () => new { volume = 1f }, _ => { });
SaveCompletedEventArgs completedArgs = null;
ShrinkSave.OnSaveCompleted += args => completedArgs = args;
await ShrinkSave.SaveSlotAsync(0);
Assert.IsNotNull(completedArgs);
Assert.AreEqual(0, completedArgs.SlotIndex);
Assert.AreEqual(2, completedArgs.ModuleNames.Length);
Assert.IsTrue(completedArgs.Timestamp > 0);
});
[UnityTest]
public IEnumerator Events_LoadCompleted_ContainsVersionAndModules() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0);
LoadCompletedEventArgs loadArgs = null;
ShrinkSave.OnLoadCompleted += args => loadArgs = args;
await ShrinkSave.LoadSlotAsync(0);
Assert.IsNotNull(loadArgs);
Assert.AreEqual(0, loadArgs.SlotIndex);
Assert.AreEqual(1, loadArgs.Version);
Assert.IsTrue(loadArgs.ModuleNames.Length > 0);
});
[UnityTest]
public IEnumerator Events_LoadStarted_Fired() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0);
LoadStartedEventArgs startArgs = null;
ShrinkSave.OnLoadStarted += args => startArgs = args;
await ShrinkSave.LoadSlotAsync(0);
Assert.IsNotNull(startArgs);
Assert.AreEqual(0, startArgs.SlotIndex);
});
[UnityTest]
public IEnumerator Events_DeleteCompleted_Fired() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0);
SlotDeletedEventArgs deleteArgs = null;
ShrinkSave.OnDeleteCompleted += args => deleteArgs = args;
await ShrinkSave.DeleteSlotAsync(0);
Assert.IsNotNull(deleteArgs);
Assert.AreEqual(0, deleteArgs.SlotIndex);
});
// ── 关键模块 ──
[UnityTest]
public IEnumerator CriticalModule_FailedSerialize_AbortsSave() => UniTask.ToCoroutine(async () =>
{
// 注册一个会抛异常的关键模块
ShrinkSave.RegisterModule<object>("broken",
() => throw new Exception("boom"),
_ => { },
new ModuleConfig { CriticalModule = true });
SaveFailedEventArgs failArgs = null;
ShrinkSave.OnSaveFailed += args => failArgs = args;
try
{
await ShrinkSave.SaveSlotAsync(0);
Assert.Fail("Should have thrown");
}
catch (InvalidOperationException) { }
Assert.IsNotNull(failArgs);
Assert.IsTrue(failArgs.ErrorMessage.Contains("broken"));
});
[UnityTest]
public IEnumerator NonCriticalModule_FailedSerialize_ContinuesSave() => UniTask.ToCoroutine(async () =>
{
// 正常模块
ShrinkSave.RegisterModule("good", () => _playerData, d => _playerData = d);
// 会失败的非关键模块
ShrinkSave.RegisterModule<object>("bad",
() => throw new Exception("oops"),
_ => { },
new ModuleConfig { CriticalModule = false });
SaveCompletedEventArgs completedArgs = null;
ShrinkSave.OnSaveCompleted += args => completedArgs = args;
LogAssert.Expect(LogType.Error, "[ShrinkDataSaver] 模块 'bad' 序列化失败(已跳过): oops");
await ShrinkSave.SaveSlotAsync(0);
// 保存应该成功(跳过了失败的非关键模块)
Assert.IsNotNull(completedArgs);
Assert.IsTrue(await ShrinkSave.SlotExistsAsync(0));
});
// ── 版本迁移事件 ──
[UnityTest]
public IEnumerator MigrationCompleted_FiredOnVersionMismatch() => UniTask.ToCoroutine(async () =>
{
// 以 v1 保存
ShrinkSave.SetCurrentSaveVersion(1);
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0);
// 设置迁移并升级到 v2
MigrationChain.Register(1, 2, data =>
{
data["migrated"] = true;
return data;
});
ShrinkSave.SetCurrentSaveVersion(2);
MigrationCompletedEventArgs migArgs = null;
ShrinkSave.OnMigrationCompleted += args => migArgs = args;
await ShrinkSave.LoadSlotAsync(0);
Assert.IsNotNull(migArgs);
Assert.AreEqual(0, migArgs.SlotIndex);
Assert.AreEqual(1, migArgs.FromVersion);
Assert.AreEqual(2, migArgs.ToVersion);
});
// ── 跨模块查询 ──
[Test]
public void QueryModule_ReturnsCurrentData()
{
_playerData = new PlayerData { Name = "Query", Level = 5, Coins = 200 };
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
var result = ShrinkSave.QueryModule<PlayerData>("player");
Assert.IsNotNull(result);
Assert.AreEqual("Query", result.Name);
Assert.AreEqual(5, result.Level);
}
[Test]
public void QueryModule_NonExistentModule_ReturnsDefault()
{
var result = ShrinkSave.QueryModule<PlayerData>("ghost");
Assert.IsNull(result);
}
[UnityTest]
public IEnumerator HasKey_AfterLoad_ChecksModuleData() => UniTask.ToCoroutine(async () =>
{
_playerData = new PlayerData { Name = "KeyTest", Level = 1, Coins = 0 };
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0);
await ShrinkSave.LoadSlotAsync(0);
Assert.IsTrue(ShrinkSave.HasKey("player", "Name"));
Assert.IsTrue(ShrinkSave.HasKey("player", "Level"));
Assert.IsFalse(ShrinkSave.HasKey("player", "NonExistentField"));
Assert.IsFalse(ShrinkSave.HasKey("ghost_module", "Name"));
});
[Test]
public void HasKey_BeforeLoad_ReturnsFalse()
{
Assert.IsFalse(ShrinkSave.HasKey("player", "Name"));
}
// ── 自动保存间隔 ──
[Test]
public void GetMinAutoSaveInterval_NoModules_ReturnsZero()
{
Assert.AreEqual(0f, ShrinkSave.GetMinAutoSaveInterval());
}
[Test]
public void GetMinAutoSaveInterval_ReturnsMinimum()
{
ShrinkSave.RegisterModule("a", () => 1, _ => { },
new ModuleConfig { AutoSaveIntervalSeconds = 60f });
ShrinkSave.RegisterModule("b", () => 2, _ => { },
new ModuleConfig { AutoSaveIntervalSeconds = 30f });
ShrinkSave.RegisterModule("c", () => 3, _ => { },
new ModuleConfig { AutoSaveIntervalSeconds = 0f }); // 不参与
Assert.AreEqual(30f, ShrinkSave.GetMinAutoSaveInterval());
}
// ── 元数据 ──
[UnityTest]
public IEnumerator GetMetaAsync_ReturnsMeta() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "MetaTest" });
var meta = await ShrinkSave.GetMetaAsync(0);
Assert.IsNotNull(meta);
Assert.AreEqual(0, meta.SlotIndex);
Assert.AreEqual("MetaTest", meta.SlotName);
Assert.AreEqual(1, meta.SaveVersion);
Assert.IsFalse(meta.IsEncrypted);
});
[UnityTest]
public IEnumerator GetMetaAsync_NonExistent_ReturnsNull() => UniTask.ToCoroutine(async () =>
{
var meta = await ShrinkSave.GetMetaAsync(99);
Assert.IsNull(meta);
});
[UnityTest]
public IEnumerator GetAllMetaAsync_ReturnsAllSlots() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "Slot0" });
await ShrinkSave.SaveSlotAsync(1, new SaveOptions { SlotName = "Slot1" });
var allMeta = await ShrinkSave.GetAllMetaAsync();
Assert.AreEqual(2, allMeta.Length);
Assert.AreEqual(0, allMeta[0].SlotIndex);
Assert.AreEqual(1, allMeta[1].SlotIndex);
});
// ── LoadedSlot ──
[Test]
public void LoadedSlot_InitiallyNegative()
{
Assert.AreEqual(-1, ShrinkSave.LoadedSlot);
}
[UnityTest]
public IEnumerator LoadedSlot_UpdatedAfterLoad() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0);
await ShrinkSave.LoadSlotAsync(0);
Assert.AreEqual(0, ShrinkSave.LoadedSlot);
});
[UnityTest]
public IEnumerator LoadedSlot_ResetAfterDelete() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(0);
await ShrinkSave.LoadSlotAsync(0);
Assert.AreEqual(0, ShrinkSave.LoadedSlot);
await ShrinkSave.DeleteSlotAsync(0);
Assert.AreEqual(-1, ShrinkSave.LoadedSlot);
});
// ── 多模块 ──
[UnityTest]
public IEnumerator MultipleModules_SaveLoad_AllRestored() => UniTask.ToCoroutine(async () =>
{
var inventory = new int[] { 1, 2, 3 };
var questFlag = new bool[] { true, false, true };
int[] loadedInv = null;
bool[] loadedQuest = null;
ShrinkSave.RegisterModule("inventory", () => inventory, d => loadedInv = d);
ShrinkSave.RegisterModule("quests", () => questFlag, d => loadedQuest = d);
await ShrinkSave.SaveSlotAsync(0);
await ShrinkSave.LoadSlotAsync(0);
Assert.AreEqual(new[] { 1, 2, 3 }, loadedInv);
Assert.AreEqual(new[] { true, false, true }, loadedQuest);
});
[UnityTest]
public IEnumerator LoadSlotAsync_FallsBackToBackup_WhenPrimaryCorrupted() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
_playerData = new PlayerData { Name = "BackupPlayer", Level = 7, Coins = 321 };
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "BackupSlot" });
var backupBytes = await _storage.ReadAsync("saves/slot_0.sav");
await _storage.WriteAsync("saves/slot_0.sav.bak1", backupBytes);
await _storage.WriteAsync("saves/slot_0.sav", DataSerializer.Serialize(new { broken = true }));
_playerData = new PlayerData();
await ShrinkSave.LoadSlotAsync(0);
Assert.AreEqual("BackupPlayer", _playerData.Name);
var repairedPrimary = await _storage.ReadAsync("saves/slot_0.sav");
CollectionAssert.AreEqual(backupBytes, repairedPrimary);
});
[UnityTest]
public IEnumerator GetAllMetaAsync_UsesBackupOnlySlot_WhenPrimaryMissing() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
await ShrinkSave.SaveSlotAsync(2, new SaveOptions { SlotName = "BackupOnly" });
var backupBytes = await _storage.ReadAsync("saves/slot_2.sav");
await _storage.WriteAsync("saves/slot_2.sav.bak1", backupBytes);
await _storage.DeleteAsync("saves/slot_2.sav");
var metas = await ShrinkSave.GetAllMetaAsync();
Assert.AreEqual(1, metas.Length);
Assert.AreEqual(2, metas[0].SlotIndex);
Assert.AreEqual("BackupOnly", metas[0].SlotName);
Assert.IsTrue(await _storage.ExistsAsync("saves/slot_2.sav"));
});
[UnityTest]
public IEnumerator GetRecommendedContinueSlotAsync_PrefersRecentAndSelfHeals() => UniTask.ToCoroutine(async () =>
{
ShrinkSave.RegisterModule("player", () => _playerData, d => _playerData = d);
_playerData = new PlayerData { Name = "Slot0", Level = 1, Coins = 10 };
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "Slot0" });
_playerData = new PlayerData { Name = "Slot3", Level = 3, Coins = 30 };
await ShrinkSave.SaveSlotAsync(3, new SaveOptions { SlotName = "Slot3" });
await ShrinkSettings.LoadAsync();
ShrinkSettings.Set("ShrinkDataSaver.RecentSlotIndex", 3);
await ShrinkSettings.SaveAsync();
var preferred = await ShrinkSave.GetRecommendedContinueSlotAsync();
Assert.AreEqual(3, preferred);
await ShrinkSave.DeleteSlotAsync(3);
preferred = await ShrinkSave.GetRecommendedContinueSlotAsync();
Assert.AreEqual(0, preferred);
Assert.AreEqual(0, ShrinkSave.GetRecentSlotIndex());
});
}
[TestFixture]
public class LocalStorageProviderTests
{
private string _rootPath;
private LocalStorageProvider _provider;
[SetUp]
public void SetUp()
{
_rootPath = Path.Combine(Path.GetTempPath(), $"ShrinkDataSaverTests_{Guid.NewGuid():N}");
Directory.CreateDirectory(_rootPath);
_provider = new LocalStorageProvider(_rootPath);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_rootPath))
{
Directory.Delete(_rootPath, true);
}
}
[UnityTest]
public IEnumerator WriteAsync_RotatesTwoBackupsAtomically() => UniTask.ToCoroutine(async () =>
{
var relativePath = "saves/sample.sav";
await _provider.WriteAsync(relativePath, DataSerializer.Serialize(new { version = 1 }));
await _provider.WriteAsync(relativePath, DataSerializer.Serialize(new { version = 2 }));
await _provider.WriteAsync(relativePath, DataSerializer.Serialize(new { version = 3 }));
var primary = DataSerializer.Deserialize<JObject>(await _provider.ReadAsync(relativePath));
var backup1 = DataSerializer.Deserialize<JObject>(await _provider.ReadAsync(relativePath + ".bak1"));
var backup2 = DataSerializer.Deserialize<JObject>(await _provider.ReadAsync(relativePath + ".bak2"));
Assert.AreEqual(3, primary["version"]?.Value<int>());
Assert.AreEqual(2, backup1["version"]?.Value<int>());
Assert.AreEqual(1, backup2["version"]?.Value<int>());
});
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e2cf2ecb1a4173846bbcfb49d584d8bb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,212 @@
using System.Collections;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace ShrinkDataSaver.Tests
{
[TestFixture]
public class ShrinkSettingsTests
{
private MockStorageProvider _storage;
private ShrinkDataSaverSettings _settings;
[SetUp]
public void SetUp()
{
_storage = new MockStorageProvider();
_settings = ScriptableObject.CreateInstance<ShrinkDataSaverSettings>();
ShrinkDataSaverSettings.Instance = _settings;
ShrinkSettings.ResetForTesting();
ShrinkSettings.Initialize(_storage, "settings.json");
}
[TearDown]
public void TearDown()
{
ShrinkSettings.ResetForTesting();
if (_settings != null) Object.DestroyImmediate(_settings);
}
// ── Get / Set ──
[Test]
public void Set_Get_String()
{
ShrinkSettings.Set("name", "Alice");
Assert.AreEqual("Alice", ShrinkSettings.Get<string>("name"));
}
[Test]
public void Set_Get_Int()
{
ShrinkSettings.Set("score", 42);
Assert.AreEqual(42, ShrinkSettings.Get<int>("score"));
}
[Test]
public void Set_Get_Float()
{
ShrinkSettings.Set("volume", 0.75f);
Assert.AreEqual(0.75f, ShrinkSettings.Get<float>("volume"), 0.001f);
}
[Test]
public void Set_Get_Bool()
{
ShrinkSettings.Set("muted", true);
Assert.IsTrue(ShrinkSettings.Get<bool>("muted"));
}
[Test]
public void Set_Get_ComplexObject()
{
var data = new Dictionary<string, int> { { "a", 1 }, { "b", 2 } };
ShrinkSettings.Set("map", data);
var restored = ShrinkSettings.Get<Dictionary<string, int>>("map");
Assert.AreEqual(1, restored["a"]);
Assert.AreEqual(2, restored["b"]);
}
[Test]
public void Get_NonExistentKey_ReturnsDefault()
{
Assert.AreEqual(0, ShrinkSettings.Get<int>("missing"));
Assert.IsNull(ShrinkSettings.Get<string>("missing"));
Assert.AreEqual(99, ShrinkSettings.Get("missing", 99));
}
// ── Has / Remove ──
[Test]
public void Has_ExistingKey_ReturnsTrue()
{
ShrinkSettings.Set("key", "value");
Assert.IsTrue(ShrinkSettings.Has("key"));
}
[Test]
public void Has_NonExistentKey_ReturnsFalse()
{
Assert.IsFalse(ShrinkSettings.Has("ghost"));
}
[Test]
public void Remove_ExistingKey()
{
ShrinkSettings.Set("temp", 123);
Assert.IsTrue(ShrinkSettings.Has("temp"));
ShrinkSettings.Remove("temp");
Assert.IsFalse(ShrinkSettings.Has("temp"));
}
[Test]
public void Remove_NonExistentKey_NoError()
{
Assert.DoesNotThrow(() => ShrinkSettings.Remove("ghost"));
}
// ── GetAllRaw ──
[Test]
public void GetAllRaw_ReturnsAllSettings()
{
ShrinkSettings.Set("a", 1);
ShrinkSettings.Set("b", "two");
ShrinkSettings.Set("c", true);
var all = ShrinkSettings.GetAllRaw();
Assert.AreEqual(3, all.Count);
Assert.IsTrue(all.ContainsKey("a"));
Assert.IsTrue(all.ContainsKey("b"));
Assert.IsTrue(all.ContainsKey("c"));
}
// ── 事件 ──
[Test]
public void OnChanged_FiredOnSet()
{
string receivedKey = null;
object receivedValue = null;
ShrinkSettings.OnChanged += (k, v) => { receivedKey = k; receivedValue = v; };
ShrinkSettings.Set("volume", 0.5f);
Assert.AreEqual("volume", receivedKey);
}
[Test]
public void OnChanged_FiredOnRemove()
{
ShrinkSettings.Set("temp", "data");
string removedKey = null;
ShrinkSettings.OnChanged += (k, v) => { removedKey = k; };
ShrinkSettings.Remove("temp");
Assert.AreEqual("temp", removedKey);
}
// ── 持久化 ──
[UnityTest]
public IEnumerator SaveAsync_LoadAsync_RoundTrip() => UniTask.ToCoroutine(async () =>
{
ShrinkSettings.Set("persist_str", "hello");
ShrinkSettings.Set("persist_int", 42);
await ShrinkSettings.SaveAsync();
// 重置内存数据后重新加载
ShrinkSettings.ResetForTesting();
ShrinkSettings.Initialize(_storage, "settings.json");
await ShrinkSettings.LoadAsync();
Assert.AreEqual("hello", ShrinkSettings.Get<string>("persist_str"));
Assert.AreEqual(42, ShrinkSettings.Get<int>("persist_int"));
});
[UnityTest]
public IEnumerator LoadAsync_EmptyStorage_GivesCleanState() => UniTask.ToCoroutine(async () =>
{
await ShrinkSettings.LoadAsync();
Assert.AreEqual(0, ShrinkSettings.GetAllRaw().Count);
});
[UnityTest]
public IEnumerator LoadAsync_FallsBackToBackup_WhenPrimaryCorrupted() => UniTask.ToCoroutine(async () =>
{
ShrinkSettings.Set("lang", "zh_cn");
await ShrinkSettings.SaveAsync();
var backupBytes = await _storage.ReadAsync("settings.json");
await _storage.WriteAsync("settings.json.bak1", backupBytes);
// 用无法解析的字节模拟主文件损坏:结构合法但字段不符的 JSON 无法与“合法空设置”区分
await _storage.WriteAsync("settings.json", System.Text.Encoding.UTF8.GetBytes("{ \"lang\": \"zh_cn\" , broken"));
ShrinkSettings.ResetForTesting();
ShrinkSettings.Initialize(_storage, "settings.json");
await ShrinkSettings.LoadAsync();
Assert.AreEqual("zh_cn", ShrinkSettings.Get<string>("lang"));
var repairedPrimary = await _storage.ReadAsync("settings.json");
CollectionAssert.AreEqual(backupBytes, repairedPrimary);
});
// ── Overwrite ──
[Test]
public void Set_OverwriteExistingKey()
{
ShrinkSettings.Set("key", "first");
ShrinkSettings.Set("key", "second");
Assert.AreEqual("second", ShrinkSettings.Get<string>("key"));
}
}
}

Some files were not shown because too many files have changed in this diff Show More