diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..9b99f72 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "UnityMCP": { + "command": "uv", + "args": [ + "run", "--no-project", "python", + "-c", "import os,runpy; runpy.run_path(os.environ['CLAUDE_PROJECT_DIR']+'/tools/unity-mcp.py', run_name='__main__')", + "lab" + ] + } + } +} diff --git a/README.md b/README.md index 8f074e4..17271af 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ And the feature I like most: **every change is persisted the moment it happens** - [Package installation](#package-installation) - [Quick start](#quick-start) +- [Replacing PlayerPrefs](#replacing-playerprefs) - [Configuring storage](#configuring-storage) - [Reading and writing](#reading-and-writing) - [Collections](#collections) @@ -82,6 +83,58 @@ string name = storage.Get("player_name", "Unknown"); > `BinaryStorage` implements `IDisposable`. Dispose it (e.g. with `using`) to flush and release the file. In the Editor the file path is locked while a storage instance is open, preventing accidental concurrent access to the same file. +## Replacing PlayerPrefs + +If you already use `PlayerPrefs` and just want a better one, `BinaryPrefs` is a static drop-in replacement. Rename the type and you are done - no path, no builder, no lifetime to manage. + +```csharp +using Appegy.Storage; + +// PlayerPrefs.SetInt("player_score", 100); +BinaryPrefs.SetInt("player_score", 100); + +int score = BinaryPrefs.GetInt("player_score", 0); +``` + +It keeps the whole `PlayerPrefs` surface - `SetInt`/`GetInt`, `SetFloat`/`GetFloat`, `SetString`/`GetString`, `HasKey`, `DeleteKey`, `DeleteAll`, `Save` - and adds the types `PlayerPrefs` never had: + +```csharp +BinaryPrefs.SetBool("music_enabled", false); +BinaryPrefs.SetLong("total_xp", 12_000_000_000L); +BinaryPrefs.SetDouble("precise_balance", 1234.5678d); +BinaryPrefs.SetDateTime("last_login", DateTime.UtcNow); +BinaryPrefs.SetTimeSpan("play_time", TimeSpan.FromHours(3)); +BinaryPrefs.SetVector3("last_position", transform.position); +BinaryPrefs.SetQuaternion("last_rotation", transform.rotation); +BinaryPrefs.SetEnum("difficulty", Difficulty.Hard); + +BinaryPrefs.Set("custom_key", 42.5d); // any type registered by AddPrimitiveTypes +double value = BinaryPrefs.Get("custom_key", 0d); +Type stored = BinaryPrefs.TypeOf("custom_key"); +``` + +Enums are stored as their underlying integral value, so they work through `SetEnum`/`GetEnum` without any registration. The generic `Set`/`Get` accepts every type registered by `AddPrimitiveTypes` and throws `UnregisteredTypeException` for anything else. + + +### Migration from PlayerPrefs + +Existing data is migrated lazily, one key at a time. When a key is missing from the binary file, `BinaryPrefs` looks it up in `PlayerPrefs`; if it is there with a matching type, the value is written to the binary storage and only then removed from `PlayerPrefs`. Every key therefore converges to a single source of truth, and nothing is deleted before it has been persisted. + +`bool` values are migrated from the conventional `PlayerPrefs` int representation, where a non-zero value means `true`. + +A key stored in `PlayerPrefs` under a different type is left untouched - reading `GetInt` for a key that `PlayerPrefs` holds as a string returns the default value and does not destroy the string. + + +### Differences from PlayerPrefs + +- Reads are type-safe but never throw. `SetInt("k", 1)` followed by `GetString("k")` returns the default value instead of garbage. +- `HasKey` returns `true` for keys that still live in `PlayerPrefs` and have not been migrated yet. +- `DeleteKey` removes the key from both storages, so a deleted key cannot come back from `PlayerPrefs`. +- `DeleteAll` mirrors `PlayerPrefs.DeleteAll` and wipes every key of the application, including keys written by Unity itself and by third-party packages. +- Every change is written to disk immediately, so calling `Save` is optional. + +The file lives at `Application.persistentDataPath/com.appegy.binary-prefs/player_prefs.bin`. When you need a different path, several files or scoped sub-storages, use `BinaryStorage` directly. + ## Configuring storage For full control use the fluent builder via `BinaryStorage.Construct`: diff --git a/lab/ProjectSettings/ProjectSettings.asset b/lab/ProjectSettings/ProjectSettings.asset index b5ca404..00ec385 100644 --- a/lab/ProjectSettings/ProjectSettings.asset +++ b/lab/ProjectSettings/ProjectSettings.asset @@ -54,7 +54,7 @@ PlayerSettings: mipStripping: 0 numberOfMipsStripped: 0 numberOfMipsStrippedPerMipmapLimitGroup: {} - m_StackTraceTypes: 000000000000000000000000000000000000000001000000 + m_StackTraceTypes: 010000000100000001000000010000000100000001000000 iosShowActivityIndicatorOnLoading: -1 androidShowActivityIndicatorOnLoading: -1 iosUseCustomAppBackgroundBehavior: 0 diff --git a/src/Runtime/BinaryPrefs.cs b/src/Runtime/BinaryPrefs.cs new file mode 100644 index 0000000..8bf63ca --- /dev/null +++ b/src/Runtime/BinaryPrefs.cs @@ -0,0 +1,448 @@ +using System; +using System.IO; +using UnityEngine; + +namespace Appegy.Storage +{ + /// + /// A drop-in replacement for backed by . + /// Values missing from the binary storage are read from once, moved into the + /// binary storage and removed from , so every key converges to a single source of truth. + /// + public static class BinaryPrefs + { + private const string StorageFileName = "player_prefs.bin"; + + private const int IntProbeA = int.MinValue; + private const int IntProbeB = int.MaxValue; + private const float FloatProbeA = float.MinValue; + private const float FloatProbeB = float.MaxValue; + private const string StringProbeA = "appegy.binary-prefs.probe.a"; + private const string StringProbeB = "appegy.binary-prefs.probe.b"; + + private static BinaryStorage _storage; + private static string _storageFilePath; + + private static string StorageFilePath => _storageFilePath ??= Path.Combine(PackageInfo.PersistentFolder, StorageFileName); + + private static BinaryStorage Storage => _storage ??= BinaryStorage + .Construct(StorageFilePath) + .AddPrimitiveTypes() + .EnableAutoSaveOnChange() + .SetMissingKeyBehaviour(MissingKeyBehavior.ReturnDefaultValueOnly) + .SetTypeMismatchBehaviour(TypeMismatchBehaviour.OverrideValueAndType) + .Build(); + + #region Int + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetInt(string key, int value) + { + Write(key, value); + } + + /// + /// Returns the value corresponding to key in the preference file if it exists. + /// If the key is not found in the binary storage, it is looked up in and migrated. + /// + /// The key to retrieve the value for. + /// The default value to return if the key does not exist or was stored with another type. + /// The value corresponding to key. + public static int GetInt(string key, int defaultValue = 0) + { + if (TryReadStored(key, out int stored)) + { + return stored; + } + if (TryReadLegacyInt(key, out var legacy)) + { + return Migrate(key, legacy); + } + return defaultValue; + } + + #endregion + + #region Float + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetFloat(string key, float value) + { + Write(key, value); + } + + /// + /// Returns the value corresponding to key in the preference file if it exists. + /// If the key is not found in the binary storage, it is looked up in and migrated. + /// + /// The key to retrieve the value for. + /// The default value to return if the key does not exist or was stored with another type. + /// The value corresponding to key. + public static float GetFloat(string key, float defaultValue = 0f) + { + if (TryReadStored(key, out float stored)) + { + return stored; + } + if (TryReadLegacyFloat(key, out var legacy)) + { + return Migrate(key, legacy); + } + return defaultValue; + } + + #endregion + + #region String + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetString(string key, string value) + { + Write(key, value); + } + + /// + /// Returns the value corresponding to key in the preference file if it exists. + /// If the key is not found in the binary storage, it is looked up in and migrated. + /// + /// The key to retrieve the value for. + /// The default value to return if the key does not exist or was stored with another type. + /// The value corresponding to key. + public static string GetString(string key, string defaultValue = "") + { + if (TryReadStored(key, out string stored)) + { + return stored; + } + if (TryReadLegacyString(key, out var legacy)) + { + return Migrate(key, legacy); + } + return defaultValue; + } + + #endregion + + #region Bool + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetBool(string key, bool value) + { + Write(key, value); + } + + /// + /// Returns the value corresponding to key in the preference file if it exists. + /// If the key is not found in the binary storage, it is looked up in as an int + /// (the conventional way of storing booleans there) and migrated. Only 0 and 1 are treated as booleans, + /// so an int preference holding any other value is neither migrated nor removed. + /// + /// The key to retrieve the value for. + /// The default value to return if the key does not exist or was stored with another type. + /// The value corresponding to key. + public static bool GetBool(string key, bool defaultValue = false) + { + if (TryReadStored(key, out bool stored)) + { + return stored; + } + if (TryReadLegacyInt(key, out var legacy) && legacy is 0 or 1) + { + return Migrate(key, legacy == 1); + } + return defaultValue; + } + + #endregion + + #region Extended types + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetLong(string key, long value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static long GetLong(string key, long defaultValue = 0L) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetDouble(string key, double value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static double GetDouble(string key, double defaultValue = 0d) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetDateTime(string key, DateTime value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static DateTime GetDateTime(string key, DateTime defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetTimeSpan(string key, TimeSpan value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static TimeSpan GetTimeSpan(string key, TimeSpan defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetVector2(string key, Vector2 value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Vector2 GetVector2(string key, Vector2 defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetVector3(string key, Vector3 value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Vector3 GetVector3(string key, Vector3 defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetVector4(string key, Vector4 value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Vector4 GetVector4(string key, Vector4 defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetVector2Int(string key, Vector2Int value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Vector2Int GetVector2Int(string key, Vector2Int defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetVector3Int(string key, Vector3Int value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Vector3Int GetVector3Int(string key, Vector3Int defaultValue = default) => Read(key, defaultValue); + + /// Sets the value of the preference identified by the given key. + /// The key to set the value for. + /// The value to set. + public static void SetQuaternion(string key, Quaternion value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + public static Quaternion GetQuaternion(string key, Quaternion defaultValue = default) => Read(key, defaultValue); + + /// + /// Sets the value of the preference identified by the given key. + /// The enum is stored as its underlying integral value, so no per-enum registration is required. + /// + /// The enum type to store. + /// The key to set the value for. + /// The value to set. + public static void SetEnum(string key, T value) + where T : unmanaged, Enum + { + Write(key, ToRawEnumValue(value)); + } + + /// Returns the enum value corresponding to key, or if it is missing or was stored with another type. + /// The enum type to read. + /// The key to retrieve the value for. + /// The default value to return if the key does not exist. + public static T GetEnum(string key, T defaultValue = default) + where T : unmanaged, Enum + { + return TryReadStored(key, out long stored) ? (T)Enum.ToObject(typeof(T), stored) : defaultValue; + } + + /// + /// Sets the value of the preference identified by the given key. + /// The type must be registered in the storage; all types added by AddPrimitiveTypes are supported. + /// + /// The type of the value. + /// The key to set the value for. + /// The value to set. + /// Thrown if the type is not supported by the storage. + public static void Set(string key, T value) => Write(key, value); + + /// Returns the value corresponding to key, or if it is missing or was stored with another type. + /// The type of the value. + /// The key to retrieve the value for. + /// The default value to return if the key does not exist. + /// Thrown if the type is not supported by the storage. + public static T Get(string key, T defaultValue = default) => Read(key, defaultValue); + + /// Returns the type the key is stored with, or null if the key is not present in the binary storage. + /// The key to get the type for. + public static Type TypeOf(string key) => Storage.TypeOf(key); + + #endregion + + #region Management + + /// Returns true if the key exists in the preference file or in the not yet migrated . + /// The key to check for existence. + /// True if the key exists; otherwise, false. + public static bool HasKey(string key) + { + return Storage.Has(key) || PlayerPrefs.HasKey(key); + } + + /// Removes the given key from the preference file and from . + /// The key to remove. + public static void DeleteKey(string key) + { + Storage.Remove(key); + if (PlayerPrefs.HasKey(key)) + { + PlayerPrefs.DeleteKey(key); + PlayerPrefs.Save(); + } + } + + /// + /// Removes all keys and values from the preference file and from . + /// Mirrors , which wipes every key of the application, including keys written by Unity and third-party packages. + /// + public static void DeleteAll() + { + Storage.RemoveAll(); + PlayerPrefs.DeleteAll(); + PlayerPrefs.Save(); + } + + /// Writes all modified preferences to disk. + public static void Save() + { + Storage.Save(); + } + + #endregion + + #region Internals + + internal static void OverrideStorageFilePath(string filePath) + { + DisposeStorage(); + _storageFilePath = filePath; + } + + internal static void Reset() + { + DisposeStorage(); + _storageFilePath = null; + } + + private static void DisposeStorage() + { + _storage?.Dispose(); + _storage = null; + } + + private static void Write(string key, T value) + { + var exists = Storage.Has(key); + Storage.Set(key, value); + if (!exists && PlayerPrefs.HasKey(key)) + { + PlayerPrefs.DeleteKey(key); + PlayerPrefs.Save(); + } + } + + private static T Read(string key, T defaultValue) + { + return TryReadStored(key, out T stored) ? stored : defaultValue; + } + + private static bool TryReadStored(string key, out T value) + { + if (Storage.TypeOf(key) == typeof(T)) + { + value = Storage.Get(key); + return true; + } + value = default; + return false; + } + + private static T Migrate(string key, T value) + { + Storage.Set(key, value); + PlayerPrefs.DeleteKey(key); + PlayerPrefs.Save(); + return value; + } + + private static bool TryReadLegacyInt(string key, out int value) + { + if (!PlayerPrefs.HasKey(key)) + { + value = default; + return false; + } + value = PlayerPrefs.GetInt(key, IntProbeA); + if (value != IntProbeA) + { + return true; + } + value = PlayerPrefs.GetInt(key, IntProbeB); + return value != IntProbeB; + } + + private static bool TryReadLegacyFloat(string key, out float value) + { + if (!PlayerPrefs.HasKey(key)) + { + value = default; + return false; + } + value = PlayerPrefs.GetFloat(key, FloatProbeA); + if (value != FloatProbeA) + { + return true; + } + value = PlayerPrefs.GetFloat(key, FloatProbeB); + return value != FloatProbeB; + } + + private static bool TryReadLegacyString(string key, out string value) + { + if (!PlayerPrefs.HasKey(key)) + { + value = default; + return false; + } + value = PlayerPrefs.GetString(key, StringProbeA); + if (value != StringProbeA) + { + return true; + } + value = PlayerPrefs.GetString(key, StringProbeB); + return value != StringProbeB; + } + + private static long ToRawEnumValue(T value) + where T : unmanaged, Enum + { + return Enum.GetUnderlyingType(typeof(T)) == typeof(ulong) + ? unchecked((long)Convert.ToUInt64(value)) + : Convert.ToInt64(value); + } + + #endregion + } +} diff --git a/src/Runtime/BinaryPrefs.cs.meta b/src/Runtime/BinaryPrefs.cs.meta new file mode 100644 index 0000000..3588755 --- /dev/null +++ b/src/Runtime/BinaryPrefs.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 6aa0713cf5824cd5acef4288973e8500 +timeCreated: 1718807205 \ No newline at end of file diff --git a/src/Tests/BinaryPrefsTests.cs b/src/Tests/BinaryPrefsTests.cs new file mode 100644 index 0000000..d56d3db --- /dev/null +++ b/src/Tests/BinaryPrefsTests.cs @@ -0,0 +1,615 @@ +using System; +using System.IO; +using FluentAssertions; +using NUnit.Framework; +using UnityEngine; + +namespace Appegy.Storage +{ + public class BinaryPrefsTestsBase + { + protected static readonly string PrefsPath = Path.Combine(Application.temporaryCachePath, "test_prefs.bin"); + + [SetUp, TearDown] + public void CleanPrefsBetweenTests() + { + BinaryPrefs.Reset(); + PlayerPrefs.DeleteAll(); + PlayerPrefs.Save(); + if (File.Exists(PrefsPath)) + { + File.Delete(PrefsPath); + } + BinaryPrefs.OverrideStorageFilePath(PrefsPath); + } + } + + [TestFixture] + public class BinaryPrefsPrimitiveTests : BinaryPrefsTestsBase + { + [Test] + public void SetInt_ShouldStoreValueInBinaryStorage() + { + BinaryPrefs.SetInt("key", 42); + + BinaryPrefs.GetInt("key").Should().Be(42); + BinaryPrefs.TypeOf("key").Should().Be(typeof(int)); + } + + [Test] + public void GetInt_ShouldReturnDefaultValueIfKeyNotFound() + { + BinaryPrefs.GetInt("unknownKey", 10).Should().Be(10); + } + + [Test] + public void SetInt_ShouldOverrideExistingValue() + { + BinaryPrefs.SetInt("key", 42); + BinaryPrefs.SetInt("key", 84); + + BinaryPrefs.GetInt("key").Should().Be(84); + } + + [Test] + public void SetFloat_ShouldStoreValueInBinaryStorage() + { + BinaryPrefs.SetFloat("key", 42.5f); + + BinaryPrefs.GetFloat("key").Should().Be(42.5f); + BinaryPrefs.TypeOf("key").Should().Be(typeof(float)); + } + + [Test] + public void GetFloat_ShouldReturnDefaultValueIfKeyNotFound() + { + BinaryPrefs.GetFloat("unknownKey", 10f).Should().Be(10f); + } + + [Test] + public void SetString_ShouldStoreValueInBinaryStorage() + { + BinaryPrefs.SetString("key", "value"); + + BinaryPrefs.GetString("key").Should().Be("value"); + BinaryPrefs.TypeOf("key").Should().Be(typeof(string)); + } + + [Test] + public void GetString_ShouldReturnDefaultValueIfKeyNotFound() + { + BinaryPrefs.GetString("unknownKey", "fallback").Should().Be("fallback"); + } + + [Test] + public void SetBool_ShouldStoreValueInBinaryStorage() + { + BinaryPrefs.SetBool("key", true); + + BinaryPrefs.GetBool("key").Should().BeTrue(); + BinaryPrefs.TypeOf("key").Should().Be(typeof(bool)); + } + + [Test] + public void GetBool_ShouldReturnDefaultValueIfKeyNotFound() + { + BinaryPrefs.GetBool("unknownKey", true).Should().BeTrue(); + } + } + + [TestFixture] + public class BinaryPrefsTypeMismatchTests : BinaryPrefsTestsBase + { + [Test] + public void GetString_ShouldNotThrowWhenKeyStoredAsInt() + { + BinaryPrefs.SetInt("key", 42); + + FluentActions.Invoking(() => BinaryPrefs.GetString("key")).Should().NotThrow(); + } + + [Test] + public void GetString_ShouldReturnDefaultWhenKeyStoredAsInt() + { + BinaryPrefs.SetInt("key", 42); + + BinaryPrefs.GetString("key", "fallback").Should().Be("fallback"); + } + + [Test] + public void GetInt_ShouldReturnDefaultWhenKeyStoredAsFloat() + { + BinaryPrefs.SetFloat("key", 42.5f); + + BinaryPrefs.GetInt("key", 7).Should().Be(7); + } + + [Test] + public void GetInt_ShouldReturnDefaultWhenKeyStoredAsBool() + { + BinaryPrefs.SetBool("key", true); + + BinaryPrefs.GetInt("key", 7).Should().Be(7); + } + + [Test] + public void Set_ShouldReplaceTypeOfExistingKey() + { + BinaryPrefs.SetInt("key", 42); + BinaryPrefs.SetString("key", "value"); + + BinaryPrefs.GetString("key").Should().Be("value"); + BinaryPrefs.TypeOf("key").Should().Be(typeof(string)); + } + } + + [TestFixture] + public class BinaryPrefsMigrationTests : BinaryPrefsTestsBase + { + [Test] + public void GetInt_ShouldReturnValueFromPlayerPrefsWhenNotInBinaryStorage() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.GetInt("key").Should().Be(100); + } + + [Test] + public void GetInt_ShouldMoveValueIntoBinaryStorage() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.GetInt("key"); + + BinaryPrefs.TypeOf("key").Should().Be(typeof(int)); + } + + [Test] + public void GetInt_ShouldRemoveMigratedKeyFromPlayerPrefs() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.GetInt("key"); + + PlayerPrefs.HasKey("key").Should().BeFalse(); + } + + [Test] + public void GetFloat_ShouldMigrateValueFromPlayerPrefs() + { + PlayerPrefs.SetFloat("key", 100.5f); + + BinaryPrefs.GetFloat("key").Should().Be(100.5f); + PlayerPrefs.HasKey("key").Should().BeFalse(); + BinaryPrefs.TypeOf("key").Should().Be(typeof(float)); + } + + [Test] + public void GetString_ShouldMigrateValueFromPlayerPrefs() + { + PlayerPrefs.SetString("key", "legacy"); + + BinaryPrefs.GetString("key").Should().Be("legacy"); + PlayerPrefs.HasKey("key").Should().BeFalse(); + BinaryPrefs.TypeOf("key").Should().Be(typeof(string)); + } + + [Test] + public void GetBool_ShouldMigrateIntValueFromPlayerPrefs() + { + PlayerPrefs.SetInt("key", 1); + + BinaryPrefs.GetBool("key").Should().BeTrue(); + PlayerPrefs.HasKey("key").Should().BeFalse(); + BinaryPrefs.TypeOf("key").Should().Be(typeof(bool)); + } + + [Test] + public void GetBool_ShouldMigrateZeroIntAsFalse() + { + PlayerPrefs.SetInt("key", 0); + + BinaryPrefs.GetBool("key", true).Should().BeFalse(); + } + + [Test] + public void GetBool_ShouldNotMigrateIntValueOutsideZeroAndOne() + { + PlayerPrefs.SetInt("key", 5); + + BinaryPrefs.GetBool("key", true).Should().BeTrue(); + } + + [Test] + public void GetBool_ShouldNotDeleteIntValueOutsideZeroAndOne() + { + PlayerPrefs.SetInt("key", 5); + + BinaryPrefs.GetBool("key"); + + BinaryPrefs.GetInt("key").Should().Be(5); + } + + [TestCase(int.MinValue)] + [TestCase(int.MaxValue)] + [TestCase(0)] + public void GetInt_ShouldMigrateValuesEqualToProbeSentinels(int value) + { + PlayerPrefs.SetInt("key", value); + + BinaryPrefs.GetInt("key").Should().Be(value); + } + + [TestCase(float.MinValue)] + [TestCase(float.MaxValue)] + [TestCase(0f)] + public void GetFloat_ShouldMigrateValuesEqualToProbeSentinels(float value) + { + PlayerPrefs.SetFloat("key", value); + + BinaryPrefs.GetFloat("key").Should().Be(value); + } + + [Test] + public void GetInt_ShouldNotMigrateKeyStoredAsStringInPlayerPrefs() + { + PlayerPrefs.SetString("key", "legacy"); + + BinaryPrefs.GetInt("key", 7).Should().Be(7); + } + + [Test] + public void GetInt_ShouldNotDeleteKeyStoredAsStringInPlayerPrefs() + { + PlayerPrefs.SetString("key", "legacy"); + + BinaryPrefs.GetInt("key"); + + PlayerPrefs.GetString("key").Should().Be("legacy"); + } + + [Test] + public void GetString_ShouldNotMigrateKeyStoredAsIntInPlayerPrefs() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.GetString("key", "fallback").Should().Be("fallback"); + PlayerPrefs.GetInt("key").Should().Be(100); + } + + [Test] + public void SetString_ShouldRemoveShadowedPlayerPrefsKey() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.SetString("key", "value"); + + PlayerPrefs.HasKey("key").Should().BeFalse(); + } + + [Test] + public void SetString_ShouldRemoveShadowedPlayerPrefsKeyAfterStorageReload() + { + PlayerPrefs.SetInt("key", 100); + BinaryPrefs.SetString("other", "value"); + BinaryPrefs.OverrideStorageFilePath(PrefsPath); + + BinaryPrefs.SetString("key", "value"); + + PlayerPrefs.HasKey("key").Should().BeFalse(); + } + + [Test] + public void GetInt_ShouldNotResurrectPlayerPrefsValueAfterKeyWasOverwrittenWithAnotherType() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.SetString("key", "value"); + + BinaryPrefs.GetInt("key", 7).Should().Be(7); + } + } + + [TestFixture] + public class BinaryPrefsDeletionTests : BinaryPrefsTestsBase + { + [Test] + public void HasKey_ShouldBeTrueForKeyOnlyInPlayerPrefs() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.HasKey("key").Should().BeTrue(); + } + + [Test] + public void HasKey_ShouldBeFalseForUnknownKey() + { + BinaryPrefs.HasKey("unknownKey").Should().BeFalse(); + } + + [Test] + public void DeleteKey_ShouldRemoveKeyFromBinaryStorage() + { + BinaryPrefs.SetInt("key", 42); + + BinaryPrefs.DeleteKey("key"); + + BinaryPrefs.HasKey("key").Should().BeFalse(); + } + + [Test] + public void DeleteKey_ShouldRemoveKeyFromPlayerPrefs() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.DeleteKey("key"); + + PlayerPrefs.HasKey("key").Should().BeFalse(); + } + + [Test] + public void DeleteKey_ShouldNotResurrectValueFromPlayerPrefs() + { + PlayerPrefs.SetInt("key", 100); + + BinaryPrefs.DeleteKey("key"); + + BinaryPrefs.GetInt("key", 7).Should().Be(7); + } + + [Test] + public void DeleteAll_ShouldRemoveEverythingFromBothStorages() + { + BinaryPrefs.SetInt("stored", 42); + PlayerPrefs.SetInt("legacy", 100); + + BinaryPrefs.DeleteAll(); + + BinaryPrefs.HasKey("stored").Should().BeFalse(); + BinaryPrefs.HasKey("legacy").Should().BeFalse(); + } + + [Test] + public void DeleteAll_ShouldNotResurrectValuesFromPlayerPrefs() + { + PlayerPrefs.SetInt("legacy", 100); + + BinaryPrefs.DeleteAll(); + + BinaryPrefs.GetInt("legacy", 7).Should().Be(7); + } + } + + [TestFixture] + public class BinaryPrefsPersistenceTests : BinaryPrefsTestsBase + { + [Test] + public void Values_ShouldSurviveStorageReload() + { + BinaryPrefs.SetInt("int", 42); + BinaryPrefs.SetString("string", "value"); + BinaryPrefs.SetBool("bool", true); + + ReopenStorage(); + + BinaryPrefs.GetInt("int").Should().Be(42); + BinaryPrefs.GetString("string").Should().Be("value"); + BinaryPrefs.GetBool("bool").Should().BeTrue(); + } + + [Test] + public void MigratedValues_ShouldSurviveStorageReload() + { + PlayerPrefs.SetInt("key", 100); + BinaryPrefs.GetInt("key"); + + ReopenStorage(); + + BinaryPrefs.GetInt("key").Should().Be(100); + } + + [Test] + public void DeletedKeys_ShouldNotComeBackAfterStorageReload() + { + BinaryPrefs.SetInt("key", 42); + BinaryPrefs.DeleteKey("key"); + + ReopenStorage(); + + BinaryPrefs.HasKey("key").Should().BeFalse(); + } + + private static void ReopenStorage() + { + BinaryPrefs.Save(); + BinaryPrefs.OverrideStorageFilePath(PrefsPath); + } + } + + [TestFixture] + public class BinaryPrefsExtendedTypesTests : BinaryPrefsTestsBase + { + [Test] + public void SetLong_GetLong_ShouldRoundTrip() + { + BinaryPrefs.SetLong("key", long.MaxValue); + + BinaryPrefs.GetLong("key").Should().Be(long.MaxValue); + } + + [Test] + public void SetDouble_GetDouble_ShouldRoundTrip() + { + BinaryPrefs.SetDouble("key", 42.125d); + + BinaryPrefs.GetDouble("key").Should().Be(42.125d); + } + + [Test] + public void SetDateTime_GetDateTime_ShouldRoundTrip() + { + var value = new DateTime(2024, 5, 17, 13, 45, 30, DateTimeKind.Utc); + + BinaryPrefs.SetDateTime("key", value); + + BinaryPrefs.GetDateTime("key").Should().Be(value); + } + + [Test] + public void SetTimeSpan_GetTimeSpan_ShouldRoundTrip() + { + var value = TimeSpan.FromMinutes(90); + + BinaryPrefs.SetTimeSpan("key", value); + + BinaryPrefs.GetTimeSpan("key").Should().Be(value); + } + + [Test] + public void SetVector2_GetVector2_ShouldRoundTrip() + { + BinaryPrefs.SetVector2("key", new Vector2(1f, 2f)); + + BinaryPrefs.GetVector2("key").Should().Be(new Vector2(1f, 2f)); + } + + [Test] + public void SetVector3_GetVector3_ShouldRoundTrip() + { + BinaryPrefs.SetVector3("key", new Vector3(1f, 2f, 3f)); + + BinaryPrefs.GetVector3("key").Should().Be(new Vector3(1f, 2f, 3f)); + } + + [Test] + public void SetVector4_GetVector4_ShouldRoundTrip() + { + BinaryPrefs.SetVector4("key", new Vector4(1f, 2f, 3f, 4f)); + + BinaryPrefs.GetVector4("key").Should().Be(new Vector4(1f, 2f, 3f, 4f)); + } + + [Test] + public void SetVector2Int_GetVector2Int_ShouldRoundTrip() + { + BinaryPrefs.SetVector2Int("key", new Vector2Int(1, 2)); + + BinaryPrefs.GetVector2Int("key").Should().Be(new Vector2Int(1, 2)); + } + + [Test] + public void SetVector3Int_GetVector3Int_ShouldRoundTrip() + { + BinaryPrefs.SetVector3Int("key", new Vector3Int(1, 2, 3)); + + BinaryPrefs.GetVector3Int("key").Should().Be(new Vector3Int(1, 2, 3)); + } + + [Test] + public void SetQuaternion_GetQuaternion_ShouldRoundTrip() + { + BinaryPrefs.SetQuaternion("key", new Quaternion(1f, 2f, 3f, 4f)); + + BinaryPrefs.GetQuaternion("key").Should().Be(new Quaternion(1f, 2f, 3f, 4f)); + } + + [Test] + public void GetLong_ShouldReturnDefaultWhenKeyStoredAsInt() + { + BinaryPrefs.SetInt("key", 42); + + BinaryPrefs.GetLong("key", 7L).Should().Be(7L); + } + + [Test] + public void SetGeneric_GetGeneric_ShouldRoundTrip() + { + BinaryPrefs.Set("key", 42.125d); + + BinaryPrefs.Get("key").Should().Be(42.125d); + } + + [Test] + public void GetGeneric_ShouldReturnDefaultWhenTypeDoesNotMatch() + { + BinaryPrefs.SetInt("key", 42); + + BinaryPrefs.Get("key", 7d).Should().Be(7d); + } + + [Test] + public void SetGeneric_ShouldThrowForUnregisteredType() + { + FluentActions.Invoking(() => BinaryPrefs.Set("key", new object())).Should().Throw(); + } + + [Test] + public void TypeOf_ShouldReturnNullForUnknownKey() + { + BinaryPrefs.TypeOf("unknownKey").Should().BeNull(); + } + } + + [TestFixture] + public class BinaryPrefsEnumTests : BinaryPrefsTestsBase + { + public enum IntBacked + { + None = 0, + Second = 2, + Negative = -5 + } + + public enum ByteBacked : byte + { + None = 0, + Max = byte.MaxValue + } + + public enum ULongBacked : ulong + { + None = 0, + Max = ulong.MaxValue + } + + [TestCase(IntBacked.Second)] + [TestCase(IntBacked.Negative)] + [TestCase(IntBacked.None)] + public void SetEnum_GetEnum_ShouldRoundTripIntBackedEnum(IntBacked value) + { + BinaryPrefs.SetEnum("key", value); + + BinaryPrefs.GetEnum("key").Should().Be(value); + } + + [Test] + public void SetEnum_GetEnum_ShouldRoundTripByteBackedEnum() + { + BinaryPrefs.SetEnum("key", ByteBacked.Max); + + BinaryPrefs.GetEnum("key").Should().Be(ByteBacked.Max); + } + + [Test] + public void SetEnum_GetEnum_ShouldRoundTripULongBackedEnum() + { + BinaryPrefs.SetEnum("key", ULongBacked.Max); + + BinaryPrefs.GetEnum("key").Should().Be(ULongBacked.Max); + } + + [Test] + public void GetEnum_ShouldReturnDefaultWhenKeyNotFound() + { + BinaryPrefs.GetEnum("unknownKey", IntBacked.Second).Should().Be(IntBacked.Second); + } + + [Test] + public void GetEnum_ShouldReturnDefaultWhenKeyStoredAsInt() + { + BinaryPrefs.SetInt("key", 2); + + BinaryPrefs.GetEnum("key", IntBacked.Negative).Should().Be(IntBacked.Negative); + } + } +} diff --git a/src/Tests/BinaryPrefsTests.cs.meta b/src/Tests/BinaryPrefsTests.cs.meta new file mode 100644 index 0000000..0f6a260 --- /dev/null +++ b/src/Tests/BinaryPrefsTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e14b5abec5094cdc82b51f6861f43f94 +timeCreated: 1723741900 \ No newline at end of file diff --git a/tools/unity-mcp.py b/tools/unity-mcp.py new file mode 100644 index 0000000..d3b309a --- /dev/null +++ b/tools/unity-mcp.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Launch the MCP for Unity server, routed to THIS checkout's Unity Editor. + +Invoked from .mcp.json. The Unity subfolder name is passed as argv[1] so this +script stays identical across repos/worktrees. + +It derives the Unity project hash like the Editor bridge +(ProjectIdentityUtility.ComputeProjectHash: hex of sha1(Application.dataPath)), +then starts the server with --default-instance . Routing by hash (which is +a function of the absolute project path) is what makes a worktree resolve to its +own Editor instead of the main checkout's - their project names are identical, only +the path-derived hash differs. + +We use the first 8 hex chars: that is exactly what Unity names its stdio status +file (unity-mcp-status-<8>.json), so it matches by `==` in stdio discovery, and it +also prefix-matches the 16-char hash the HTTP hub reports - one value works in both. +""" +import hashlib +import os +import subprocess +import sys + +SERVER_PACKAGE = "mcpforunityserver==10.1.0" + +unity_subdir = sys.argv[1] + +# CLAUDE_PROJECT_DIR is set by Claude Code to the checkout/worktree root. Forward +# slashes + no trailing slash match Unity's Application.dataPath on every platform. +root = (os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()).replace("\\", "/").rstrip("/") +data_path = f"{root}/{unity_subdir}/Assets" +project_hash = hashlib.sha1(data_path.encode("utf-8")).hexdigest()[:8] + +sys.exit(subprocess.run([ + "uvx", "--from", SERVER_PACKAGE, "mcp-for-unity", + "--transport", "stdio", + "--default-instance", project_hash, +]).returncode)