copy from github
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -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
|
||||
8
Editor.meta
Normal file
8
Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 59da007fca3e0ac4e9a5096277c37275
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
18
Editor/Jovian.SaveSystem.Editor.asmdef
Normal file
18
Editor/Jovian.SaveSystem.Editor.asmdef
Normal file
@@ -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
|
||||
}
|
||||
7
Editor/Jovian.SaveSystem.Editor.asmdef.meta
Normal file
7
Editor/Jovian.SaveSystem.Editor.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bbc94d83b5c0e8c4c8c529cc64c94ddf
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
49
Editor/SaveSystemSettingsProvider.cs
Normal file
49
Editor/SaveSystemSettingsProvider.cs
Normal file
@@ -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<string>(new[] { "save", "persistence", "serialization", "binary", "json" })
|
||||
};
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/SaveSystemSettingsProvider.cs.meta
Normal file
2
Editor/SaveSystemSettingsProvider.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34f9753365e282e4f8c610df1cc61e28
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
7
LICENSE.meta
Normal file
7
LICENSE.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 230d63e981df3274ab47c00223c7b6c0
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
198
README.md
198
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.
|
||||
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<SaveSlotInfo> slots = saveSystem.GetSlots(sessionId);
|
||||
|
||||
foreach(SaveSlotInfo slot in slots) {
|
||||
Debug.Log($"{slot.DisplayLabel} — {slot.TimestampDateTime}");
|
||||
}
|
||||
|
||||
GameState loaded = saveSystem.Load<GameState>(slots[0]);
|
||||
```
|
||||
|
||||
### 5. Async save/load
|
||||
|
||||
```csharp
|
||||
await saveSystem.SaveAsync(sessionId, state, SaveSlotType.Auto);
|
||||
|
||||
GameState loaded = await saveSystem.LoadAsync<GameState>(slots[0]);
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
Sessions group related saves together (e.g. one playthrough).
|
||||
|
||||
```csharp
|
||||
// List all sessions
|
||||
IReadOnlyList<SaveSessionInfo> 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<string, byte[]> files
|
||||
= new Dictionary<string, byte[]>();
|
||||
|
||||
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<byte[]> ReadAsync(string path) => Task.FromResult(Read(path));
|
||||
public Task<bool> ExistsAsync(string path) => Task.FromResult(Exists(path));
|
||||
public Task DeleteAsync(string path) { Delete(path); return Task.CompletedTask; }
|
||||
public Task<string[]> 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<SaveSlotInfo> slots = saveSystem.GetSlots(sessionId);
|
||||
MyData loaded = saveSystem.Load<MyData>(slots[0]);
|
||||
Assert.AreEqual(myData.playerName, loaded.playerName);
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Internal package — Jovian Industries.
|
||||
|
||||
7
README.md.meta
Normal file
7
README.md.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6af1b651ff0c3854f907408768835b5c
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Runtime.meta
Normal file
8
Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b869b6ce172663a42adca34c78db2f78
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
10
Runtime/ISaveSerializer.cs
Normal file
10
Runtime/ISaveSerializer.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// Converts typed data to and from byte arrays.
|
||||
/// Implementations define the format (JSON, binary, etc.).
|
||||
/// </summary>
|
||||
public interface ISaveSerializer {
|
||||
byte[] Serialize<TData>(TData data);
|
||||
TData Deserialize<TData>(byte[] payload);
|
||||
}
|
||||
}
|
||||
2
Runtime/ISaveSerializer.cs.meta
Normal file
2
Runtime/ISaveSerializer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 27f27474b2fc12f47b9eea77568ac3c6
|
||||
23
Runtime/ISaveSlotManager.cs
Normal file
23
Runtime/ISaveSlotManager.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// Manages save slot allocation, session tracking, and auto-save rotation.
|
||||
/// </summary>
|
||||
public interface ISaveSlotManager {
|
||||
SaveSlotInfo AllocateManualSlot(string sessionId);
|
||||
SaveSlotInfo AllocateAutoSlot(string sessionId);
|
||||
SaveSlotInfo AllocateQuickSlot(string sessionId);
|
||||
|
||||
IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId);
|
||||
IReadOnlyList<SaveSessionInfo> GetAllSessions();
|
||||
|
||||
string CreateSession();
|
||||
void DeleteSlot(SaveSlotInfo slot);
|
||||
void DeleteSession(string sessionId);
|
||||
bool HasAnySaves();
|
||||
|
||||
void UpdateSlotMetadata(SaveSlotInfo slot, long timestampUtc, int saveVersion);
|
||||
void PersistIndex();
|
||||
}
|
||||
}
|
||||
2
Runtime/ISaveSlotManager.cs.meta
Normal file
2
Runtime/ISaveSlotManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd45cbabfe17114419035e9591ac06ce
|
||||
22
Runtime/ISaveStorage.cs
Normal file
22
Runtime/ISaveStorage.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// Reads and writes raw byte arrays to a persistent location.
|
||||
/// Has no knowledge of save data types or serialization formats.
|
||||
/// </summary>
|
||||
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<byte[]> ReadAsync(string path);
|
||||
Task<bool> ExistsAsync(string path);
|
||||
Task DeleteAsync(string path);
|
||||
Task<string[]> ListAsync(string directoryPath);
|
||||
}
|
||||
}
|
||||
2
Runtime/ISaveStorage.cs.meta
Normal file
2
Runtime/ISaveStorage.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5f445979ba5d9414abd75819f53bfaba
|
||||
24
Runtime/ISaveSystem.cs
Normal file
24
Runtime/ISaveSystem.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// Top-level facade for the save system. Orchestrates serialization,
|
||||
/// storage, and slot management. This is the main API the game interacts with.
|
||||
/// </summary>
|
||||
public interface ISaveSystem {
|
||||
string CreateSession();
|
||||
bool HasAnySaves();
|
||||
|
||||
void Save<TData>(string sessionId, TData data, SaveSlotType slotType);
|
||||
TData Load<TData>(SaveSlotInfo slot);
|
||||
|
||||
Task SaveAsync<TData>(string sessionId, TData data, SaveSlotType slotType);
|
||||
Task<TData> LoadAsync<TData>(SaveSlotInfo slot);
|
||||
|
||||
IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId);
|
||||
IReadOnlyList<SaveSessionInfo> GetAllSessions();
|
||||
void DeleteSlot(SaveSlotInfo slot);
|
||||
void DeleteSession(string sessionId);
|
||||
}
|
||||
}
|
||||
2
Runtime/ISaveSystem.cs.meta
Normal file
2
Runtime/ISaveSystem.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54cb074c17a51c74d93aadb3ce7b2e47
|
||||
16
Runtime/Jovian.SaveSystem.asmdef
Normal file
16
Runtime/Jovian.SaveSystem.asmdef
Normal file
@@ -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
|
||||
}
|
||||
7
Runtime/Jovian.SaveSystem.asmdef.meta
Normal file
7
Runtime/Jovian.SaveSystem.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a19ae8824b9d43447be860535961727d
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Runtime/Model.meta
Normal file
8
Runtime/Model.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1f99186168750fb469321dee33fd3397
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Runtime/Model/SaveEnvelope.cs
Normal file
16
Runtime/Model/SaveEnvelope.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public sealed class SaveEnvelope {
|
||||
public int version;
|
||||
public long timestampUtc;
|
||||
public SaveSlotType slotType;
|
||||
public JToken payload;
|
||||
}
|
||||
}
|
||||
2
Runtime/Model/SaveEnvelope.cs.meta
Normal file
2
Runtime/Model/SaveEnvelope.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ffa4184b4e99e3947ac73fa747d346d7
|
||||
6
Runtime/Model/SaveFormat.cs
Normal file
6
Runtime/Model/SaveFormat.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Jovian.SaveSystem {
|
||||
public enum SaveFormat {
|
||||
Json,
|
||||
Binary
|
||||
}
|
||||
}
|
||||
2
Runtime/Model/SaveFormat.cs.meta
Normal file
2
Runtime/Model/SaveFormat.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a118c9ab8d89ad546ad67e8dc1a71c98
|
||||
7
Runtime/Model/SaveSlotType.cs
Normal file
7
Runtime/Model/SaveSlotType.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Jovian.SaveSystem {
|
||||
public enum SaveSlotType {
|
||||
Manual,
|
||||
Auto,
|
||||
Quick
|
||||
}
|
||||
}
|
||||
2
Runtime/Model/SaveSlotType.cs.meta
Normal file
2
Runtime/Model/SaveSlotType.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0f1e478bd3e3fec43a55f4efec51577c
|
||||
138
Runtime/SaveSystem.cs
Normal file
138
Runtime/SaveSystem.cs
Normal file
@@ -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 {
|
||||
/// <summary>
|
||||
/// Facade that orchestrates serialization, storage, and slot management.
|
||||
/// Thread-safe: uses SemaphoreSlim for async and lock for sync operations.
|
||||
/// </summary>
|
||||
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<TData>(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<TData>(SaveSlotInfo slot) {
|
||||
lock(syncLock) {
|
||||
return LoadInternal<TData>(slot);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync<TData>(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<TData> LoadAsync<TData>(SaveSlotInfo slot) {
|
||||
await asyncLock.WaitAsync().ConfigureAwait(false);
|
||||
try {
|
||||
return LoadInternal<TData>(slot);
|
||||
} finally {
|
||||
asyncLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId) {
|
||||
return slotManager.GetSlots(sessionId);
|
||||
}
|
||||
|
||||
public IReadOnlyList<SaveSessionInfo> 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<TData>(SaveSlotInfo slot) {
|
||||
byte[] envelopeBytes = storage.Read(slot.filePath);
|
||||
SaveEnvelope envelope = serializer.Deserialize<SaveEnvelope>(envelopeBytes);
|
||||
return envelope.payload.ToObject<TData>();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/SaveSystem.cs.meta
Normal file
2
Runtime/SaveSystem.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ef842c1c48d58641b6016af3c512d39
|
||||
8
Runtime/Serialization.meta
Normal file
8
Runtime/Serialization.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee824fed683906741ab35b553a76486a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
75
Runtime/Serialization/BinarySaveSerializer.cs
Normal file
75
Runtime/Serialization/BinarySaveSerializer.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// Serializes data to an obfuscated binary format.
|
||||
/// Pipeline: JSON string → UTF-8 bytes → XOR obfuscation → DeflateStream compression.
|
||||
/// </summary>
|
||||
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>(TData data) {
|
||||
string json = JsonConvert.SerializeObject(data, serializerSettings);
|
||||
byte[] jsonBytes = Encoding.UTF8.GetBytes(json);
|
||||
byte[] obfuscated = ApplyXor(jsonBytes);
|
||||
return Compress(obfuscated);
|
||||
}
|
||||
|
||||
public TData Deserialize<TData>(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<TData>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/Serialization/BinarySaveSerializer.cs.meta
Normal file
2
Runtime/Serialization/BinarySaveSerializer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f7af3b3f06e67b408b0f52089acdeea
|
||||
34
Runtime/Serialization/JsonSaveSerializer.cs
Normal file
34
Runtime/Serialization/JsonSaveSerializer.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// Serializes data to/from JSON using Newtonsoft.Json.
|
||||
/// </summary>
|
||||
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>(TData data) {
|
||||
string json = JsonConvert.SerializeObject(data, serializerSettings);
|
||||
return Encoding.UTF8.GetBytes(json);
|
||||
}
|
||||
|
||||
public TData Deserialize<TData>(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<TData>(json, serializerSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/Serialization/JsonSaveSerializer.cs.meta
Normal file
2
Runtime/Serialization/JsonSaveSerializer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2edaf26270d17a145a2c7f1fa7080d3a
|
||||
8
Runtime/Settings.meta
Normal file
8
Runtime/Settings.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a4191975d6dce814bbda5a57c1d17548
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
48
Runtime/Settings/SaveSystemSettings.cs
Normal file
48
Runtime/Settings/SaveSystemSettings.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// Configuration for the save system. Stored as JSON in ProjectSettings/SaveSystemSettings.json.
|
||||
/// </summary>
|
||||
[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<SaveSystemSettings>(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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/Settings/SaveSystemSettings.cs.meta
Normal file
2
Runtime/Settings/SaveSystemSettings.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 86b7c6be79eeb6a48aa96d9041dcb5ed
|
||||
8
Runtime/Slots.meta
Normal file
8
Runtime/Slots.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7eaf086b5f24df42a8d6a79de5c5c52
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Runtime/Slots/SaveSessionInfo.cs
Normal file
16
Runtime/Slots/SaveSessionInfo.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// Describes a save session (a new game playthrough).
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
2
Runtime/Slots/SaveSessionInfo.cs.meta
Normal file
2
Runtime/Slots/SaveSessionInfo.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f03201423cd9f364c85bc6ce44386fbd
|
||||
26
Runtime/Slots/SaveSlotInfo.cs
Normal file
26
Runtime/Slots/SaveSlotInfo.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// Describes a single save slot within a session.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
2
Runtime/Slots/SaveSlotInfo.cs.meta
Normal file
2
Runtime/Slots/SaveSlotInfo.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c10256ba563528a4c9d92d158c6c009c
|
||||
202
Runtime/Slots/SaveSlotManager.cs
Normal file
202
Runtime/Slots/SaveSlotManager.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// Manages save slot allocation, session tracking, and auto-save rotation.
|
||||
/// Maintains a persistent index file that tracks all sessions and their slots.
|
||||
/// </summary>
|
||||
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<SaveSlotInfo> 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<SaveSlotInfo> sessionSlots = GetOrCreateSessionSlots(sessionId);
|
||||
List<SaveSlotInfo> 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<SaveSlotInfo> 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<SaveSlotInfo> GetSlots(string sessionId) {
|
||||
return index.slots
|
||||
.Where(s => s.sessionId == sessionId)
|
||||
.OrderByDescending(s => s.timestampUtc)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<SaveSessionInfo> 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<SaveSlotInfo> 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<SaveIndex>(json) ?? new SaveIndex();
|
||||
} catch(Exception) {
|
||||
index = new SaveIndex();
|
||||
}
|
||||
}
|
||||
|
||||
private List<SaveSlotInfo> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal index structure persisted to disk.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
private sealed class SaveIndex {
|
||||
public List<SaveSessionInfo> sessions = new List<SaveSessionInfo>();
|
||||
public List<SaveSlotInfo> slots = new List<SaveSlotInfo>();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/Slots/SaveSlotManager.cs.meta
Normal file
2
Runtime/Slots/SaveSlotManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9498521d42a48944ba38a961e8e82459
|
||||
8
Runtime/Storage.meta
Normal file
8
Runtime/Storage.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a74728833fd95214a8e55433cc901ac6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
107
Runtime/Storage/FileSystemSaveStorage.cs
Normal file
107
Runtime/Storage/FileSystemSaveStorage.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.SaveSystem {
|
||||
/// <summary>
|
||||
/// File-system backed storage. Base path is typically Application.persistentDataPath.
|
||||
/// All paths passed to methods are relative to the constructed base path.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
}
|
||||
|
||||
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<byte[]> 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<bool> ExistsAsync(string path) {
|
||||
return Task.FromResult(Exists(path));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string path) {
|
||||
Delete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string[]> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/Storage/FileSystemSaveStorage.cs.meta
Normal file
2
Runtime/Storage/FileSystemSaveStorage.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cba707f119fcd0143be086bff73bcd9d
|
||||
8
Tests.meta
Normal file
8
Tests.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a7653161b6889242a7ecb08f584398c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Tests/Editor.meta
Normal file
8
Tests/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ff28f3695e4c214d9f9c9d3f14e2259
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
24
Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef
Normal file
24
Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef
Normal file
@@ -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
|
||||
}
|
||||
7
Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef.meta
Normal file
7
Tests/Editor/Jovian.SaveSystem.EditorTests.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e61712a36e316da4b9cd50d3d5ae71bc
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
104
Tests/Editor/SaveSerializerTests.cs
Normal file
104
Tests/Editor/SaveSerializerTests.cs
Normal file
@@ -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<TestData>(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<ArgumentException>(() => serializer.Deserialize<TestData>(null));
|
||||
Assert.Throws<ArgumentException>(() => serializer.Deserialize<TestData>(Array.Empty<byte>()));
|
||||
}
|
||||
|
||||
[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<TestData>(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<ArgumentException>(() => new BinarySaveSerializer(""));
|
||||
Assert.Throws<ArgumentException>(() => new BinarySaveSerializer(null));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BinarySerializer_DeserializeNull_Throws() {
|
||||
BinarySaveSerializer serializer = new BinarySaveSerializer("key");
|
||||
|
||||
Assert.Throws<ArgumentException>(() => serializer.Deserialize<TestData>(null));
|
||||
Assert.Throws<ArgumentException>(() => serializer.Deserialize<TestData>(Array.Empty<byte>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Tests/Editor/SaveSerializerTests.cs.meta
Normal file
2
Tests/Editor/SaveSerializerTests.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e16fa5e1d6fe1240bbb82bbb338cec3
|
||||
150
Tests/Editor/SaveSlotManagerTests.cs
Normal file
150
Tests/Editor/SaveSlotManagerTests.cs
Normal file
@@ -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<SaveSessionInfo> 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<SaveSlotInfo> slots1 = slotManager.GetSlots(session1);
|
||||
IReadOnlyList<SaveSlotInfo> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory ISaveStorage for testing without file system.
|
||||
/// </summary>
|
||||
private sealed class InMemorySaveStorage : ISaveStorage {
|
||||
private readonly Dictionary<string, byte[]> files = new Dictionary<string, byte[]>();
|
||||
private readonly HashSet<string> directories = new HashSet<string>();
|
||||
|
||||
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<byte[]> ReadAsync(string path) { return Task.FromResult(Read(path)); }
|
||||
public Task<bool> ExistsAsync(string path) { return Task.FromResult(Exists(path)); }
|
||||
public Task DeleteAsync(string path) { Delete(path); return Task.CompletedTask; }
|
||||
public Task<string[]> ListAsync(string directoryPath) { return Task.FromResult(List(directoryPath)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Tests/Editor/SaveSlotManagerTests.cs.meta
Normal file
2
Tests/Editor/SaveSlotManagerTests.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 942e63ef3dd14ac4294a5330ba0442c4
|
||||
143
Tests/Editor/SaveSystemFacadeTests.cs
Normal file
143
Tests/Editor/SaveSystemFacadeTests.cs
Normal file
@@ -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<SaveSlotInfo> slots = saveSystem.GetSlots(sessionId);
|
||||
Assert.AreEqual(1, slots.Count);
|
||||
|
||||
GameState loaded = saveSystem.Load<GameState>(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<SaveSlotInfo> slots = saveSystem.GetSlots(sessionId);
|
||||
SaveSlotInfo quickSlot = slots.First(s => s.slotType == SaveSlotType.Quick);
|
||||
|
||||
GameState loaded = saveSystem.Load<GameState>(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<SaveSlotInfo> 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<GameState>(saveSystem.GetSlots(session1)[0]);
|
||||
GameState loaded2 = saveSystem.Load<GameState>(saveSystem.GetSlots(session2)[0]);
|
||||
|
||||
Assert.AreEqual("Player1", loaded1.playerName);
|
||||
Assert.AreEqual("Player2", loaded2.playerName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory ISaveStorage for testing.
|
||||
/// </summary>
|
||||
private sealed class InMemorySaveStorage : ISaveStorage {
|
||||
private readonly Dictionary<string, byte[]> files = new Dictionary<string, byte[]>();
|
||||
private readonly HashSet<string> directories = new HashSet<string>();
|
||||
|
||||
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<byte[]> ReadAsync(string path) { return Task.FromResult(Read(path)); }
|
||||
public Task<bool> ExistsAsync(string path) { return Task.FromResult(Exists(path)); }
|
||||
public Task DeleteAsync(string path) { Delete(path); return Task.CompletedTask; }
|
||||
public Task<string[]> ListAsync(string directoryPath) { return Task.FromResult(List(directoryPath)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Tests/Editor/SaveSystemFacadeTests.cs.meta
Normal file
2
Tests/Editor/SaveSystemFacadeTests.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 55a1496c116a3434291a828b258bbb97
|
||||
8
Tests/Runtime.meta
Normal file
8
Tests/Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b9f7ed9a26cede488cbe67e3bed5938
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
22
Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef
Normal file
22
Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef
Normal file
@@ -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
|
||||
}
|
||||
7
Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef.meta
Normal file
7
Tests/Runtime/Jovian.SaveSystem.RuntimeTests.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6c0ac5542b5d3b846a1ed415880d1411
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
18
package.json
Normal file
18
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
7
package.json.meta
Normal file
7
package.json.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4934a2fae884a0e4a9f789f07b4409ad
|
||||
PackageManifestImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user