From 739fe328e7bad16d61b741b26db89fe1e9ba0732 Mon Sep 17 00:00:00 2001 From: cneicy Date: Wed, 26 Aug 2026 02:50:03 +0800 Subject: [PATCH] chore: initialize standalone UPM package --- .gitea/workflows/publish.yml | 49 ++ .gitea/workflows/unity-verify.yml | 39 + .gitignore | 10 + .npmignore | 8 + CHANGELOG.md | 35 + CHANGELOG.md.meta | 7 + Development~/UnityProject/.gitignore | 6 + Development~/UnityProject/Assets/.gitkeep | 0 .../UnityProject/Packages/manifest.json | 15 + .../ProjectSettings/ProjectVersion.txt | 2 + Editor.meta | 3 + Editor/ShrinkDataSaver.Editor.asmdef | 15 + Editor/ShrinkDataSaver.Editor.asmdef.meta | 3 + Editor/ShrinkDataSaverEditorWindow.cs | 378 ++++++++++ Editor/ShrinkDataSaverEditorWindow.cs.meta | 3 + README.md | 540 ++++++++++++++ README.md.meta | 3 + Runtime.meta | 3 + Runtime/AssemblyInfo.cs | 3 + Runtime/AssemblyInfo.cs.meta | 11 + Runtime/DataSerializer.cs | 28 + Runtime/DataSerializer.cs.meta | 3 + Runtime/IStorageProvider.cs | 14 + Runtime/IStorageProvider.cs.meta | 3 + Runtime/LocalStorageProvider.cs | 197 +++++ Runtime/LocalStorageProvider.cs.meta | 3 + Runtime/MigrationChain.cs | 60 ++ Runtime/MigrationChain.cs.meta | 3 + Runtime/SaveEncryptor.cs | 86 +++ Runtime/SaveEncryptor.cs.meta | 3 + Runtime/SaveTypes.cs | 135 ++++ Runtime/SaveTypes.cs.meta | 3 + Runtime/ShrinkDataSaver.Runtime.asmdef | 17 + Runtime/ShrinkDataSaver.Runtime.asmdef.meta | 3 + Runtime/ShrinkDataSaverBootstrap.cs | 44 ++ Runtime/ShrinkDataSaverBootstrap.cs.meta | 3 + Runtime/ShrinkDataSaverRuntime.cs | 98 +++ Runtime/ShrinkDataSaverRuntime.cs.meta | 11 + Runtime/ShrinkDataSaverSettings.cs | 61 ++ Runtime/ShrinkDataSaverSettings.cs.meta | 3 + Runtime/ShrinkSave.cs | 673 ++++++++++++++++++ Runtime/ShrinkSave.cs.meta | 3 + Runtime/ShrinkSettings.cs | 180 +++++ Runtime/ShrinkSettings.cs.meta | 3 + Tests.meta | 8 + Tests/DataSerializerTests.cs | 81 +++ Tests/DataSerializerTests.cs.meta | 11 + Tests/MigrationChainTests.cs | 125 ++++ Tests/MigrationChainTests.cs.meta | 11 + Tests/MockStorageProvider.cs | 54 ++ Tests/MockStorageProvider.cs.meta | 11 + Tests/SaveEncryptorTests.cs | 89 +++ Tests/SaveEncryptorTests.cs.meta | 11 + Tests/SaveTypesTests.cs | 134 ++++ Tests/SaveTypesTests.cs.meta | 11 + Tests/ShrinkDataSaver.Tests.asmdef | 25 + Tests/ShrinkDataSaver.Tests.asmdef.meta | 7 + Tests/ShrinkSaveTests.cs | 629 ++++++++++++++++ Tests/ShrinkSaveTests.cs.meta | 11 + Tests/ShrinkSettingsTests.cs | 212 ++++++ Tests/ShrinkSettingsTests.cs.meta | 11 + package.json | 29 + package.json.meta | 3 + 63 files changed, 4243 insertions(+) create mode 100644 .gitea/workflows/publish.yml create mode 100644 .gitea/workflows/unity-verify.yml create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 CHANGELOG.md create mode 100644 CHANGELOG.md.meta create mode 100644 Development~/UnityProject/.gitignore create mode 100644 Development~/UnityProject/Assets/.gitkeep create mode 100644 Development~/UnityProject/Packages/manifest.json create mode 100644 Development~/UnityProject/ProjectSettings/ProjectVersion.txt create mode 100644 Editor.meta create mode 100644 Editor/ShrinkDataSaver.Editor.asmdef create mode 100644 Editor/ShrinkDataSaver.Editor.asmdef.meta create mode 100644 Editor/ShrinkDataSaverEditorWindow.cs create mode 100644 Editor/ShrinkDataSaverEditorWindow.cs.meta create mode 100644 README.md create mode 100644 README.md.meta create mode 100644 Runtime.meta create mode 100644 Runtime/AssemblyInfo.cs create mode 100644 Runtime/AssemblyInfo.cs.meta create mode 100644 Runtime/DataSerializer.cs create mode 100644 Runtime/DataSerializer.cs.meta create mode 100644 Runtime/IStorageProvider.cs create mode 100644 Runtime/IStorageProvider.cs.meta create mode 100644 Runtime/LocalStorageProvider.cs create mode 100644 Runtime/LocalStorageProvider.cs.meta create mode 100644 Runtime/MigrationChain.cs create mode 100644 Runtime/MigrationChain.cs.meta create mode 100644 Runtime/SaveEncryptor.cs create mode 100644 Runtime/SaveEncryptor.cs.meta create mode 100644 Runtime/SaveTypes.cs create mode 100644 Runtime/SaveTypes.cs.meta create mode 100644 Runtime/ShrinkDataSaver.Runtime.asmdef create mode 100644 Runtime/ShrinkDataSaver.Runtime.asmdef.meta create mode 100644 Runtime/ShrinkDataSaverBootstrap.cs create mode 100644 Runtime/ShrinkDataSaverBootstrap.cs.meta create mode 100644 Runtime/ShrinkDataSaverRuntime.cs create mode 100644 Runtime/ShrinkDataSaverRuntime.cs.meta create mode 100644 Runtime/ShrinkDataSaverSettings.cs create mode 100644 Runtime/ShrinkDataSaverSettings.cs.meta create mode 100644 Runtime/ShrinkSave.cs create mode 100644 Runtime/ShrinkSave.cs.meta create mode 100644 Runtime/ShrinkSettings.cs create mode 100644 Runtime/ShrinkSettings.cs.meta create mode 100644 Tests.meta create mode 100644 Tests/DataSerializerTests.cs create mode 100644 Tests/DataSerializerTests.cs.meta create mode 100644 Tests/MigrationChainTests.cs create mode 100644 Tests/MigrationChainTests.cs.meta create mode 100644 Tests/MockStorageProvider.cs create mode 100644 Tests/MockStorageProvider.cs.meta create mode 100644 Tests/SaveEncryptorTests.cs create mode 100644 Tests/SaveEncryptorTests.cs.meta create mode 100644 Tests/SaveTypesTests.cs create mode 100644 Tests/SaveTypesTests.cs.meta create mode 100644 Tests/ShrinkDataSaver.Tests.asmdef create mode 100644 Tests/ShrinkDataSaver.Tests.asmdef.meta create mode 100644 Tests/ShrinkSaveTests.cs create mode 100644 Tests/ShrinkSaveTests.cs.meta create mode 100644 Tests/ShrinkSettingsTests.cs create mode 100644 Tests/ShrinkSettingsTests.cs.meta create mode 100644 package.json create mode 100644 package.json.meta diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..6dab29f --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish UPM package + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + NODE_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }} + steps: + - name: Fetch tagged revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + test -n "$ref" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkDataSaver.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Validate immutable release version + shell: bash + run: | + set -eu + tag="$(git describe --exact-match --tags HEAD)" + version="$(node -p "require('./package.json').version")" + test "$tag" = "v$version" + npm pack --dry-run + + - name: Publish to ShrinkSDK registry + shell: bash + run: | + set -eu + : "${NODE_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}" + npmrc="$HOME/.npmrc" + cleanup() { rm -f "$npmrc"; } + trap cleanup EXIT + printf '%s\n' \ + 'registry=https://git.crash.work/api/packages/ShrinkSDK/npm/' \ + '//git.crash.work/api/packages/ShrinkSDK/npm/:_authToken=${NODE_AUTH_TOKEN}' > "$npmrc" + npm publish --registry=https://git.crash.work/api/packages/ShrinkSDK/npm/ \ No newline at end of file diff --git a/.gitea/workflows/unity-verify.yml b/.gitea/workflows/unity-verify.yml new file mode 100644 index 0000000..1c19bb4 --- /dev/null +++ b/.gitea/workflows/unity-verify.yml @@ -0,0 +1,39 @@ +name: Verify standalone Unity package + +on: + workflow_dispatch: + +jobs: + editmode: + runs-on: unity-2022.3.62f3 + container: + image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3 + volumes: + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses + steps: + - name: Fetch selected revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkDataSaver.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Run package EditMode tests + shell: bash + run: | + set -eu + unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)" + test -n "$unity_bin" + "$unity_bin" \ + -batchmode \ + -nographics \ + -quit \ + -projectPath "$PWD/Development~/UnityProject" \ + -runTests \ + -testPlatform EditMode \ + -testResults "$PWD/TestResults/editmode.xml" \ + -logFile "$PWD/TestResults/unity.log" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..246935d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/Development~/UnityProject/[Ll]ibrary/ +/Development~/UnityProject/[Tt]emp/ +/Development~/UnityProject/[Oo]bj/ +/Development~/UnityProject/[Ll]ogs/ +/Development~/UnityProject/[Uu]ser[Ss]ettings/ +/Development~/UnityProject/TestResults/ +/Tools~/**/[Bb]in/ +/Tools~/**/[Oo]bj/ +*.user +*.DotSettings.user diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..700f03d --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +.git/ +.gitea/ +Development~/ +Tools~/ +*.csproj +*.sln +*.user +*.DotSettings.user diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2e77c71 --- /dev/null +++ b/CHANGELOG.md @@ -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` 顶栏下。 diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta new file mode 100644 index 0000000..fc80cf2 --- /dev/null +++ b/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 78b8bd668657cd546a75bf4ae7cb3b8f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Development~/UnityProject/.gitignore b/Development~/UnityProject/.gitignore new file mode 100644 index 0000000..aa2eb00 --- /dev/null +++ b/Development~/UnityProject/.gitignore @@ -0,0 +1,6 @@ +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Ll]ogs/ +[Uu]ser[Ss]ettings/ +TestResults/ \ No newline at end of file diff --git a/Development~/UnityProject/Assets/.gitkeep b/Development~/UnityProject/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Development~/UnityProject/Packages/manifest.json b/Development~/UnityProject/Packages/manifest.json new file mode 100644 index 0000000..ead635c --- /dev/null +++ b/Development~/UnityProject/Packages/manifest.json @@ -0,0 +1,15 @@ +{ + "scopedRegistries": [ + { + "name": "ShrinkSDK", + "url": "https://git.crash.work/api/packages/ShrinkSDK/npm/", + "scopes": [ + "com.cneicy" + ] + } + ], + "dependencies": { + "com.unity.test-framework": "1.1.33", + "com.cneicy.shrink-datasaver": "file:../../.." + } +} diff --git a/Development~/UnityProject/ProjectSettings/ProjectVersion.txt b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt new file mode 100644 index 0000000..587f809 --- /dev/null +++ b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt @@ -0,0 +1,2 @@ +m_EditorVersion: 2022.3.62f3 +m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7) diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 0000000..d75e917 --- /dev/null +++ b/Editor.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9e02de79ba4c4595bc36097d194fdf52 +timeCreated: 1773047471 \ No newline at end of file diff --git a/Editor/ShrinkDataSaver.Editor.asmdef b/Editor/ShrinkDataSaver.Editor.asmdef new file mode 100644 index 0000000..49bacf9 --- /dev/null +++ b/Editor/ShrinkDataSaver.Editor.asmdef @@ -0,0 +1,15 @@ +{ + "name": "ShrinkDataSaver.Editor", + "rootNamespace": "ShrinkDataSaver.Editor", + "references": [ + "ShrinkDataSaver.Runtime", + "UniTask" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": false +} diff --git a/Editor/ShrinkDataSaver.Editor.asmdef.meta b/Editor/ShrinkDataSaver.Editor.asmdef.meta new file mode 100644 index 0000000..e590b96 --- /dev/null +++ b/Editor/ShrinkDataSaver.Editor.asmdef.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 78cf16cc0efe432a939df0e90a4cdb14 +timeCreated: 1773047512 \ No newline at end of file diff --git a/Editor/ShrinkDataSaverEditorWindow.cs b/Editor/ShrinkDataSaverEditorWindow.cs new file mode 100644 index 0000000..451f53c --- /dev/null +++ b/Editor/ShrinkDataSaverEditorWindow.cs @@ -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("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() ? "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 diff --git a/Editor/ShrinkDataSaverEditorWindow.cs.meta b/Editor/ShrinkDataSaverEditorWindow.cs.meta new file mode 100644 index 0000000..efc6ef2 --- /dev/null +++ b/Editor/ShrinkDataSaverEditorWindow.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0c285b35e7094111b6ea986ea5af391e +timeCreated: 1773047478 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..19626cd --- /dev/null +++ b/README.md @@ -0,0 +1,540 @@ +# ShrinkDataSaver + +一个为 Unity C# 项目设计的模块化存档与设置管理系统。支持多存档槽、链式版本迁移、可选 AES-256 加密、关键模块保护、跨模块只读查询,以及完整的事件驱动架构。 + +## ✨ 特性概览 + +| 特性 | 说明 | +|------|------| +| 💾 **模块化存档** | 按模块拆分存档数据,注册即用,读写隔离 | +| 🔢 **多存档槽** | 槽位数量可配置,支持元数据轻量查询 | +| 🔄 **链式版本迁移** | 注册迁移规则后自动链式执行,失败时整体回滚 | +| 🔒 **可选加密** | AES-256-CBC + PBKDF2-SHA256,随机 Salt/IV,密钥由使用者管理 | +| ⚡ **事件驱动** | 8 种原生事件,覆盖保存/加载/删除/迁移的完整生命周期 | +| 🔍 **跨模块查询** | `QueryModule` 只读查询其他模块的运行时数据 | +| 🛡️ **关键模块保护** | `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://git.crash.work/ShrinkSDK/ShrinkDataSaver.git" + } +} +``` + +或通过 Package Manager → `+` → `Add package from git URL` 输入: + +``` +https://git.crash.work/ShrinkSDK/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 +// 方式 A:Lambda(轻量) +ShrinkSave.RegisterModule( + key: "inventory", + serialize: () => inventoryManager.GetData(), + deserialize: data => inventoryManager.LoadData(data) +); + +// 方式 B:Lambda + 模块配置 +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 +{ + 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("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 + +// 监听变更 +ShrinkSettings.OnChanged += (key, value) => Debug.Log($"{key} = {value}"); +ShrinkSettings.Watch("MasterVolume", vol => ApplyVolume(vol)); +``` + +变更时立刻触发回调,写入磁盘有防抖延迟(默认 300ms),防止高频调用产生大量 IO。 + +### 加密 + +可选加密,默认关闭。使用 AES-256-CBC + PBKDF2-SHA256(10000 次迭代),每次加密随机生成 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://git.crash.work/ShrinkSDK/ShrinkDataSaver.Integration.EventBus) + +项目中同时包含 `ShrinkDataSaver.Integration.EventBus` 时,由 Context 组件或宿主显式管理桥接生命周期,将原生事件发布到指定的 ShrinkEventBus 2.0 Bus: + +```csharp +[ShrinkEventSubscriber(DefaultBus = "game")] +public sealed partial class SaveUIManager : MonoBehaviour +{ + [ShrinkSubscribe] + private void OnSaveCompleted(SaveCompletedEvent e) + { + ShowSaveIndicator(e.SlotIndex, e.ModuleNames); + } + + [ShrinkSubscribe] + private void OnLoadFailed(LoadFailedEvent e) + { + ShowErrorDialog(e.ErrorMessage); + } + + [ShrinkSubscribe] + private void OnSettingsChanged(SettingsChangedEvent e) + { + if (e.Key == "MasterVolume") + ApplyVolume(e.Get()); + } +} +``` + +普通 C# 对象通过 `EventBus.Attach(target)` 接入;MonoBehaviour 可使用 `ShrinkMonoEventScope`,也可在 `OnEnable`/`OnDisable` 中自行持有并释放 binding。桥接器本身由 `ShrinkDataSaverEventBusComponent` 或 `DataSaverEventBusBridge.Register()`/`Unregister()` 管理。 + +**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(string key, Func serialize, Action deserialize, ModuleConfig config = null) +ShrinkSave.UnregisterModule(string key) +ShrinkSave.HasModule(string moduleName) → bool +ShrinkSave.GetModuleConfig(string key) → ModuleConfig +ShrinkSave.GetRegisteredModuleNames() → IReadOnlyCollection +``` + +#### 存档操作 + +```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 +ShrinkSave.LoadedSlot → int (-1 = 未加载) +``` + +#### 元数据查询 + +```csharp +ShrinkSave.GetAllMetaAsync(CancellationToken) → UniTask +ShrinkSave.GetMetaAsync(int slotIndex, ct) → UniTask +ShrinkSave.GetRecentSlotIndex() → int +ShrinkSave.GetRecommendedContinueSlotAsync(ct) → UniTask +``` + +#### 跨模块查询 + +```csharp +ShrinkSave.QueryModule(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) +``` + +#### 事件 + +```csharp +ShrinkSave.OnSaveStarted += Action +ShrinkSave.OnSaveCompleted += Action +ShrinkSave.OnSaveFailed += Action +ShrinkSave.OnLoadStarted += Action +ShrinkSave.OnLoadCompleted += Action +ShrinkSave.OnLoadFailed += Action +ShrinkSave.OnMigrationCompleted += Action +ShrinkSave.OnDeleteCompleted += Action +``` + +### ShrinkSettings(静态门面) + +```csharp +ShrinkSettings.Set(string key, T value) +ShrinkSettings.Get(string key, T defaultValue = default) → T +ShrinkSettings.Has(string key) → bool +ShrinkSettings.Remove(string key) +ShrinkSettings.GetAllRaw() → IReadOnlyDictionary +ShrinkSettings.Watch(string key, Action callback) +ShrinkSettings.Unwatch(string key, Action callback) +ShrinkSettings.SaveAsync(CancellationToken) → UniTask +ShrinkSettings.LoadAsync(CancellationToken) → UniTask +ShrinkSettings.OnChanged += Action +``` + +--- + +## 🏗️ 架构说明 + +``` +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) diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 0000000..fb1bd4e --- /dev/null +++ b/README.md.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a0a2b66f2bb84ff1bfe75303700753dd +timeCreated: 1773048087 \ No newline at end of file diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..a6cb741 --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 4927a1e86fef478f9ab5e203485c50ce +timeCreated: 1773046045 \ No newline at end of file diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..035b952 --- /dev/null +++ b/Runtime/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ShrinkDataSaver.Tests")] diff --git a/Runtime/AssemblyInfo.cs.meta b/Runtime/AssemblyInfo.cs.meta new file mode 100644 index 0000000..4935ef5 --- /dev/null +++ b/Runtime/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e21adcea9de62441b55c09908f22530 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/DataSerializer.cs b/Runtime/DataSerializer.cs new file mode 100644 index 0000000..f5d124b --- /dev/null +++ b/Runtime/DataSerializer.cs @@ -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 obj) + => Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(obj, Settings)); + + public static T Deserialize(byte[] data) + => JsonConvert.DeserializeObject(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)); + } +} \ No newline at end of file diff --git a/Runtime/DataSerializer.cs.meta b/Runtime/DataSerializer.cs.meta new file mode 100644 index 0000000..7d9a8e5 --- /dev/null +++ b/Runtime/DataSerializer.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 19531b42fff04fd9b2fde3cfb450881e +timeCreated: 1773046107 \ No newline at end of file diff --git a/Runtime/IStorageProvider.cs b/Runtime/IStorageProvider.cs new file mode 100644 index 0000000..8a03f16 --- /dev/null +++ b/Runtime/IStorageProvider.cs @@ -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 ReadAsync(string path, CancellationToken ct = default); + UniTask ExistsAsync(string path, CancellationToken ct = default); + UniTask DeleteAsync(string path, CancellationToken ct = default); + UniTask ListAsync(string prefix = "", CancellationToken ct = default); + } +} \ No newline at end of file diff --git a/Runtime/IStorageProvider.cs.meta b/Runtime/IStorageProvider.cs.meta new file mode 100644 index 0000000..04db3d8 --- /dev/null +++ b/Runtime/IStorageProvider.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0ea76ef6ec0a455aabd1fe2346be4ddf +timeCreated: 1773046056 \ No newline at end of file diff --git a/Runtime/LocalStorageProvider.cs b/Runtime/LocalStorageProvider.cs new file mode 100644 index 0000000..c1d9c67 --- /dev/null +++ b/Runtime/LocalStorageProvider.cs @@ -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 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 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 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 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(); + } + + 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 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; + } + } +} diff --git a/Runtime/LocalStorageProvider.cs.meta b/Runtime/LocalStorageProvider.cs.meta new file mode 100644 index 0000000..e2f0bc8 --- /dev/null +++ b/Runtime/LocalStorageProvider.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: dde6d5dd1c3f47db9453a3d0122d39d4 +timeCreated: 1773046075 \ No newline at end of file diff --git a/Runtime/MigrationChain.cs b/Runtime/MigrationChain.cs new file mode 100644 index 0000000..dbc0ecb --- /dev/null +++ b/Runtime/MigrationChain.cs @@ -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 Migrations = new(); + + private struct Migration + { + public int ToVersion; + public Func Migrate; + } + + public static void Register(int fromVersion, int toVersion, Func 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(); + } +} \ No newline at end of file diff --git a/Runtime/MigrationChain.cs.meta b/Runtime/MigrationChain.cs.meta new file mode 100644 index 0000000..9fd1852 --- /dev/null +++ b/Runtime/MigrationChain.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 56cc513596cc4b30b82412e080e8b716 +timeCreated: 1773047408 \ No newline at end of file diff --git a/Runtime/SaveEncryptor.cs b/Runtime/SaveEncryptor.cs new file mode 100644 index 0000000..164215a --- /dev/null +++ b/Runtime/SaveEncryptor.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/Runtime/SaveEncryptor.cs.meta b/Runtime/SaveEncryptor.cs.meta new file mode 100644 index 0000000..142ae83 --- /dev/null +++ b/Runtime/SaveEncryptor.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 751846b93860451e8864bf71f593d4ff +timeCreated: 1773046087 \ No newline at end of file diff --git a/Runtime/SaveTypes.cs b/Runtime/SaveTypes.cs new file mode 100644 index 0000000..3b4c42e --- /dev/null +++ b/Runtime/SaveTypes.cs @@ -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 Modules { get; set; } = new(); + public string EncryptedModules { get; set; } + } + + + public interface ISaveModule + { + string Key { get; } + object SerializeRaw(); + void DeserializeRaw(JToken data); + } + + public interface ISaveModule : ISaveModule + { + T Serialize(); + void Deserialize(T data); + object ISaveModule.SerializeRaw() => Serialize(); + void ISaveModule.DeserializeRaw(JToken data) => Deserialize(data.ToObject()); + } + + internal class LambdaSaveModule : ISaveModule + { + public string Key { get; } + private readonly Func _serialize; + private readonly Action _deserialize; + + public LambdaSaveModule(string key, Func serialize, Action 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 + { + /// 是否参与云存档同步(设置模块应设为 false) + public bool EnableCloudSync { get; set; } = true; + + /// 自动保存间隔(秒),0 表示不自动保存 + public float AutoSaveIntervalSeconds { get; set; } = 0f; + + /// 是否为关键模块(关键模块序列化失败将中止整个保存操作) + 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; } + } +} diff --git a/Runtime/SaveTypes.cs.meta b/Runtime/SaveTypes.cs.meta new file mode 100644 index 0000000..fc3d389 --- /dev/null +++ b/Runtime/SaveTypes.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7cb5188d2a984a1b8f6a701789b8d0f1 +timeCreated: 1773047394 \ No newline at end of file diff --git a/Runtime/ShrinkDataSaver.Runtime.asmdef b/Runtime/ShrinkDataSaver.Runtime.asmdef new file mode 100644 index 0000000..27597bb --- /dev/null +++ b/Runtime/ShrinkDataSaver.Runtime.asmdef @@ -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 +} \ No newline at end of file diff --git a/Runtime/ShrinkDataSaver.Runtime.asmdef.meta b/Runtime/ShrinkDataSaver.Runtime.asmdef.meta new file mode 100644 index 0000000..83b9712 --- /dev/null +++ b/Runtime/ShrinkDataSaver.Runtime.asmdef.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5b60ff3063fc45e39b61d1df0c2a6339 +timeCreated: 1773047537 \ No newline at end of file diff --git a/Runtime/ShrinkDataSaverBootstrap.cs b/Runtime/ShrinkDataSaverBootstrap.cs new file mode 100644 index 0000000..c1f2fe0 --- /dev/null +++ b/Runtime/ShrinkDataSaverBootstrap.cs @@ -0,0 +1,44 @@ +using UnityEngine; + +namespace ShrinkDataSaver +{ + /// + /// 兼容旧用法:仍可把该组件挂到首场景中, + /// 但真正初始化已下沉到 ShrinkDataSaverRuntime,便于宿主层统一接管。 + /// + [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 + }); + } + } +} diff --git a/Runtime/ShrinkDataSaverBootstrap.cs.meta b/Runtime/ShrinkDataSaverBootstrap.cs.meta new file mode 100644 index 0000000..bc70980 --- /dev/null +++ b/Runtime/ShrinkDataSaverBootstrap.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 30e24d3823c349c99bd78fa0992be0e7 +timeCreated: 1773047454 \ No newline at end of file diff --git a/Runtime/ShrinkDataSaverRuntime.cs b/Runtime/ShrinkDataSaverRuntime.cs new file mode 100644 index 0000000..aaa8cbb --- /dev/null +++ b/Runtime/ShrinkDataSaverRuntime.cs @@ -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(); + 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(); + } + } +} diff --git a/Runtime/ShrinkDataSaverRuntime.cs.meta b/Runtime/ShrinkDataSaverRuntime.cs.meta new file mode 100644 index 0000000..818a9e8 --- /dev/null +++ b/Runtime/ShrinkDataSaverRuntime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d9b14a9eecf6eb746b24241eac07c939 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkDataSaverSettings.cs b/Runtime/ShrinkDataSaverSettings.cs new file mode 100644 index 0000000..1e1cb44 --- /dev/null +++ b/Runtime/ShrinkDataSaverSettings.cs @@ -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"); + +#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(path); + } + } +#endif + + if (!_instance) + { + _instance = CreateInstance(); + 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; + } +} diff --git a/Runtime/ShrinkDataSaverSettings.cs.meta b/Runtime/ShrinkDataSaverSettings.cs.meta new file mode 100644 index 0000000..11b10c0 --- /dev/null +++ b/Runtime/ShrinkDataSaverSettings.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5df22787ec2544b9abbb40cf28536c7e +timeCreated: 1773047342 \ No newline at end of file diff --git a/Runtime/ShrinkSave.cs b/Runtime/ShrinkSave.cs new file mode 100644 index 0000000..ecda16b --- /dev/null +++ b/Runtime/ShrinkSave.cs @@ -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 _modules = new(); + private static readonly Dictionary _moduleConfigs = new(); + + private static int _loadedSlot = -1; + private static float _sessionStart; + private static float _storedPlaytime; + + private static Dictionary _loadedModuleData; + + public static event Action OnSaveStarted; + public static event Action OnSaveCompleted; + public static event Action OnSaveFailed; + public static event Action OnLoadStarted; + public static event Action OnLoadCompleted; + public static event Action OnLoadFailed; + public static event Action OnMigrationCompleted; + public static event Action 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(string key, Func serialize, Action deserialize, ModuleConfig config = null) + { + _modules[key] = new LambdaSaveModule(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 GetRegisteredModuleNames() => _modules.Keys; + + public static T QueryModule(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(); + } + 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 GetAllMetaAsync(CancellationToken ct = default) + { + var results = new List(); + var files = await _storage.ListAsync(_savesDir, ct); + var discoveredSlots = new HashSet(); + + 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 SlotExistsAsync(int slotIndex, CancellationToken ct = default) + => await _storage.ExistsAsync(SlotPath(slotIndex), ct); + + public static async UniTask 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 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(); + var moduleNames = new List(); + + 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(loadedModules); + + var moduleNames = new List(); + 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 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 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 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 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 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(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 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(bytes); + if (packet?.Meta == null) + { + throw new InvalidDataException($"反序列化槽位 {slotIndex} 失败。"); + } + + Dictionary 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>(decryptedBytes); + } + else + { + loadedModules = packet.Modules ?? new Dictionary(); + } + + 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(); + 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 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)); + } + } +} diff --git a/Runtime/ShrinkSave.cs.meta b/Runtime/ShrinkSave.cs.meta new file mode 100644 index 0000000..ec0105b --- /dev/null +++ b/Runtime/ShrinkSave.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d41b28112039451ca4fca14c901a91a9 +timeCreated: 1773047436 \ No newline at end of file diff --git a/Runtime/ShrinkSettings.cs b/Runtime/ShrinkSettings.cs new file mode 100644 index 0000000..6bcb650 --- /dev/null +++ b/Runtime/ShrinkSettings.cs @@ -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 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>> _watchers = new(); + + public static event Action OnChanged; + + internal static void Initialize(IStorageProvider storage, string filePath) + { + _storage = storage; _filePath = filePath; + } + + public static void Set(string key, T value) + { + _data.Values[key] = JToken.FromObject(value); + FireChanged(key, value); + ScheduleWrite().Forget(); + } + + public static T Get(string key, T defaultValue = default) + { + if (_data.Values.TryGetValue(key, out var token)) + { + try { return token.ToObject(); } catch { /* ignore */ } + } + return defaultValue; + } + + public static bool Has(string key) => _data.Values.ContainsKey(key); + + public static IReadOnlyDictionary GetAllRaw() => _data.Values; + + public static void Remove(string key) + { + if (_data.Values.Remove(key)) + { + FireChanged(key, null); + ScheduleWrite().Forget(); + } + } + + public static void Watch(string key, Action callback) + { + if (!_watchers.ContainsKey(key)) _watchers[key] = new List>(); + _watchers[key].Add(raw => callback((T)Convert.ChangeType(raw, typeof(T)))); + } + + public static void Unwatch(string key, Action 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 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(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 EnumerateCandidatePaths(string primaryPath) + { + yield return primaryPath; + yield return primaryPath + BackupPrimarySuffix; + yield return primaryPath + BackupSecondarySuffix; + } + } +} diff --git a/Runtime/ShrinkSettings.cs.meta b/Runtime/ShrinkSettings.cs.meta new file mode 100644 index 0000000..6f8fa76 --- /dev/null +++ b/Runtime/ShrinkSettings.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ffdd053c674640a9987f34ddd93080d8 +timeCreated: 1773047370 \ No newline at end of file diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..be34e9d --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3ce4b0a2f2d4f304aa1f93b4ceccbf92 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/DataSerializerTests.cs b/Tests/DataSerializerTests.cs new file mode 100644 index 0000000..a738459 --- /dev/null +++ b/Tests/DataSerializerTests.cs @@ -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(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(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(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")); + } + } +} diff --git a/Tests/DataSerializerTests.cs.meta b/Tests/DataSerializerTests.cs.meta new file mode 100644 index 0000000..d8aec64 --- /dev/null +++ b/Tests/DataSerializerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 818cff0034bbd7d4aa0c64c46fc28343 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/MigrationChainTests.cs b/Tests/MigrationChainTests.cs new file mode 100644 index 0000000..7955684 --- /dev/null +++ b/Tests/MigrationChainTests.cs @@ -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(() => + MigrationChain.Register(2, 1, data => data)); + + Assert.Throws(() => + 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()); + Assert.IsFalse(result.ContainsKey("gold")); + Assert.AreEqual(42, result["version3Field"].Value()); + } + + [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()); + } + } +} diff --git a/Tests/MigrationChainTests.cs.meta b/Tests/MigrationChainTests.cs.meta new file mode 100644 index 0000000..0dc66a9 --- /dev/null +++ b/Tests/MigrationChainTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dba29246ec289aa4d9798abaef89a7bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/MockStorageProvider.cs b/Tests/MockStorageProvider.cs new file mode 100644 index 0000000..eca4f72 --- /dev/null +++ b/Tests/MockStorageProvider.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Cysharp.Threading.Tasks; + +namespace ShrinkDataSaver.Tests +{ + /// + /// 内存模拟存储,用于单元测试,无需文件 I/O。 + /// + public class MockStorageProvider : IStorageProvider + { + private readonly Dictionary _store = new(); + + public UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default) + { + _store[NormalizePath(path)] = data; + return UniTask.CompletedTask; + } + + public UniTask 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 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 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('\\', '/'); + } +} diff --git a/Tests/MockStorageProvider.cs.meta b/Tests/MockStorageProvider.cs.meta new file mode 100644 index 0000000..0cc9a91 --- /dev/null +++ b/Tests/MockStorageProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b20797d49fc7bfc47a22c7be5bbd6cf2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/SaveEncryptorTests.cs b/Tests/SaveEncryptorTests.cs new file mode 100644 index 0000000..7c8fa99 --- /dev/null +++ b/Tests/SaveEncryptorTests.cs @@ -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(() => + 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(() => + SaveEncryptor.Encrypt(null, "key")); + } + + [Test] + public void Encrypt_EmptyPassword_Throws() + { + Assert.Throws(() => + SaveEncryptor.Encrypt(new byte[] { 1, 2, 3 }, "")); + } + + [Test] + public void Decrypt_NullData_Throws() + { + Assert.Throws(() => + SaveEncryptor.Decrypt(null, "key")); + } + + [Test] + public void Decrypt_DataTooShort_Throws() + { + Assert.Throws(() => + SaveEncryptor.Decrypt(new byte[] { 1, 2, 3 }, "key")); + } + } +} diff --git a/Tests/SaveEncryptorTests.cs.meta b/Tests/SaveEncryptorTests.cs.meta new file mode 100644 index 0000000..a326d5b --- /dev/null +++ b/Tests/SaveEncryptorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e5040f6e0d2203349aea6b1d2c85a46d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/SaveTypesTests.cs b/Tests/SaveTypesTests.cs new file mode 100644 index 0000000..ef0427c --- /dev/null +++ b/Tests/SaveTypesTests.cs @@ -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("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("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; + } + } +} diff --git a/Tests/SaveTypesTests.cs.meta b/Tests/SaveTypesTests.cs.meta new file mode 100644 index 0000000..70a87e4 --- /dev/null +++ b/Tests/SaveTypesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4fec6ba5af68dd46ae44776da037c69 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/ShrinkDataSaver.Tests.asmdef b/Tests/ShrinkDataSaver.Tests.asmdef new file mode 100644 index 0000000..5fe79e4 --- /dev/null +++ b/Tests/ShrinkDataSaver.Tests.asmdef @@ -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 +} \ No newline at end of file diff --git a/Tests/ShrinkDataSaver.Tests.asmdef.meta b/Tests/ShrinkDataSaver.Tests.asmdef.meta new file mode 100644 index 0000000..b35451b --- /dev/null +++ b/Tests/ShrinkDataSaver.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ea4bb7f0bb4396d4e9454b19f4630613 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/ShrinkSaveTests.cs b/Tests/ShrinkSaveTests.cs new file mode 100644 index 0000000..f2650d0 --- /dev/null +++ b/Tests/ShrinkSaveTests.cs @@ -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(); + _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("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("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("player"); + Assert.IsNotNull(result); + Assert.AreEqual("Query", result.Name); + Assert.AreEqual(5, result.Level); + } + + [Test] + public void QueryModule_NonExistentModule_ReturnsDefault() + { + var result = ShrinkSave.QueryModule("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(await _provider.ReadAsync(relativePath)); + var backup1 = DataSerializer.Deserialize(await _provider.ReadAsync(relativePath + ".bak1")); + var backup2 = DataSerializer.Deserialize(await _provider.ReadAsync(relativePath + ".bak2")); + + Assert.AreEqual(3, primary["version"]?.Value()); + Assert.AreEqual(2, backup1["version"]?.Value()); + Assert.AreEqual(1, backup2["version"]?.Value()); + }); + } +} diff --git a/Tests/ShrinkSaveTests.cs.meta b/Tests/ShrinkSaveTests.cs.meta new file mode 100644 index 0000000..ea503e5 --- /dev/null +++ b/Tests/ShrinkSaveTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e2cf2ecb1a4173846bbcfb49d584d8bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/ShrinkSettingsTests.cs b/Tests/ShrinkSettingsTests.cs new file mode 100644 index 0000000..a2de758 --- /dev/null +++ b/Tests/ShrinkSettingsTests.cs @@ -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.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("name")); + } + + [Test] + public void Set_Get_Int() + { + ShrinkSettings.Set("score", 42); + Assert.AreEqual(42, ShrinkSettings.Get("score")); + } + + [Test] + public void Set_Get_Float() + { + ShrinkSettings.Set("volume", 0.75f); + Assert.AreEqual(0.75f, ShrinkSettings.Get("volume"), 0.001f); + } + + [Test] + public void Set_Get_Bool() + { + ShrinkSettings.Set("muted", true); + Assert.IsTrue(ShrinkSettings.Get("muted")); + } + + [Test] + public void Set_Get_ComplexObject() + { + var data = new Dictionary { { "a", 1 }, { "b", 2 } }; + ShrinkSettings.Set("map", data); + var restored = ShrinkSettings.Get>("map"); + + Assert.AreEqual(1, restored["a"]); + Assert.AreEqual(2, restored["b"]); + } + + [Test] + public void Get_NonExistentKey_ReturnsDefault() + { + Assert.AreEqual(0, ShrinkSettings.Get("missing")); + Assert.IsNull(ShrinkSettings.Get("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("persist_str")); + Assert.AreEqual(42, ShrinkSettings.Get("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("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("key")); + } + } +} diff --git a/Tests/ShrinkSettingsTests.cs.meta b/Tests/ShrinkSettingsTests.cs.meta new file mode 100644 index 0000000..5cd8af3 --- /dev/null +++ b/Tests/ShrinkSettingsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 40ae83c4b82a9c24dbd209f7a03f08e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json new file mode 100644 index 0000000..2232d2f --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "com.cneicy.shrink-datasaver", + "version": "2.2.0", + "displayName": "ShrinkDataSaver", + "description": "模块化 Unity 存档与设置管理系统,支持多槽位、链式版本迁移、AES-256 加密、关键模块保护、跨模块查询与事件驱动架构。", + "unity": "2022.3", + "documentationUrl": "https://git.crash.work/ShrinkSDK/ShrinkDataSaver", + "changelogUrl": "https://git.crash.work/ShrinkSDK/ShrinkDataSaver/src/branch/main/CHANGELOG.md", + "licensesUrl": "https://github.com/cneicy/ShrinkDataSaver/blob/main/LICENSE", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.2.1", + "com.cysharp.unitask": "2.5.0", + "com.unity.modules.imageconversion": "1.0.0", + "com.unity.modules.screencapture": "1.0.0" + }, + "keywords": [ + "save", + "load", + "data", + "settings", + "serialization", + "encryption", + "migration" + ], + "author": { + "name": "cneicy", + "url": "https://git.crash.work/ShrinkSDK" + } +} diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..93d116e --- /dev/null +++ b/package.json.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: eaff7d7dedd446b88dfdbe10d7bf3187 +timeCreated: 1774092581 \ No newline at end of file