copy from github

This commit is contained in:
Sebastian Bularca
2026-03-27 15:14:08 +01:00
parent 4aefcfd47f
commit b5d13e86d9
63 changed files with 1706 additions and 2 deletions

24
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 59da007fca3e0ac4e9a5096277c37275
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: bbc94d83b5c0e8c4c8c529cc64c94ddf
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 34f9753365e282e4f8c610df1cc61e28

21
LICENSE Normal file
View 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
View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 230d63e981df3274ab47c00223c7b6c0
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

198
README.md
View File

@@ -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
View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6af1b651ff0c3854f907408768835b5c
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Runtime.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b869b6ce172663a42adca34c78db2f78
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 27f27474b2fc12f47b9eea77568ac3c6

View 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();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cd45cbabfe17114419035e9591ac06ce

22
Runtime/ISaveStorage.cs Normal file
View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5f445979ba5d9414abd75819f53bfaba

24
Runtime/ISaveSystem.cs Normal file
View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54cb074c17a51c74d93aadb3ce7b2e47

View 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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a19ae8824b9d43447be860535961727d
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Runtime/Model.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1f99186168750fb469321dee33fd3397
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ffa4184b4e99e3947ac73fa747d346d7

View File

@@ -0,0 +1,6 @@
namespace Jovian.SaveSystem {
public enum SaveFormat {
Json,
Binary
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a118c9ab8d89ad546ad67e8dc1a71c98

View File

@@ -0,0 +1,7 @@
namespace Jovian.SaveSystem {
public enum SaveSlotType {
Manual,
Auto,
Quick
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0f1e478bd3e3fec43a55f4efec51577c

138
Runtime/SaveSystem.cs Normal file
View 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>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1ef842c1c48d58641b6016af3c512d39

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee824fed683906741ab35b553a76486a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f7af3b3f06e67b408b0f52089acdeea

View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2edaf26270d17a145a2c7f1fa7080d3a

8
Runtime/Settings.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a4191975d6dce814bbda5a57c1d17548
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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}");
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 86b7c6be79eeb6a48aa96d9041dcb5ed

8
Runtime/Slots.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b7eaf086b5f24df42a8d6a79de5c5c52
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f03201423cd9f364c85bc6ce44386fbd

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c10256ba563528a4c9d92d158c6c009c

View 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>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9498521d42a48944ba38a961e8e82459

8
Runtime/Storage.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a74728833fd95214a8e55433cc901ac6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cba707f119fcd0143be086bff73bcd9d

8
Tests.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2a7653161b6889242a7ecb08f584398c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Tests/Editor.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5ff28f3695e4c214d9f9c9d3f14e2259
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: e61712a36e316da4b9cd50d3d5ae71bc
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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>()));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8e16fa5e1d6fe1240bbb82bbb338cec3

View 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)); }
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 942e63ef3dd14ac4294a5330ba0442c4

View 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)); }
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 55a1496c116a3434291a828b258bbb97

8
Tests/Runtime.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8b9f7ed9a26cede488cbe67e3bed5938
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6c0ac5542b5d3b846a1ed415880d1411
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

18
package.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 4934a2fae884a0e4a9f789f07b4409ad
PackageManifestImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: