From b5d13e86d9b1ab92f6ed1652b1884a8551d2e2e5 Mon Sep 17 00:00:00 2001 From: Sebastian Bularca Date: Fri, 27 Mar 2026 15:14:08 +0100 Subject: [PATCH] copy from github --- .gitignore | 24 +++ Editor.meta | 8 + Editor/Jovian.SaveSystem.Editor.asmdef | 18 ++ Editor/Jovian.SaveSystem.Editor.asmdef.meta | 7 + Editor/SaveSystemSettingsProvider.cs | 49 +++++ Editor/SaveSystemSettingsProvider.cs.meta | 2 + LICENSE | 21 ++ LICENSE.meta | 7 + README.md | 198 ++++++++++++++++- README.md.meta | 7 + Runtime.meta | 8 + Runtime/ISaveSerializer.cs | 10 + Runtime/ISaveSerializer.cs.meta | 2 + Runtime/ISaveSlotManager.cs | 23 ++ Runtime/ISaveSlotManager.cs.meta | 2 + Runtime/ISaveStorage.cs | 22 ++ Runtime/ISaveStorage.cs.meta | 2 + Runtime/ISaveSystem.cs | 24 +++ Runtime/ISaveSystem.cs.meta | 2 + Runtime/Jovian.SaveSystem.asmdef | 16 ++ Runtime/Jovian.SaveSystem.asmdef.meta | 7 + Runtime/Model.meta | 8 + Runtime/Model/SaveEnvelope.cs | 16 ++ Runtime/Model/SaveEnvelope.cs.meta | 2 + Runtime/Model/SaveFormat.cs | 6 + Runtime/Model/SaveFormat.cs.meta | 2 + Runtime/Model/SaveSlotType.cs | 7 + Runtime/Model/SaveSlotType.cs.meta | 2 + Runtime/SaveSystem.cs | 138 ++++++++++++ Runtime/SaveSystem.cs.meta | 2 + Runtime/Serialization.meta | 8 + Runtime/Serialization/BinarySaveSerializer.cs | 75 +++++++ .../BinarySaveSerializer.cs.meta | 2 + Runtime/Serialization/JsonSaveSerializer.cs | 34 +++ .../Serialization/JsonSaveSerializer.cs.meta | 2 + Runtime/Settings.meta | 8 + Runtime/Settings/SaveSystemSettings.cs | 48 +++++ Runtime/Settings/SaveSystemSettings.cs.meta | 2 + Runtime/Slots.meta | 8 + Runtime/Slots/SaveSessionInfo.cs | 16 ++ Runtime/Slots/SaveSessionInfo.cs.meta | 2 + Runtime/Slots/SaveSlotInfo.cs | 26 +++ Runtime/Slots/SaveSlotInfo.cs.meta | 2 + Runtime/Slots/SaveSlotManager.cs | 202 ++++++++++++++++++ Runtime/Slots/SaveSlotManager.cs.meta | 2 + Runtime/Storage.meta | 8 + Runtime/Storage/FileSystemSaveStorage.cs | 107 ++++++++++ Runtime/Storage/FileSystemSaveStorage.cs.meta | 2 + Tests.meta | 8 + Tests/Editor.meta | 8 + .../Jovian.SaveSystem.EditorTests.asmdef | 24 +++ .../Jovian.SaveSystem.EditorTests.asmdef.meta | 7 + Tests/Editor/SaveSerializerTests.cs | 104 +++++++++ Tests/Editor/SaveSerializerTests.cs.meta | 2 + Tests/Editor/SaveSlotManagerTests.cs | 150 +++++++++++++ Tests/Editor/SaveSlotManagerTests.cs.meta | 2 + Tests/Editor/SaveSystemFacadeTests.cs | 143 +++++++++++++ Tests/Editor/SaveSystemFacadeTests.cs.meta | 2 + Tests/Runtime.meta | 8 + .../Jovian.SaveSystem.RuntimeTests.asmdef | 22 ++ ...Jovian.SaveSystem.RuntimeTests.asmdef.meta | 7 + package.json | 18 ++ package.json.meta | 7 + 63 files changed, 1706 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 Editor.meta create mode 100644 Editor/Jovian.SaveSystem.Editor.asmdef create mode 100644 Editor/Jovian.SaveSystem.Editor.asmdef.meta create mode 100644 Editor/SaveSystemSettingsProvider.cs create mode 100644 Editor/SaveSystemSettingsProvider.cs.meta create mode 100644 LICENSE create mode 100644 LICENSE.meta create mode 100644 README.md.meta create mode 100644 Runtime.meta create mode 100644 Runtime/ISaveSerializer.cs create mode 100644 Runtime/ISaveSerializer.cs.meta create mode 100644 Runtime/ISaveSlotManager.cs create mode 100644 Runtime/ISaveSlotManager.cs.meta create mode 100644 Runtime/ISaveStorage.cs create mode 100644 Runtime/ISaveStorage.cs.meta create mode 100644 Runtime/ISaveSystem.cs create mode 100644 Runtime/ISaveSystem.cs.meta create mode 100644 Runtime/Jovian.SaveSystem.asmdef create mode 100644 Runtime/Jovian.SaveSystem.asmdef.meta create mode 100644 Runtime/Model.meta create mode 100644 Runtime/Model/SaveEnvelope.cs create mode 100644 Runtime/Model/SaveEnvelope.cs.meta create mode 100644 Runtime/Model/SaveFormat.cs create mode 100644 Runtime/Model/SaveFormat.cs.meta create mode 100644 Runtime/Model/SaveSlotType.cs create mode 100644 Runtime/Model/SaveSlotType.cs.meta create mode 100644 Runtime/SaveSystem.cs create mode 100644 Runtime/SaveSystem.cs.meta create mode 100644 Runtime/Serialization.meta create mode 100644 Runtime/Serialization/BinarySaveSerializer.cs create mode 100644 Runtime/Serialization/BinarySaveSerializer.cs.meta create mode 100644 Runtime/Serialization/JsonSaveSerializer.cs create mode 100644 Runtime/Serialization/JsonSaveSerializer.cs.meta create mode 100644 Runtime/Settings.meta create mode 100644 Runtime/Settings/SaveSystemSettings.cs create mode 100644 Runtime/Settings/SaveSystemSettings.cs.meta create mode 100644 Runtime/Slots.meta create mode 100644 Runtime/Slots/SaveSessionInfo.cs create mode 100644 Runtime/Slots/SaveSessionInfo.cs.meta create mode 100644 Runtime/Slots/SaveSlotInfo.cs create mode 100644 Runtime/Slots/SaveSlotInfo.cs.meta create mode 100644 Runtime/Slots/SaveSlotManager.cs create mode 100644 Runtime/Slots/SaveSlotManager.cs.meta create mode 100644 Runtime/Storage.meta create mode 100644 Runtime/Storage/FileSystemSaveStorage.cs create mode 100644 Runtime/Storage/FileSystemSaveStorage.cs.meta create mode 100644 Tests.meta create mode 100644 Tests/Editor.meta create mode 100644 Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef create mode 100644 Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef.meta create mode 100644 Tests/Editor/SaveSerializerTests.cs create mode 100644 Tests/Editor/SaveSerializerTests.cs.meta create mode 100644 Tests/Editor/SaveSlotManagerTests.cs create mode 100644 Tests/Editor/SaveSlotManagerTests.cs.meta create mode 100644 Tests/Editor/SaveSystemFacadeTests.cs create mode 100644 Tests/Editor/SaveSystemFacadeTests.cs.meta create mode 100644 Tests/Runtime.meta create mode 100644 Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef create mode 100644 Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef.meta create mode 100644 package.json create mode 100644 package.json.meta diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b9aaac --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +### Unity +# Unity generated directories +/[Ll]ibrary/ +/[Tt]emp/ +/[Oo]bj/ +/[Bb]uild/ +/[Bb]uilds/ +/[Ll]ogs/ +/[Uu]ser[Ss]ettings/ +/[Mm]emory[Cc]aptures/ + +# Asset meta data should only be ignored when the corresponding asset is also ignored +!/[Aa]ssets/**/*.meta + +# Build output +*.apk +*.aab +*.unitypackage + +# Autogenerated solution and project files +*.csproj +*.unityproj +*.sln +*.suo \ No newline at end of file diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 0000000..15ab13e --- /dev/null +++ b/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 59da007fca3e0ac4e9a5096277c37275 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Jovian.SaveSystem.Editor.asmdef b/Editor/Jovian.SaveSystem.Editor.asmdef new file mode 100644 index 0000000..47354b3 --- /dev/null +++ b/Editor/Jovian.SaveSystem.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "Jovian.SaveSystem.Editor", + "rootNamespace": "Jovian.SaveSystem.Editor", + "references": [ + "Jovian.SaveSystem" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Editor/Jovian.SaveSystem.Editor.asmdef.meta b/Editor/Jovian.SaveSystem.Editor.asmdef.meta new file mode 100644 index 0000000..749c969 --- /dev/null +++ b/Editor/Jovian.SaveSystem.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bbc94d83b5c0e8c4c8c529cc64c94ddf +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/SaveSystemSettingsProvider.cs b/Editor/SaveSystemSettingsProvider.cs new file mode 100644 index 0000000..cfa0161 --- /dev/null +++ b/Editor/SaveSystemSettingsProvider.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using UnityEditor; + +namespace Jovian.SaveSystem.Editor { + public sealed class SaveSystemSettingsProvider : SettingsProvider { + private SaveSystemSettings settings; + private SerializedObject serializedSettings; + + private SaveSystemSettingsProvider(string path, SettingsScope scope) + : base(path, scope) { } + + public override void OnActivate(string searchContext, UnityEngine.UIElements.VisualElement rootElement) { + settings = SaveSystemSettings.Load(); + } + + public override void OnGUI(string searchContext) { + EditorGUILayout.Space(10); + EditorGUILayout.LabelField("Save System Settings", EditorStyles.boldLabel); + EditorGUILayout.Space(5); + + EditorGUI.BeginChangeCheck(); + + settings.saveFormat = (SaveFormat)EditorGUILayout.EnumPopup("Save Format", settings.saveFormat); + settings.maxAutoSavesPerSession = EditorGUILayout.IntSlider("Max Auto Saves Per Session", settings.maxAutoSavesPerSession, 1, 10); + settings.currentSaveVersion = EditorGUILayout.IntField("Current Save Version", settings.currentSaveVersion); + settings.saveDirectoryName = EditorGUILayout.TextField("Save Directory Name", settings.saveDirectoryName); + + EditorGUILayout.Space(10); + EditorGUILayout.LabelField("Binary Obfuscation", EditorStyles.boldLabel); + settings.obfuscationKey = EditorGUILayout.TextField("Obfuscation Key", settings.obfuscationKey); + + if(settings.saveFormat == SaveFormat.Json) { + EditorGUILayout.HelpBox("JSON format is human-readable and suitable for development. Switch to Binary for release builds.", MessageType.Info); + } + + if(EditorGUI.EndChangeCheck()) { + settings.Save(); + } + } + + [SettingsProvider] + public static SettingsProvider CreateProvider() { + SaveSystemSettingsProvider provider = new SaveSystemSettingsProvider("Project/Jovian/Save System", SettingsScope.Project) { + keywords = new HashSet(new[] { "save", "persistence", "serialization", "binary", "json" }) + }; + return provider; + } + } +} diff --git a/Editor/SaveSystemSettingsProvider.cs.meta b/Editor/SaveSystemSettingsProvider.cs.meta new file mode 100644 index 0000000..682fecb --- /dev/null +++ b/Editor/SaveSystemSettingsProvider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 34f9753365e282e4f8c610df1cc61e28 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..472990a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sebastian Bularca + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE.meta b/LICENSE.meta new file mode 100644 index 0000000..89bbdb7 --- /dev/null +++ b/LICENSE.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 230d63e981df3274ab47c00223c7b6c0 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index 6dd22d6..0c78262 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,197 @@ -# unity-save-system +# Jovian Save System -A generic, game-agnostic save system supporting multiple save slots, sessions, auto-saves, and dual JSON/Binary serialization. \ No newline at end of file +A generic, game-agnostic save system for Unity supporting multiple save slots, sessions, auto-saves, and dual JSON/Binary serialization. + +## Requirements + +- Unity 2022.3+ +- Newtonsoft Json (`com.unity.nuget.newtonsoft-json` 3.2.1) + +## Installation + +Add as a git submodule or reference via Unity Package Manager: + +``` +https://github.com/sbularca/unity-save-system.git +``` + +## Architecture + +``` +ISaveSystem (facade) +├── ISaveSlotManager — session and slot allocation +├── ISaveSerializer — data encoding (JSON or Binary) +└── ISaveStorage — byte-level file I/O +``` + +All components are interface-driven and injected via constructor, making them easy to swap or mock for testing. + +## Configuration + +Settings are accessible in the Unity Editor under **Project Settings > Jovian > Save System**. + +| Setting | Default | Description | +|---------|---------|-------------| +| Save Format | Json | Serialization format (Json or Binary) | +| Max Auto Saves | 3 | Auto-save slots before rotation begins | +| Save Version | 1 | Version number embedded in each save for migration | +| Save Directory | "saves" | Subfolder under `Application.persistentDataPath` | +| Obfuscation Key | "default-key" | XOR key used by the Binary serializer | + +## Save Slot Types + +| Type | Behavior | +|------|----------| +| **Manual** | Player-initiated. Each save gets a new slot (manual_001, manual_002, ...) | +| **Auto** | System-initiated. Creates slots up to the configured max, then rotates the oldest | +| **Quick** | Single slot per session. Always overwrites the previous quick save | + +## Quick Start + +### 1. Define your save data + +```csharp +public class GameState { + public string playerName; + public int level; + public float health; +} +``` + +### 2. Wire up the save system + +```csharp +SaveSystemSettings settings = SaveSystemSettings.Load(); + +ISaveStorage storage = new FileSystemSaveStorage( + Application.persistentDataPath, settings.saveDirectoryName); + +ISaveSerializer serializer = settings.saveFormat == SaveFormat.Binary + ? new BinarySaveSerializer(settings.obfuscationKey) + : new JsonSaveSerializer(); + +ISaveSlotManager slotManager = new SaveSlotManager(storage, settings); + +ISaveSystem saveSystem = new SaveSystem(serializer, storage, slotManager, settings); +``` + +### 3. Create a session and save + +```csharp +string sessionId = saveSystem.CreateSession(); + +GameState state = new GameState { + playerName = "Hero", + level = 10, + health = 95.5f +}; + +// Manual save +saveSystem.Save(sessionId, state, SaveSlotType.Manual); + +// Auto save +saveSystem.Save(sessionId, state, SaveSlotType.Auto); + +// Quick save +saveSystem.Save(sessionId, state, SaveSlotType.Quick); +``` + +### 4. Load a save + +```csharp +IReadOnlyList slots = saveSystem.GetSlots(sessionId); + +foreach(SaveSlotInfo slot in slots) { + Debug.Log($"{slot.DisplayLabel} — {slot.TimestampDateTime}"); +} + +GameState loaded = saveSystem.Load(slots[0]); +``` + +### 5. Async save/load + +```csharp +await saveSystem.SaveAsync(sessionId, state, SaveSlotType.Auto); + +GameState loaded = await saveSystem.LoadAsync(slots[0]); +``` + +## Session Management + +Sessions group related saves together (e.g. one playthrough). + +```csharp +// List all sessions +IReadOnlyList sessions = saveSystem.GetAllSessions(); + +foreach(SaveSessionInfo session in sessions) { + Debug.Log($"Session {session.sessionId} — Last save: {session.LastSaveDateTime}"); +} + +// Check if any saves exist +bool hasSaves = saveSystem.HasAnySaves(); + +// Delete a single slot +saveSystem.DeleteSlot(slots[0]); + +// Delete an entire session and all its saves +saveSystem.DeleteSession(sessionId); +``` + +## Serialization Formats + +### JSON (`JsonSaveSerializer`) + +Human-readable, indented JSON. Ideal for development and debugging. Save files are plain text and easy to inspect. + +### Binary (`BinarySaveSerializer`) + +Compact binary format that applies XOR obfuscation with a configurable key followed by Deflate compression. Recommended for release builds — smaller file size and not trivially editable. + +## Testing + +The interfaces make it easy to test with an in-memory storage implementation: + +```csharp +public class InMemorySaveStorage : ISaveStorage { + private readonly Dictionary files + = new Dictionary(); + + public void Write(string path, byte[] data) { files[path] = data; } + public byte[] Read(string path) { return files[path]; } + public bool Exists(string path) { return files.ContainsKey(path); } + public void Delete(string path) { files.Remove(path); } + public string[] List(string dir) { + return files.Keys.Where(k => k.StartsWith(dir)).ToArray(); + } + public void CreateDirectory(string path) { } + + // Async versions delegate to sync + public Task WriteAsync(string path, byte[] data) { Write(path, data); return Task.CompletedTask; } + public Task ReadAsync(string path) => Task.FromResult(Read(path)); + public Task ExistsAsync(string path) => Task.FromResult(Exists(path)); + public Task DeleteAsync(string path) { Delete(path); return Task.CompletedTask; } + public Task ListAsync(string dir) => Task.FromResult(List(dir)); +} +``` + +Then wire it up in your test: + +```csharp +InMemorySaveStorage storage = new InMemorySaveStorage(); +SaveSystemSettings settings = new SaveSystemSettings(); +JsonSaveSerializer serializer = new JsonSaveSerializer(); +SaveSlotManager slotManager = new SaveSlotManager(storage, settings); +ISaveSystem saveSystem = new SaveSystem(serializer, storage, slotManager, settings); + +string sessionId = saveSystem.CreateSession(); +saveSystem.Save(sessionId, myData, SaveSlotType.Manual); + +IReadOnlyList slots = saveSystem.GetSlots(sessionId); +MyData loaded = saveSystem.Load(slots[0]); +Assert.AreEqual(myData.playerName, loaded.playerName); +``` + +## License + +Internal package — Jovian Industries. diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 0000000..8197d27 --- /dev/null +++ b/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6af1b651ff0c3854f907408768835b5c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..fca17cc --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b869b6ce172663a42adca34c78db2f78 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ISaveSerializer.cs b/Runtime/ISaveSerializer.cs new file mode 100644 index 0000000..11c23b5 --- /dev/null +++ b/Runtime/ISaveSerializer.cs @@ -0,0 +1,10 @@ +namespace Jovian.SaveSystem { + /// + /// Converts typed data to and from byte arrays. + /// Implementations define the format (JSON, binary, etc.). + /// + public interface ISaveSerializer { + byte[] Serialize(TData data); + TData Deserialize(byte[] payload); + } +} diff --git a/Runtime/ISaveSerializer.cs.meta b/Runtime/ISaveSerializer.cs.meta new file mode 100644 index 0000000..6aca437 --- /dev/null +++ b/Runtime/ISaveSerializer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 27f27474b2fc12f47b9eea77568ac3c6 \ No newline at end of file diff --git a/Runtime/ISaveSlotManager.cs b/Runtime/ISaveSlotManager.cs new file mode 100644 index 0000000..3f43d95 --- /dev/null +++ b/Runtime/ISaveSlotManager.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace Jovian.SaveSystem { + /// + /// Manages save slot allocation, session tracking, and auto-save rotation. + /// + public interface ISaveSlotManager { + SaveSlotInfo AllocateManualSlot(string sessionId); + SaveSlotInfo AllocateAutoSlot(string sessionId); + SaveSlotInfo AllocateQuickSlot(string sessionId); + + IReadOnlyList GetSlots(string sessionId); + IReadOnlyList GetAllSessions(); + + string CreateSession(); + void DeleteSlot(SaveSlotInfo slot); + void DeleteSession(string sessionId); + bool HasAnySaves(); + + void UpdateSlotMetadata(SaveSlotInfo slot, long timestampUtc, int saveVersion); + void PersistIndex(); + } +} diff --git a/Runtime/ISaveSlotManager.cs.meta b/Runtime/ISaveSlotManager.cs.meta new file mode 100644 index 0000000..e6656ea --- /dev/null +++ b/Runtime/ISaveSlotManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd45cbabfe17114419035e9591ac06ce \ No newline at end of file diff --git a/Runtime/ISaveStorage.cs b/Runtime/ISaveStorage.cs new file mode 100644 index 0000000..7ee6a74 --- /dev/null +++ b/Runtime/ISaveStorage.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; + +namespace Jovian.SaveSystem { + /// + /// Reads and writes raw byte arrays to a persistent location. + /// Has no knowledge of save data types or serialization formats. + /// + public interface ISaveStorage { + void Write(string path, byte[] data); + byte[] Read(string path); + bool Exists(string path); + void Delete(string path); + string[] List(string directoryPath); + void CreateDirectory(string path); + + Task WriteAsync(string path, byte[] data); + Task ReadAsync(string path); + Task ExistsAsync(string path); + Task DeleteAsync(string path); + Task ListAsync(string directoryPath); + } +} diff --git a/Runtime/ISaveStorage.cs.meta b/Runtime/ISaveStorage.cs.meta new file mode 100644 index 0000000..2d3adbe --- /dev/null +++ b/Runtime/ISaveStorage.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5f445979ba5d9414abd75819f53bfaba \ No newline at end of file diff --git a/Runtime/ISaveSystem.cs b/Runtime/ISaveSystem.cs new file mode 100644 index 0000000..a4aee5a --- /dev/null +++ b/Runtime/ISaveSystem.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Jovian.SaveSystem { + /// + /// Top-level facade for the save system. Orchestrates serialization, + /// storage, and slot management. This is the main API the game interacts with. + /// + public interface ISaveSystem { + string CreateSession(); + bool HasAnySaves(); + + void Save(string sessionId, TData data, SaveSlotType slotType); + TData Load(SaveSlotInfo slot); + + Task SaveAsync(string sessionId, TData data, SaveSlotType slotType); + Task LoadAsync(SaveSlotInfo slot); + + IReadOnlyList GetSlots(string sessionId); + IReadOnlyList GetAllSessions(); + void DeleteSlot(SaveSlotInfo slot); + void DeleteSession(string sessionId); + } +} diff --git a/Runtime/ISaveSystem.cs.meta b/Runtime/ISaveSystem.cs.meta new file mode 100644 index 0000000..9d77ebb --- /dev/null +++ b/Runtime/ISaveSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 54cb074c17a51c74d93aadb3ce7b2e47 \ No newline at end of file diff --git a/Runtime/Jovian.SaveSystem.asmdef b/Runtime/Jovian.SaveSystem.asmdef new file mode 100644 index 0000000..2c0fc90 --- /dev/null +++ b/Runtime/Jovian.SaveSystem.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Jovian.SaveSystem", + "rootNamespace": "Jovian.SaveSystem", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "Newtonsoft.Json.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Runtime/Jovian.SaveSystem.asmdef.meta b/Runtime/Jovian.SaveSystem.asmdef.meta new file mode 100644 index 0000000..7e7ce8f --- /dev/null +++ b/Runtime/Jovian.SaveSystem.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a19ae8824b9d43447be860535961727d +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Model.meta b/Runtime/Model.meta new file mode 100644 index 0000000..f7a5aa6 --- /dev/null +++ b/Runtime/Model.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1f99186168750fb469321dee33fd3397 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Model/SaveEnvelope.cs b/Runtime/Model/SaveEnvelope.cs new file mode 100644 index 0000000..71cc04c --- /dev/null +++ b/Runtime/Model/SaveEnvelope.cs @@ -0,0 +1,16 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace Jovian.SaveSystem { + /// + /// On-disk wrapper that pairs minimal metadata with the game data payload. + /// Payload is stored as JToken so it remains readable JSON when using JsonSaveSerializer. + /// + [Serializable] + public sealed class SaveEnvelope { + public int version; + public long timestampUtc; + public SaveSlotType slotType; + public JToken payload; + } +} diff --git a/Runtime/Model/SaveEnvelope.cs.meta b/Runtime/Model/SaveEnvelope.cs.meta new file mode 100644 index 0000000..fe79c68 --- /dev/null +++ b/Runtime/Model/SaveEnvelope.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ffa4184b4e99e3947ac73fa747d346d7 \ No newline at end of file diff --git a/Runtime/Model/SaveFormat.cs b/Runtime/Model/SaveFormat.cs new file mode 100644 index 0000000..c7ad2b8 --- /dev/null +++ b/Runtime/Model/SaveFormat.cs @@ -0,0 +1,6 @@ +namespace Jovian.SaveSystem { + public enum SaveFormat { + Json, + Binary + } +} diff --git a/Runtime/Model/SaveFormat.cs.meta b/Runtime/Model/SaveFormat.cs.meta new file mode 100644 index 0000000..dce65e5 --- /dev/null +++ b/Runtime/Model/SaveFormat.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a118c9ab8d89ad546ad67e8dc1a71c98 \ No newline at end of file diff --git a/Runtime/Model/SaveSlotType.cs b/Runtime/Model/SaveSlotType.cs new file mode 100644 index 0000000..396bf3e --- /dev/null +++ b/Runtime/Model/SaveSlotType.cs @@ -0,0 +1,7 @@ +namespace Jovian.SaveSystem { + public enum SaveSlotType { + Manual, + Auto, + Quick + } +} diff --git a/Runtime/Model/SaveSlotType.cs.meta b/Runtime/Model/SaveSlotType.cs.meta new file mode 100644 index 0000000..1282951 --- /dev/null +++ b/Runtime/Model/SaveSlotType.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0f1e478bd3e3fec43a55f4efec51577c \ No newline at end of file diff --git a/Runtime/SaveSystem.cs b/Runtime/SaveSystem.cs new file mode 100644 index 0000000..1e6064d --- /dev/null +++ b/Runtime/SaveSystem.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace Jovian.SaveSystem { + /// + /// Facade that orchestrates serialization, storage, and slot management. + /// Thread-safe: uses SemaphoreSlim for async and lock for sync operations. + /// + public sealed class SaveSystem : ISaveSystem { + private readonly ISaveSerializer serializer; + private readonly ISaveStorage storage; + private readonly ISaveSlotManager slotManager; + private readonly int saveVersion; + + private readonly object syncLock = new object(); + private readonly SemaphoreSlim asyncLock = new SemaphoreSlim(1, 1); + + public SaveSystem( + ISaveSerializer serializer, + ISaveStorage storage, + ISaveSlotManager slotManager, + SaveSystemSettings settings) { + this.serializer = serializer; + this.storage = storage; + this.slotManager = slotManager; + saveVersion = settings.currentSaveVersion; + } + + public string CreateSession() { + return slotManager.CreateSession(); + } + + public bool HasAnySaves() { + return slotManager.HasAnySaves(); + } + + public void Save(string sessionId, TData data, SaveSlotType slotType) { + lock(syncLock) { + SaveSlotInfo slot = AllocateSlot(sessionId, slotType); + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + SaveEnvelope envelope = new SaveEnvelope { + version = saveVersion, + timestampUtc = timestamp, + slotType = slotType, + payload = JToken.FromObject(data) + }; + + byte[] envelopeBytes = serializer.Serialize(envelope); + storage.Write(slot.filePath, envelopeBytes); + + slotManager.UpdateSlotMetadata(slot, timestamp, saveVersion); + slotManager.PersistIndex(); + + Debug.Log($"[SaveSystem] Saved {slotType} to {slot.filePath}"); + } + } + + public TData Load(SaveSlotInfo slot) { + lock(syncLock) { + return LoadInternal(slot); + } + } + + public async Task SaveAsync(string sessionId, TData data, SaveSlotType slotType) { + await asyncLock.WaitAsync().ConfigureAwait(false); + try { + SaveSlotInfo slot = AllocateSlot(sessionId, slotType); + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + SaveEnvelope envelope = new SaveEnvelope { + version = saveVersion, + timestampUtc = timestamp, + slotType = slotType, + payload = JToken.FromObject(data) + }; + + byte[] envelopeBytes = serializer.Serialize(envelope); + await storage.WriteAsync(slot.filePath, envelopeBytes).ConfigureAwait(false); + + slotManager.UpdateSlotMetadata(slot, timestamp, saveVersion); + slotManager.PersistIndex(); + + Debug.Log($"[SaveSystem] Saved {slotType} to {slot.filePath}"); + } finally { + asyncLock.Release(); + } + } + + public async Task LoadAsync(SaveSlotInfo slot) { + await asyncLock.WaitAsync().ConfigureAwait(false); + try { + return LoadInternal(slot); + } finally { + asyncLock.Release(); + } + } + + public IReadOnlyList GetSlots(string sessionId) { + return slotManager.GetSlots(sessionId); + } + + public IReadOnlyList GetAllSessions() { + return slotManager.GetAllSessions(); + } + + public void DeleteSlot(SaveSlotInfo slot) { + lock(syncLock) { + slotManager.DeleteSlot(slot); + } + } + + public void DeleteSession(string sessionId) { + lock(syncLock) { + slotManager.DeleteSession(sessionId); + } + } + + private SaveSlotInfo AllocateSlot(string sessionId, SaveSlotType slotType) { + return slotType switch { + SaveSlotType.Manual => slotManager.AllocateManualSlot(sessionId), + SaveSlotType.Auto => slotManager.AllocateAutoSlot(sessionId), + SaveSlotType.Quick => slotManager.AllocateQuickSlot(sessionId), + _ => throw new ArgumentOutOfRangeException(nameof(slotType), slotType, "Unknown slot type.") + }; + } + + private TData LoadInternal(SaveSlotInfo slot) { + byte[] envelopeBytes = storage.Read(slot.filePath); + SaveEnvelope envelope = serializer.Deserialize(envelopeBytes); + return envelope.payload.ToObject(); + } + } +} diff --git a/Runtime/SaveSystem.cs.meta b/Runtime/SaveSystem.cs.meta new file mode 100644 index 0000000..bfcf3f9 --- /dev/null +++ b/Runtime/SaveSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1ef842c1c48d58641b6016af3c512d39 \ No newline at end of file diff --git a/Runtime/Serialization.meta b/Runtime/Serialization.meta new file mode 100644 index 0000000..7c7948c --- /dev/null +++ b/Runtime/Serialization.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ee824fed683906741ab35b553a76486a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Serialization/BinarySaveSerializer.cs b/Runtime/Serialization/BinarySaveSerializer.cs new file mode 100644 index 0000000..dc049e8 --- /dev/null +++ b/Runtime/Serialization/BinarySaveSerializer.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using Newtonsoft.Json; + +namespace Jovian.SaveSystem { + /// + /// Serializes data to an obfuscated binary format. + /// Pipeline: JSON string → UTF-8 bytes → XOR obfuscation → DeflateStream compression. + /// + public sealed class BinarySaveSerializer : ISaveSerializer { + private readonly byte[] keyBytes; + private readonly JsonSerializerSettings serializerSettings; + + public BinarySaveSerializer(string obfuscationKey) { + if(string.IsNullOrEmpty(obfuscationKey)) { + throw new ArgumentException("Obfuscation key must not be null or empty.", nameof(obfuscationKey)); + } + + keyBytes = Encoding.UTF8.GetBytes(obfuscationKey); + serializerSettings = new JsonSerializerSettings { + TypeNameHandling = TypeNameHandling.None, + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.None + }; + } + + public byte[] Serialize(TData data) { + string json = JsonConvert.SerializeObject(data, serializerSettings); + byte[] jsonBytes = Encoding.UTF8.GetBytes(json); + byte[] obfuscated = ApplyXor(jsonBytes); + return Compress(obfuscated); + } + + public TData Deserialize(byte[] payload) { + if(payload == null || payload.Length == 0) { + throw new ArgumentException("Payload is null or empty.", nameof(payload)); + } + + byte[] decompressed = Decompress(payload); + byte[] deobfuscated = ApplyXor(decompressed); + string json = Encoding.UTF8.GetString(deobfuscated); + return JsonConvert.DeserializeObject(json, serializerSettings); + } + + private byte[] ApplyXor(byte[] data) { + byte[] result = new byte[data.Length]; + for(int i = 0; i < data.Length; i++) { + result[i] = (byte)(data[i] ^ keyBytes[i % keyBytes.Length]); + } + return result; + } + + private static byte[] Compress(byte[] data) { + using(MemoryStream output = new MemoryStream()) { + using(DeflateStream deflate = new DeflateStream(output, CompressionLevel.Fastest, leaveOpen: true)) { + deflate.Write(data, 0, data.Length); + } + return output.ToArray(); + } + } + + private static byte[] Decompress(byte[] data) { + using(MemoryStream input = new MemoryStream(data)) { + using(DeflateStream deflate = new DeflateStream(input, CompressionMode.Decompress)) { + using(MemoryStream output = new MemoryStream()) { + deflate.CopyTo(output); + return output.ToArray(); + } + } + } + } + } +} diff --git a/Runtime/Serialization/BinarySaveSerializer.cs.meta b/Runtime/Serialization/BinarySaveSerializer.cs.meta new file mode 100644 index 0000000..8b65353 --- /dev/null +++ b/Runtime/Serialization/BinarySaveSerializer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f7af3b3f06e67b408b0f52089acdeea \ No newline at end of file diff --git a/Runtime/Serialization/JsonSaveSerializer.cs b/Runtime/Serialization/JsonSaveSerializer.cs new file mode 100644 index 0000000..8ee5904 --- /dev/null +++ b/Runtime/Serialization/JsonSaveSerializer.cs @@ -0,0 +1,34 @@ +using System; +using System.Text; +using Newtonsoft.Json; + +namespace Jovian.SaveSystem { + /// + /// Serializes data to/from JSON using Newtonsoft.Json. + /// + public sealed class JsonSaveSerializer : ISaveSerializer { + private readonly JsonSerializerSettings serializerSettings; + + public JsonSaveSerializer() { + serializerSettings = new JsonSerializerSettings { + TypeNameHandling = TypeNameHandling.None, + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.Indented + }; + } + + public byte[] Serialize(TData data) { + string json = JsonConvert.SerializeObject(data, serializerSettings); + return Encoding.UTF8.GetBytes(json); + } + + public TData Deserialize(byte[] payload) { + if(payload == null || payload.Length == 0) { + throw new ArgumentException("Payload is null or empty.", nameof(payload)); + } + + string json = Encoding.UTF8.GetString(payload); + return JsonConvert.DeserializeObject(json, serializerSettings); + } + } +} diff --git a/Runtime/Serialization/JsonSaveSerializer.cs.meta b/Runtime/Serialization/JsonSaveSerializer.cs.meta new file mode 100644 index 0000000..05fe7b3 --- /dev/null +++ b/Runtime/Serialization/JsonSaveSerializer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2edaf26270d17a145a2c7f1fa7080d3a \ No newline at end of file diff --git a/Runtime/Settings.meta b/Runtime/Settings.meta new file mode 100644 index 0000000..6a502a3 --- /dev/null +++ b/Runtime/Settings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a4191975d6dce814bbda5a57c1d17548 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Settings/SaveSystemSettings.cs b/Runtime/Settings/SaveSystemSettings.cs new file mode 100644 index 0000000..e4315b4 --- /dev/null +++ b/Runtime/Settings/SaveSystemSettings.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using UnityEngine; + +namespace Jovian.SaveSystem { + /// + /// Configuration for the save system. Stored as JSON in ProjectSettings/SaveSystemSettings.json. + /// + [Serializable] + public sealed class SaveSystemSettings { + private const string SettingsPath = "ProjectSettings/SaveSystemSettings.json"; + + public SaveFormat saveFormat = SaveFormat.Json; + public int maxAutoSavesPerSession = 3; + public int currentSaveVersion = 1; + public string obfuscationKey = "default-key"; + public string saveDirectoryName = "saves"; + + public static SaveSystemSettings Load() { + if(!File.Exists(SettingsPath)) { + return new SaveSystemSettings(); + } + + try { + var json = File.ReadAllText(SettingsPath); + return JsonConvert.DeserializeObject(json) ?? new SaveSystemSettings(); + } catch(Exception e) { + Debug.LogWarning($"[SaveSystem] Failed to load settings: {e.Message}. Using defaults."); + return new SaveSystemSettings(); + } + } + + public void Save() { + try { + var directory = Path.GetDirectoryName(SettingsPath); + if(!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { + Directory.CreateDirectory(directory); + } + + var json = JsonConvert.SerializeObject(this, Formatting.Indented); + File.WriteAllText(SettingsPath, json); + } catch(Exception e) { + Debug.LogError($"[SaveSystem] Failed to save settings: {e.Message}"); + } + } + } +} diff --git a/Runtime/Settings/SaveSystemSettings.cs.meta b/Runtime/Settings/SaveSystemSettings.cs.meta new file mode 100644 index 0000000..1c6d748 --- /dev/null +++ b/Runtime/Settings/SaveSystemSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 86b7c6be79eeb6a48aa96d9041dcb5ed \ No newline at end of file diff --git a/Runtime/Slots.meta b/Runtime/Slots.meta new file mode 100644 index 0000000..5b38cd6 --- /dev/null +++ b/Runtime/Slots.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b7eaf086b5f24df42a8d6a79de5c5c52 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Slots/SaveSessionInfo.cs b/Runtime/Slots/SaveSessionInfo.cs new file mode 100644 index 0000000..3f2823b --- /dev/null +++ b/Runtime/Slots/SaveSessionInfo.cs @@ -0,0 +1,16 @@ +using System; + +namespace Jovian.SaveSystem { + /// + /// Describes a save session (a new game playthrough). + /// + [Serializable] + public sealed class SaveSessionInfo { + public string sessionId; + public long creationDateUtc; + public long lastSaveDateUtc; + + public DateTime CreationDateTime => DateTimeOffset.FromUnixTimeMilliseconds(creationDateUtc).UtcDateTime; + public DateTime LastSaveDateTime => DateTimeOffset.FromUnixTimeMilliseconds(lastSaveDateUtc).UtcDateTime; + } +} diff --git a/Runtime/Slots/SaveSessionInfo.cs.meta b/Runtime/Slots/SaveSessionInfo.cs.meta new file mode 100644 index 0000000..7f4ac36 --- /dev/null +++ b/Runtime/Slots/SaveSessionInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f03201423cd9f364c85bc6ce44386fbd \ No newline at end of file diff --git a/Runtime/Slots/SaveSlotInfo.cs b/Runtime/Slots/SaveSlotInfo.cs new file mode 100644 index 0000000..311a345 --- /dev/null +++ b/Runtime/Slots/SaveSlotInfo.cs @@ -0,0 +1,26 @@ +using System; + +namespace Jovian.SaveSystem { + /// + /// Describes a single save slot within a session. + /// + [Serializable] + public sealed class SaveSlotInfo { + public string sessionId; + public SaveSlotType slotType; + public int slotNumber; + public string filePath; + public long timestampUtc; + public int saveVersion; + + public string DisplayLabel => + slotType switch { + SaveSlotType.Manual => $"Manual Save {slotNumber}", + SaveSlotType.Auto => $"Auto Save {slotNumber}", + SaveSlotType.Quick => "Quick Save", + _ => "Unknown" + }; + + public DateTime TimestampDateTime => DateTimeOffset.FromUnixTimeMilliseconds(timestampUtc).UtcDateTime; + } +} diff --git a/Runtime/Slots/SaveSlotInfo.cs.meta b/Runtime/Slots/SaveSlotInfo.cs.meta new file mode 100644 index 0000000..a306e56 --- /dev/null +++ b/Runtime/Slots/SaveSlotInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c10256ba563528a4c9d92d158c6c009c \ No newline at end of file diff --git a/Runtime/Slots/SaveSlotManager.cs b/Runtime/Slots/SaveSlotManager.cs new file mode 100644 index 0000000..cb33f05 --- /dev/null +++ b/Runtime/Slots/SaveSlotManager.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json; + +namespace Jovian.SaveSystem { + /// + /// Manages save slot allocation, session tracking, and auto-save rotation. + /// Maintains a persistent index file that tracks all sessions and their slots. + /// + public sealed class SaveSlotManager : ISaveSlotManager { + private const string IndexFileName = "index.json"; + private const string ManualPrefix = "manual_"; + private const string AutoPrefix = "auto_"; + private const string QuickFileName = "quick.sav"; + private const string SaveExtension = ".sav"; + + private readonly ISaveStorage storage; + private readonly int maxAutoSaves; + private SaveIndex index; + + public SaveSlotManager(ISaveStorage storage, SaveSystemSettings settings) { + this.storage = storage; + maxAutoSaves = settings.maxAutoSavesPerSession; + LoadIndex(); + } + + public string CreateSession() { + string sessionId = Guid.NewGuid().ToString("N"); + SaveSessionInfo session = new SaveSessionInfo { + sessionId = sessionId, + creationDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + lastSaveDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + index.sessions.Add(session); + storage.CreateDirectory(sessionId); + PersistIndex(); + return sessionId; + } + + public SaveSlotInfo AllocateManualSlot(string sessionId) { + List sessionSlots = GetOrCreateSessionSlots(sessionId); + int nextNumber = sessionSlots + .Where(s => s.slotType == SaveSlotType.Manual) + .Select(s => s.slotNumber) + .DefaultIfEmpty(0) + .Max() + 1; + + SaveSlotInfo slot = new SaveSlotInfo { + sessionId = sessionId, + slotType = SaveSlotType.Manual, + slotNumber = nextNumber, + filePath = $"{sessionId}/{ManualPrefix}{nextNumber:D3}{SaveExtension}", + timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + index.slots.Add(slot); + return slot; + } + + public SaveSlotInfo AllocateAutoSlot(string sessionId) { + List sessionSlots = GetOrCreateSessionSlots(sessionId); + List autoSlots = sessionSlots + .Where(s => s.slotType == SaveSlotType.Auto) + .OrderBy(s => s.slotNumber) + .ToList(); + + if(autoSlots.Count >= maxAutoSaves) { + // Rotate: reuse the oldest slot + var oldest = autoSlots.OrderBy(s => s.timestampUtc).First(); + oldest.timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return oldest; + } + + int nextNumber = autoSlots.Count + 1; + SaveSlotInfo slot = new SaveSlotInfo { + sessionId = sessionId, + slotType = SaveSlotType.Auto, + slotNumber = nextNumber, + filePath = $"{sessionId}/{AutoPrefix}{nextNumber:D3}{SaveExtension}", + timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + index.slots.Add(slot); + return slot; + } + + public SaveSlotInfo AllocateQuickSlot(string sessionId) { + List sessionSlots = GetOrCreateSessionSlots(sessionId); + SaveSlotInfo existingQuick = sessionSlots.FirstOrDefault(s => s.slotType == SaveSlotType.Quick); + + if(existingQuick != null) { + existingQuick.timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return existingQuick; + } + + SaveSlotInfo slot = new SaveSlotInfo { + sessionId = sessionId, + slotType = SaveSlotType.Quick, + slotNumber = 1, + filePath = $"{sessionId}/{QuickFileName}", + timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + index.slots.Add(slot); + return slot; + } + + public IReadOnlyList GetSlots(string sessionId) { + return index.slots + .Where(s => s.sessionId == sessionId) + .OrderByDescending(s => s.timestampUtc) + .ToList(); + } + + public IReadOnlyList GetAllSessions() { + return index.sessions + .OrderByDescending(s => s.lastSaveDateUtc) + .ToList(); + } + + public bool HasAnySaves() { + return index.slots.Count > 0; + } + + public void DeleteSlot(SaveSlotInfo slot) { + storage.Delete(slot.filePath); + index.slots.RemoveAll(s => s.filePath == slot.filePath); + PersistIndex(); + } + + public void DeleteSession(string sessionId) { + List slotsToDelete = index.slots + .Where(s => s.sessionId == sessionId) + .ToList(); + + foreach(SaveSlotInfo slot in slotsToDelete) { + storage.Delete(slot.filePath); + } + + index.slots.RemoveAll(s => s.sessionId == sessionId); + index.sessions.RemoveAll(s => s.sessionId == sessionId); + PersistIndex(); + } + + public void UpdateSlotMetadata(SaveSlotInfo slot, long timestampUtc, int saveVersion) { + slot.timestampUtc = timestampUtc; + slot.saveVersion = saveVersion; + + SaveSessionInfo session = index.sessions.FirstOrDefault(s => s.sessionId == slot.sessionId); + if(session != null) { + session.lastSaveDateUtc = timestampUtc; + } + } + + public void PersistIndex() { + string json = JsonConvert.SerializeObject(index, Formatting.Indented); + byte[] bytes = Encoding.UTF8.GetBytes(json); + storage.Write(IndexFileName, bytes); + } + + private void LoadIndex() { + if(!storage.Exists(IndexFileName)) { + index = new SaveIndex(); + return; + } + + try { + byte[] bytes = storage.Read(IndexFileName); + string json = Encoding.UTF8.GetString(bytes); + index = JsonConvert.DeserializeObject(json) ?? new SaveIndex(); + } catch(Exception) { + index = new SaveIndex(); + } + } + + private List GetOrCreateSessionSlots(string sessionId) { + if(!index.sessions.Any(s => s.sessionId == sessionId)) { + SaveSessionInfo session = new SaveSessionInfo { + sessionId = sessionId, + creationDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + lastSaveDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + index.sessions.Add(session); + storage.CreateDirectory(sessionId); + } + + return index.slots.Where(s => s.sessionId == sessionId).ToList(); + } + + /// + /// Internal index structure persisted to disk. + /// + [Serializable] + private sealed class SaveIndex { + public List sessions = new List(); + public List slots = new List(); + } + } +} diff --git a/Runtime/Slots/SaveSlotManager.cs.meta b/Runtime/Slots/SaveSlotManager.cs.meta new file mode 100644 index 0000000..48210e0 --- /dev/null +++ b/Runtime/Slots/SaveSlotManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9498521d42a48944ba38a961e8e82459 \ No newline at end of file diff --git a/Runtime/Storage.meta b/Runtime/Storage.meta new file mode 100644 index 0000000..ff47bc2 --- /dev/null +++ b/Runtime/Storage.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a74728833fd95214a8e55433cc901ac6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Storage/FileSystemSaveStorage.cs b/Runtime/Storage/FileSystemSaveStorage.cs new file mode 100644 index 0000000..6c078ef --- /dev/null +++ b/Runtime/Storage/FileSystemSaveStorage.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using UnityEngine; + +namespace Jovian.SaveSystem { + /// + /// File-system backed storage. Base path is typically Application.persistentDataPath. + /// All paths passed to methods are relative to the constructed base path. + /// + public sealed class FileSystemSaveStorage : ISaveStorage { + private readonly string basePath; + + public FileSystemSaveStorage(string rootPath, string saveDirectoryName) { + basePath = Path.Combine(rootPath, saveDirectoryName); + } + + private string ResolvePath(string relativePath) { + return Path.Combine(basePath, relativePath); + } + + public void CreateDirectory(string path) { + string fullPath = ResolvePath(path); + if(!Directory.Exists(fullPath)) { + Directory.CreateDirectory(fullPath); + } + } + + public void Write(string path, byte[] data) { + string fullPath = ResolvePath(path); + EnsureDirectory(fullPath); + File.WriteAllBytes(fullPath, data); + } + + public byte[] Read(string path) { + string fullPath = ResolvePath(path); + if(!File.Exists(fullPath)) { + throw new FileNotFoundException($"Save file not found: {fullPath}"); + } + return File.ReadAllBytes(fullPath); + } + + public bool Exists(string path) { + return File.Exists(ResolvePath(path)); + } + + public void Delete(string path) { + string fullPath = ResolvePath(path); + if(File.Exists(fullPath)) { + File.Delete(fullPath); + } + } + + public string[] List(string directoryPath) { + string fullPath = ResolvePath(directoryPath); + if(!Directory.Exists(fullPath)) { + return Array.Empty(); + } + + return Directory.GetFiles(fullPath) + .Select(f => Path.GetFileName(f)) + .ToArray(); + } + + public async Task WriteAsync(string path, byte[] data) { + string fullPath = ResolvePath(path); + EnsureDirectory(fullPath); + using(FileStream stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true)) { + await stream.WriteAsync(data, 0, data.Length).ConfigureAwait(false); + } + } + + public async Task ReadAsync(string path) { + string fullPath = ResolvePath(path); + if(!File.Exists(fullPath)) { + throw new FileNotFoundException($"Save file not found: {fullPath}"); + } + + using(FileStream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true)) { + byte[] buffer = new byte[stream.Length]; + await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + return buffer; + } + } + + public Task ExistsAsync(string path) { + return Task.FromResult(Exists(path)); + } + + public Task DeleteAsync(string path) { + Delete(path); + return Task.CompletedTask; + } + + public Task ListAsync(string directoryPath) { + return Task.FromResult(List(directoryPath)); + } + + private static void EnsureDirectory(string filePath) { + string directory = Path.GetDirectoryName(filePath); + if(!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { + Directory.CreateDirectory(directory); + } + } + } +} diff --git a/Runtime/Storage/FileSystemSaveStorage.cs.meta b/Runtime/Storage/FileSystemSaveStorage.cs.meta new file mode 100644 index 0000000..2bd833f --- /dev/null +++ b/Runtime/Storage/FileSystemSaveStorage.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cba707f119fcd0143be086bff73bcd9d \ No newline at end of file diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..f7a8e3f --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2a7653161b6889242a7ecb08f584398c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor.meta b/Tests/Editor.meta new file mode 100644 index 0000000..f7ef9b4 --- /dev/null +++ b/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5ff28f3695e4c214d9f9c9d3f14e2259 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef b/Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef new file mode 100644 index 0000000..8869219 --- /dev/null +++ b/Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef @@ -0,0 +1,24 @@ +{ + "name": "Jovian.SaveSystem.EditorTests", + "rootNamespace": "Jovian.SaveSystem.Tests.Editor", + "references": [ + "Jovian.SaveSystem", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef.meta b/Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef.meta new file mode 100644 index 0000000..7c303d8 --- /dev/null +++ b/Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e61712a36e316da4b9cd50d3d5ae71bc +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/SaveSerializerTests.cs b/Tests/Editor/SaveSerializerTests.cs new file mode 100644 index 0000000..72a65df --- /dev/null +++ b/Tests/Editor/SaveSerializerTests.cs @@ -0,0 +1,104 @@ +using System; +using NUnit.Framework; + +namespace Jovian.SaveSystem.Tests.Editor { + public class SaveSerializerTests { + [Serializable] + private sealed class TestData { + public string name; + public int score; + public float[] positions; + } + + [Test] + public void JsonSerializer_RoundTrip_PreservesData() { + JsonSaveSerializer serializer = new JsonSaveSerializer(); + TestData original = new TestData { + name = "TestPlayer", + score = 42, + positions = new[] { 1.5f, 2.5f, 3.5f } + }; + + byte[] bytes = serializer.Serialize(original); + TestData deserialized = serializer.Deserialize(bytes); + + Assert.AreEqual(original.name, deserialized.name); + Assert.AreEqual(original.score, deserialized.score); + Assert.AreEqual(original.positions, deserialized.positions); + } + + [Test] + public void JsonSerializer_ProducesNonEmptyBytes() { + JsonSaveSerializer serializer = new JsonSaveSerializer(); + TestData data = new TestData { name = "Test", score = 1 }; + + byte[] bytes = serializer.Serialize(data); + + Assert.IsNotNull(bytes); + Assert.Greater(bytes.Length, 0); + } + + [Test] + public void JsonSerializer_DeserializeNull_Throws() { + JsonSaveSerializer serializer = new JsonSaveSerializer(); + + Assert.Throws(() => serializer.Deserialize(null)); + Assert.Throws(() => serializer.Deserialize(Array.Empty())); + } + + [Test] + public void BinarySerializer_RoundTrip_PreservesData() { + BinarySaveSerializer serializer = new BinarySaveSerializer("test-key-123"); + TestData original = new TestData { + name = "BinaryPlayer", + score = 99, + positions = new[] { 10f, 20f, 30f } + }; + + byte[] bytes = serializer.Serialize(original); + TestData deserialized = serializer.Deserialize(bytes); + + Assert.AreEqual(original.name, deserialized.name); + Assert.AreEqual(original.score, deserialized.score); + Assert.AreEqual(original.positions, deserialized.positions); + } + + [Test] + public void BinarySerializer_OutputDiffersFromJson() { + JsonSaveSerializer jsonSerializer = new JsonSaveSerializer(); + BinarySaveSerializer binarySerializer = new BinarySaveSerializer("test-key"); + TestData data = new TestData { name = "Test", score = 1 }; + + byte[] jsonBytes = jsonSerializer.Serialize(data); + byte[] binaryBytes = binarySerializer.Serialize(data); + + Assert.AreNotEqual(jsonBytes, binaryBytes); + } + + [Test] + public void BinarySerializer_DifferentKeys_ProduceDifferentOutput() { + BinarySaveSerializer serializer1 = new BinarySaveSerializer("key-alpha"); + BinarySaveSerializer serializer2 = new BinarySaveSerializer("key-bravo"); + TestData data = new TestData { name = "Test", score = 1 }; + + byte[] bytes1 = serializer1.Serialize(data); + byte[] bytes2 = serializer2.Serialize(data); + + Assert.AreNotEqual(bytes1, bytes2); + } + + [Test] + public void BinarySerializer_EmptyKey_Throws() { + Assert.Throws(() => new BinarySaveSerializer("")); + Assert.Throws(() => new BinarySaveSerializer(null)); + } + + [Test] + public void BinarySerializer_DeserializeNull_Throws() { + BinarySaveSerializer serializer = new BinarySaveSerializer("key"); + + Assert.Throws(() => serializer.Deserialize(null)); + Assert.Throws(() => serializer.Deserialize(Array.Empty())); + } + } +} diff --git a/Tests/Editor/SaveSerializerTests.cs.meta b/Tests/Editor/SaveSerializerTests.cs.meta new file mode 100644 index 0000000..51c08b4 --- /dev/null +++ b/Tests/Editor/SaveSerializerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8e16fa5e1d6fe1240bbb82bbb338cec3 \ No newline at end of file diff --git a/Tests/Editor/SaveSlotManagerTests.cs b/Tests/Editor/SaveSlotManagerTests.cs new file mode 100644 index 0000000..a770f43 --- /dev/null +++ b/Tests/Editor/SaveSlotManagerTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Jovian.SaveSystem.Tests.Editor { + public class SaveSlotManagerTests { + private InMemorySaveStorage storage; + private SaveSystemSettings settings; + private SaveSlotManager slotManager; + + [SetUp] + public void SetUp() { + storage = new InMemorySaveStorage(); + settings = new SaveSystemSettings { maxAutoSavesPerSession = 3 }; + slotManager = new SaveSlotManager(storage, settings); + } + + [Test] + public void CreateSession_ReturnsNonEmptyId() { + string sessionId = slotManager.CreateSession(); + + Assert.IsNotNull(sessionId); + Assert.IsNotEmpty(sessionId); + } + + [Test] + public void CreateSession_AppearsInAllSessions() { + string sessionId = slotManager.CreateSession(); + + IReadOnlyList sessions = slotManager.GetAllSessions(); + + Assert.AreEqual(1, sessions.Count); + Assert.AreEqual(sessionId, sessions[0].sessionId); + } + + [Test] + public void AllocateManualSlot_IncrementsSlotNumber() { + string sessionId = slotManager.CreateSession(); + + SaveSlotInfo slot1 = slotManager.AllocateManualSlot(sessionId); + SaveSlotInfo slot2 = slotManager.AllocateManualSlot(sessionId); + + Assert.AreEqual(1, slot1.slotNumber); + Assert.AreEqual(2, slot2.slotNumber); + Assert.AreEqual(SaveSlotType.Manual, slot1.slotType); + } + + [Test] + public void AllocateAutoSlot_RotatesWhenMaxReached() { + string sessionId = slotManager.CreateSession(); + + SaveSlotInfo slot1 = slotManager.AllocateAutoSlot(sessionId); + SaveSlotInfo slot2 = slotManager.AllocateAutoSlot(sessionId); + SaveSlotInfo slot3 = slotManager.AllocateAutoSlot(sessionId); + + // 4th should rotate to reuse the oldest + SaveSlotInfo slot4 = slotManager.AllocateAutoSlot(sessionId); + + // slot4 should reuse slot1's file path (oldest by timestamp) + Assert.AreEqual(slot1.filePath, slot4.filePath); + } + + [Test] + public void AllocateQuickSlot_AlwaysReturnsSameSlot() { + string sessionId = slotManager.CreateSession(); + + SaveSlotInfo slot1 = slotManager.AllocateQuickSlot(sessionId); + SaveSlotInfo slot2 = slotManager.AllocateQuickSlot(sessionId); + + Assert.AreEqual(slot1.filePath, slot2.filePath); + Assert.AreEqual(SaveSlotType.Quick, slot1.slotType); + } + + [Test] + public void GetSlots_ReturnsOnlySessionSlots() { + string session1 = slotManager.CreateSession(); + string session2 = slotManager.CreateSession(); + + slotManager.AllocateManualSlot(session1); + slotManager.AllocateManualSlot(session1); + slotManager.AllocateManualSlot(session2); + + IReadOnlyList slots1 = slotManager.GetSlots(session1); + IReadOnlyList slots2 = slotManager.GetSlots(session2); + + Assert.AreEqual(2, slots1.Count); + Assert.AreEqual(1, slots2.Count); + } + + [Test] + public void HasAnySaves_FalseWhenEmpty_TrueAfterAllocation() { + Assert.IsFalse(slotManager.HasAnySaves()); + + string sessionId = slotManager.CreateSession(); + slotManager.AllocateManualSlot(sessionId); + + Assert.IsTrue(slotManager.HasAnySaves()); + } + + [Test] + public void DeleteSlot_RemovesFromIndex() { + string sessionId = slotManager.CreateSession(); + SaveSlotInfo slot = slotManager.AllocateManualSlot(sessionId); + + slotManager.DeleteSlot(slot); + + Assert.AreEqual(0, slotManager.GetSlots(sessionId).Count); + } + + [Test] + public void DeleteSession_RemovesAllSlotsAndSession() { + string sessionId = slotManager.CreateSession(); + slotManager.AllocateManualSlot(sessionId); + slotManager.AllocateAutoSlot(sessionId); + slotManager.AllocateQuickSlot(sessionId); + + slotManager.DeleteSession(sessionId); + + Assert.AreEqual(0, slotManager.GetAllSessions().Count); + Assert.AreEqual(0, slotManager.GetSlots(sessionId).Count); + } + + /// + /// In-memory ISaveStorage for testing without file system. + /// + private sealed class InMemorySaveStorage : ISaveStorage { + private readonly Dictionary files = new Dictionary(); + private readonly HashSet directories = new HashSet(); + + public void Write(string path, byte[] data) { files[path] = data; } + public byte[] Read(string path) { return files[path]; } + public bool Exists(string path) { return files.ContainsKey(path); } + public void Delete(string path) { files.Remove(path); } + public string[] List(string directoryPath) { + return files.Keys + .Where(k => k.StartsWith(directoryPath)) + .ToArray(); + } + public void CreateDirectory(string path) { directories.Add(path); } + + public Task WriteAsync(string path, byte[] data) { Write(path, data); return Task.CompletedTask; } + public Task ReadAsync(string path) { return Task.FromResult(Read(path)); } + public Task ExistsAsync(string path) { return Task.FromResult(Exists(path)); } + public Task DeleteAsync(string path) { Delete(path); return Task.CompletedTask; } + public Task ListAsync(string directoryPath) { return Task.FromResult(List(directoryPath)); } + } + } +} diff --git a/Tests/Editor/SaveSlotManagerTests.cs.meta b/Tests/Editor/SaveSlotManagerTests.cs.meta new file mode 100644 index 0000000..8b03acf --- /dev/null +++ b/Tests/Editor/SaveSlotManagerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 942e63ef3dd14ac4294a5330ba0442c4 \ No newline at end of file diff --git a/Tests/Editor/SaveSystemFacadeTests.cs b/Tests/Editor/SaveSystemFacadeTests.cs new file mode 100644 index 0000000..a7fad13 --- /dev/null +++ b/Tests/Editor/SaveSystemFacadeTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Jovian.SaveSystem.Tests.Editor { + public class SaveSystemFacadeTests { + [Serializable] + private sealed class GameState { + public string playerName; + public int level; + public float health; + } + + private InMemorySaveStorage storage; + private SaveSystemSettings settings; + private ISaveSystem saveSystem; + + [SetUp] + public void SetUp() { + storage = new InMemorySaveStorage(); + settings = new SaveSystemSettings { + maxAutoSavesPerSession = 3, + currentSaveVersion = 1 + }; + JsonSaveSerializer serializer = new JsonSaveSerializer(); + SaveSlotManager slotManager = new SaveSlotManager(storage, settings); + saveSystem = new SaveSystem(serializer, storage, slotManager, settings); + } + + [Test] + public void SaveAndLoad_ManualSlot_RoundTrips() { + string sessionId = saveSystem.CreateSession(); + GameState original = new GameState { + playerName = "Hero", + level = 10, + health = 95.5f + }; + + saveSystem.Save(sessionId, original, SaveSlotType.Manual); + + IReadOnlyList slots = saveSystem.GetSlots(sessionId); + Assert.AreEqual(1, slots.Count); + + GameState loaded = saveSystem.Load(slots[0]); + Assert.AreEqual(original.playerName, loaded.playerName); + Assert.AreEqual(original.level, loaded.level); + Assert.AreEqual(original.health, loaded.health); + } + + [Test] + public void SaveAndLoad_QuickSlot_OverwritesPrevious() { + string sessionId = saveSystem.CreateSession(); + + saveSystem.Save(sessionId, new GameState { playerName = "First" }, SaveSlotType.Quick); + saveSystem.Save(sessionId, new GameState { playerName = "Second" }, SaveSlotType.Quick); + + IReadOnlyList slots = saveSystem.GetSlots(sessionId); + SaveSlotInfo quickSlot = slots.First(s => s.slotType == SaveSlotType.Quick); + + GameState loaded = saveSystem.Load(quickSlot); + Assert.AreEqual("Second", loaded.playerName); + } + + [Test] + public void HasAnySaves_ReflectsState() { + Assert.IsFalse(saveSystem.HasAnySaves()); + + string sessionId = saveSystem.CreateSession(); + saveSystem.Save(sessionId, new GameState { playerName = "Test" }, SaveSlotType.Manual); + + Assert.IsTrue(saveSystem.HasAnySaves()); + } + + [Test] + public void DeleteSlot_RemovesSave() { + string sessionId = saveSystem.CreateSession(); + saveSystem.Save(sessionId, new GameState { playerName = "Delete Me" }, SaveSlotType.Manual); + + IReadOnlyList slots = saveSystem.GetSlots(sessionId); + saveSystem.DeleteSlot(slots[0]); + + Assert.AreEqual(0, saveSystem.GetSlots(sessionId).Count); + } + + [Test] + public void DeleteSession_RemovesEverything() { + string sessionId = saveSystem.CreateSession(); + saveSystem.Save(sessionId, new GameState { playerName = "A" }, SaveSlotType.Manual); + saveSystem.Save(sessionId, new GameState { playerName = "B" }, SaveSlotType.Auto); + saveSystem.Save(sessionId, new GameState { playerName = "C" }, SaveSlotType.Quick); + + saveSystem.DeleteSession(sessionId); + + Assert.AreEqual(0, saveSystem.GetAllSessions().Count); + Assert.IsFalse(saveSystem.HasAnySaves()); + } + + [Test] + public void MultipleSessions_AreIndependent() { + string session1 = saveSystem.CreateSession(); + string session2 = saveSystem.CreateSession(); + + saveSystem.Save(session1, new GameState { playerName = "Player1" }, SaveSlotType.Manual); + saveSystem.Save(session2, new GameState { playerName = "Player2" }, SaveSlotType.Manual); + + Assert.AreEqual(1, saveSystem.GetSlots(session1).Count); + Assert.AreEqual(1, saveSystem.GetSlots(session2).Count); + + GameState loaded1 = saveSystem.Load(saveSystem.GetSlots(session1)[0]); + GameState loaded2 = saveSystem.Load(saveSystem.GetSlots(session2)[0]); + + Assert.AreEqual("Player1", loaded1.playerName); + Assert.AreEqual("Player2", loaded2.playerName); + } + + /// + /// In-memory ISaveStorage for testing. + /// + private sealed class InMemorySaveStorage : ISaveStorage { + private readonly Dictionary files = new Dictionary(); + private readonly HashSet directories = new HashSet(); + + public void Write(string path, byte[] data) { files[path] = data; } + public byte[] Read(string path) { return files[path]; } + public bool Exists(string path) { return files.ContainsKey(path); } + public void Delete(string path) { files.Remove(path); } + public string[] List(string directoryPath) { + return files.Keys + .Where(k => k.StartsWith(directoryPath)) + .ToArray(); + } + public void CreateDirectory(string path) { directories.Add(path); } + + public Task WriteAsync(string path, byte[] data) { Write(path, data); return Task.CompletedTask; } + public Task ReadAsync(string path) { return Task.FromResult(Read(path)); } + public Task ExistsAsync(string path) { return Task.FromResult(Exists(path)); } + public Task DeleteAsync(string path) { Delete(path); return Task.CompletedTask; } + public Task ListAsync(string directoryPath) { return Task.FromResult(List(directoryPath)); } + } + } +} diff --git a/Tests/Editor/SaveSystemFacadeTests.cs.meta b/Tests/Editor/SaveSystemFacadeTests.cs.meta new file mode 100644 index 0000000..7402e06 --- /dev/null +++ b/Tests/Editor/SaveSystemFacadeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 55a1496c116a3434291a828b258bbb97 \ No newline at end of file diff --git a/Tests/Runtime.meta b/Tests/Runtime.meta new file mode 100644 index 0000000..bb5e127 --- /dev/null +++ b/Tests/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8b9f7ed9a26cede488cbe67e3bed5938 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef b/Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef new file mode 100644 index 0000000..3827b24 --- /dev/null +++ b/Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef @@ -0,0 +1,22 @@ +{ + "name": "Jovian.SaveSystem.RuntimeTests", + "rootNamespace": "Jovian.SaveSystem.Tests.Runtime", + "references": [ + "Jovian.SaveSystem", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef.meta b/Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef.meta new file mode 100644 index 0000000..dc59f79 --- /dev/null +++ b/Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6c0ac5542b5d3b846a1ed415880d1411 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d4197b --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "com.jovian.savesystem", + "version": "0.1.0", + "displayName": "Jovian Save System", + "description": "A generic, game-agnostic save system supporting multiple save slots, sessions, auto-saves, and dual JSON/Binary serialization.", + "unity": "2022.3", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.2.1" + }, + "keywords": [ + "save", + "persistence", + "serialization" + ], + "author": { + "name": "Jovian" + } +} diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..089f303 --- /dev/null +++ b/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4934a2fae884a0e4a9f789f07b4409ad +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: