From a5d66e18d7e13840c5bab556d56967976e9fe79e Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Wed, 5 Aug 2026 01:27:15 +0100 Subject: [PATCH 01/13] ci: update release-preflight check --- .github/workflows/release-preflight.yml | 55 +++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index 005ac2f..fb96068 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -35,6 +35,7 @@ name: release-preflight on: pull_request: branches: [master] + types: [opened, synchronize, reopened, closed] permissions: contents: read @@ -45,6 +46,7 @@ concurrency: jobs: preflight: + if: github.event.action != 'closed' runs-on: ubuntu-latest steps: # `lfs: true` is load-bearing, not a nicety. The default (false) leaves every @@ -53,7 +55,7 @@ jobs: # FETCHABLE from the remote — the exact failure that hit uiservice, where the # working tree held stubs while the published 1.2.1 had real content. - name: Check out the package - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 lfs: true @@ -65,16 +67,22 @@ jobs: # yet — omitting it fails with "No such file or directory". Retarget this to # master once the skill is merged there. - name: Check out the release tooling - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: repository: CoderGamester/Frameworks - ref: develop + # Immutable tooling snapshot. Update intentionally through + # `release.py install-preflight`, never by following a moving branch. + ref: be8c1474ae2ab772fae76a29f07bd520abc3b3ac path: .release-tooling sparse-checkout: .claude/skills/unity-package-release/scripts persist-credentials: false + - name: Assert the release tooling checkout + run: | + test -f .release-tooling/.claude/skills/unity-package-release/scripts/release.py + - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.12" @@ -82,3 +90,42 @@ jobs: run: | python3 .release-tooling/.claude/skills/unity-package-release/scripts/release.py \ preflight-pr --path . --base "origin/${{ github.base_ref }}" + + # GITHUB_TOKEN cannot dispatch into another repository. Add a fine-grained + # FRAMEWORKS_DISPATCH_TOKEN secret (Contents: read/write on Frameworks) to each + # package repository to activate this hand-off. A missing token is reported as + # a warning rather than turning an already-merged release PR red. + notify-release: + if: github.event.action == 'closed' && github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Notify the host release workflow + env: + DISPATCH_TOKEN: ${{ secrets.FRAMEWORKS_DISPATCH_TOKEN }} + PACKAGE_REPOSITORY: ${{ github.event.repository.name }} + run: | + if [ -z "$DISPATCH_TOKEN" ]; then + echo "::warning::FRAMEWORKS_DISPATCH_TOKEN is not configured; skipping host release notification." + exit 0 + fi + + case "$PACKAGE_REPOSITORY" in + Unity-GameData) package=gamedata ;; + Unity-GoogleSheet-Importer) package=googlesheetimporter ;; + Unity-MobileServices) package=mobileservices ;; + Unity-Services) package=services ;; + Statechart-HFSM) package=statechart ;; + Unity-UiService) package=uiservice ;; + *) + echo "::error::Unknown package repository: $PACKAGE_REPOSITORY" + exit 1 + ;; + esac + + payload=$(printf '{"event_type":"upm-release","client_payload":{"package":"%s","phase":"status"}}' "$package") + curl --fail-with-body --location --request POST \ + --url https://api.github.com/repos/CoderGamester/Frameworks/dispatches \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $DISPATCH_TOKEN" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + --data "$payload" From afef76b4f36d8af48a0cd394140bbc64ef6a3e9c Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Wed, 5 Aug 2026 01:29:04 +0100 Subject: [PATCH 02/13] ci: update release-preflight check --- .github/workflows/release-preflight.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index fb96068..d8225c4 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -72,7 +72,7 @@ jobs: repository: CoderGamester/Frameworks # Immutable tooling snapshot. Update intentionally through # `release.py install-preflight`, never by following a moving branch. - ref: be8c1474ae2ab772fae76a29f07bd520abc3b3ac + ref: be8c147f3d12d94a4055d556ffdc0a26e077e5ec path: .release-tooling sparse-checkout: .claude/skills/unity-package-release/scripts persist-credentials: false From c879ba03e806243e22966d572489b2aadecd6013 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Wed, 5 Aug 2026 01:30:27 +0100 Subject: [PATCH 03/13] ci: update release-preflight check --- .github/workflows/release-preflight.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index d8225c4..0a2b01c 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -62,10 +62,9 @@ jobs: persist-credentials: false # The gate logic is shared rather than vendored into six repos, so there is - # one source of truth. `ref` is explicit: actions/checkout defaults to the - # target repo's DEFAULT branch (master), where the tooling does not exist - # yet — omitting it fails with "No such file or directory". Retarget this to - # master once the skill is merged there. + # one source of truth. Pinning its full commit keeps an unchanged package PR + # reproducible when Frameworks/develop advances; the installer performs + # intentional snapshot updates across all package repositories. - name: Check out the release tooling uses: actions/checkout@v7 with: From 59f7a73f4fd702ff5ff92104af22b6f765929957 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Mon, 10 Aug 2026 10:41:12 +0300 Subject: [PATCH 04/13] style: wrap long XML summaries --- .../AddressableIdsEditorSettings.cs | 16 ++++++++++++---- Editor/AssetsImporter/AssetConfigsImporter.cs | 14 ++++++++++---- Editor/Versioning/VersioningEditorSettings.cs | 4 +++- Runtime/AssetResolverService.cs | 6 ++++-- Runtime/DependencyInjection/MainInstaller.cs | 4 +++- Runtime/MessageBrokerService.cs | 11 ++++++++--- Runtime/TickService.cs | 6 ++++-- Runtime/TimeService.cs | 10 +++++++--- 8 files changed, 51 insertions(+), 20 deletions(-) diff --git a/Editor/AddressableIds/AddressableIdsEditorSettings.cs b/Editor/AddressableIds/AddressableIdsEditorSettings.cs index a33e5b6..24669df 100644 --- a/Editor/AddressableIds/AddressableIdsEditorSettings.cs +++ b/Editor/AddressableIds/AddressableIdsEditorSettings.cs @@ -90,15 +90,23 @@ public string AddressableLabel public int LastGenerationIdCount => _lastGenerationIdCount; /// How many labels the last generation emitted. public int LastGenerationLabelCount => _lastGenerationLabelCount; - /// Script filename the last generation used; a change from the current setting makes the snapshot stale. + /// + /// Script filename the last generation used; a change from the current setting makes the snapshot stale. + /// public string LastGenerationFilenameUsed => _lastGenerationFilenameUsed ?? string.Empty; - /// Label filter the last generation used; a change from the current setting makes the snapshot stale. + /// + /// Label filter the last generation used; a change from the current setting makes the snapshot stale. + /// public string LastGenerationLabelFilterUsed => _lastGenerationLabelFilterUsed ?? string.Empty; - /// Sorted list of addressable addresses that were emitted in the last generation. Empty array when no snapshot. + /// + /// Sorted list of addressable addresses that were emitted in the last generation. Empty array when no snapshot. + /// public IReadOnlyList LastGenerationAddresses => _lastGenerationAddresses ?? Array.Empty(); - /// Sorted list of addressable labels that were emitted in the last generation. Empty array when no snapshot. + /// + /// Sorted list of addressable labels that were emitted in the last generation. Empty array when no snapshot. + /// public IReadOnlyList LastGenerationLabels => _lastGenerationLabels ?? Array.Empty(); /// diff --git a/Editor/AssetsImporter/AssetConfigsImporter.cs b/Editor/AssetsImporter/AssetConfigsImporter.cs index 631339a..98c5b36 100644 --- a/Editor/AssetsImporter/AssetConfigsImporter.cs +++ b/Editor/AssetsImporter/AssetConfigsImporter.cs @@ -115,7 +115,9 @@ public void Import(string assetsFolderPath = null) $"To: '{typeof(TScriptableObject).Name}' - From '{scriptableObject.AssetsFolderPath}' "); } - /// The filename fragment an id is matched against; override when assets are not named after the id. + /// + /// The filename fragment an id is matched against; override when assets are not named after the id. + /// protected virtual string IdPattern(TId id) { return id.ToString(); @@ -147,7 +149,9 @@ protected virtual List> OnImportIds(TScriptableObject return list; } - /// Index of the first path containing /{id}., or -1; the dot is what stops a prefix matching. + /// + /// Index of the first path containing /{id}., or -1; the dot is what stops a prefix matching. + /// protected int IndexOfId(string id, IList assetsPath) { for (var i = 0; i < assetsPath.Count; i++) @@ -171,7 +175,9 @@ public abstract class AssetsConfigsGeneratorImporter : IAssetConfigsGene /// Assembly-qualified name of the id enum the generated script should use. public abstract string TIdName { get; } - /// Assembly-qualified name of the configs ScriptableObject the generated script should target. + /// + /// Assembly-qualified name of the configs ScriptableObject the generated script should target. + /// public abstract string TScriptableObjectName { get; } /// When true the previous generated script is kept as a backup before being overwritten. @@ -298,4 +304,4 @@ private string GenerateImporterScript(Type importerType, Type assetType) return stringBuilder.ToString(); } } -} \ No newline at end of file +} diff --git a/Editor/Versioning/VersioningEditorSettings.cs b/Editor/Versioning/VersioningEditorSettings.cs index 66b68b7..76eb0f4 100644 --- a/Editor/Versioning/VersioningEditorSettings.cs +++ b/Editor/Versioning/VersioningEditorSettings.cs @@ -12,7 +12,9 @@ namespace GameLovers.Services.Versioning.Editor [FilePath("ProjectSettings/VersioningEditorSettings.asset", FilePathAttribute.Location.ProjectFolder)] internal sealed class VersioningEditorSettings : ScriptableSingleton { - /// Folder under which version-data.txt is written. Must contain a Resources segment. + /// + /// Folder under which version-data.txt is written. Must contain a Resources segment. + /// public const string DefaultFolderPath = "Assets/Configs/Resources"; [SerializeField] private string _resourcesFolderPath = DefaultFolderPath; diff --git a/Runtime/AssetResolverService.cs b/Runtime/AssetResolverService.cs index 5d38515..29b0ae1 100644 --- a/Runtime/AssetResolverService.cs +++ b/Runtime/AssetResolverService.cs @@ -128,7 +128,9 @@ public class AssetResolverService : AddressablesAssetLoader, IAssetAdderService private readonly IDictionary> _assetMap = new Dictionary>(); - /// Registered assets, keyed by asset type then id type. Editor introspection only — see AGENTS.md §4. + /// + /// Registered assets, keyed by asset type then id type. Editor introspection only — see AGENTS.md §4. + /// internal IReadOnlyDictionary> AssetMap => (IReadOnlyDictionary>)_assetMap; @@ -534,4 +536,4 @@ private bool TryGetAssetReference(TId id, out AssetReference assetR return dictionary.TryGetValue(id, out assetReference); } } -} \ No newline at end of file +} diff --git a/Runtime/DependencyInjection/MainInstaller.cs b/Runtime/DependencyInjection/MainInstaller.cs index 4ea5c4a..1f0854c 100644 --- a/Runtime/DependencyInjection/MainInstaller.cs +++ b/Runtime/DependencyInjection/MainInstaller.cs @@ -12,7 +12,9 @@ public static class MainInstaller { private static readonly Installer _installer = new Installer(); - /// The single installer this static facade wraps. Editor introspection only — see AGENTS.md §4. + /// + /// The single installer this static facade wraps. Editor introspection only — see AGENTS.md §4. + /// internal static Installer InstallerInstance => _installer; /// diff --git a/Runtime/MessageBrokerService.cs b/Runtime/MessageBrokerService.cs index 042a60e..ca9f04d 100644 --- a/Runtime/MessageBrokerService.cs +++ b/Runtime/MessageBrokerService.cs @@ -66,10 +66,15 @@ public class MessageBrokerService : IMessageBrokerService private (bool, IMessage) _isPublishing; - /// Subscribers keyed by message type, then by subscriber target. Editor introspection only — see AGENTS.md §4. + /// + /// Subscribers keyed by message type, then by subscriber target. Editor introspection only — see AGENTS.md §4. + /// internal IReadOnlyDictionary> Subscriptions => (IReadOnlyDictionary>)_subscriptions; - /// True while a publish is in flight, when mutating the subscriber list would throw. Editor introspection only — see AGENTS.md §4. + /// + /// True while a publish is in flight, when mutating the subscriber list would throw. + /// Editor introspection only — see AGENTS.md §4. + /// internal bool IsPublishing => _isPublishing.Item1; /// @@ -187,4 +192,4 @@ public void UnsubscribeAll(object subscriber = null) } } } -} \ No newline at end of file +} diff --git a/Runtime/TickService.cs b/Runtime/TickService.cs index 30667b2..3791882 100644 --- a/Runtime/TickService.cs +++ b/Runtime/TickService.cs @@ -364,7 +364,9 @@ private void Update(List list) } } - /// One tick subscription: its action, cadence and bookkeeping. Editor introspection only — see AGENTS.md §4. + /// + /// One tick subscription: its action, cadence and bookkeeping. Editor introspection only — see AGENTS.md §4. + /// internal struct TickData { public int Id; @@ -428,4 +430,4 @@ private void LateUpdate() OnLateUpdate(); } } -} \ No newline at end of file +} diff --git a/Runtime/TimeService.cs b/Runtime/TimeService.cs index bd9acfe..146f03e 100644 --- a/Runtime/TimeService.cs +++ b/Runtime/TimeService.cs @@ -79,9 +79,13 @@ public class TimeService : ITimeManipulator private float _extraTime; private DateTime _initialTime = DateTime.MinValue; - /// Seconds added by AddTime, on top of real elapsed time. Editor introspection only — see AGENTS.md §4. + /// + /// Seconds added by AddTime, on top of real elapsed time. Editor introspection only — see AGENTS.md §4. + /// internal float ExtraTime => _extraTime; - /// The reference instant every conversion is measured from. Editor introspection only — see AGENTS.md §4. + /// + /// The reference instant every conversion is measured from. Editor introspection only — see AGENTS.md §4. + /// internal DateTime InitialTime => _initialTime; /// @@ -152,4 +156,4 @@ public void SetInitialTime(DateTime initialTime) _initialUnityTime = Time.realtimeSinceStartup; } } -} \ No newline at end of file +} From 3c6b2a71df8654a4089a70b50d483c3dfb4d8f0e Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Wed, 12 Aug 2026 20:31:59 +0300 Subject: [PATCH 05/13] docs: modernize service guidance --- AGENTS.md | 2 +- CHANGELOG.md | 7 +- MIGRATION.md | 4 +- README.md | 352 ++++---------------------- Samples~/AssetResolver/README.md | 2 + Samples~/README.md | 2 + Samples~/ServicesPlayground/README.md | 2 + docs/README.md | 2 + package.json | 6 +- 9 files changed, 66 insertions(+), 313 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c746108..e8c079e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## 1. Package Overview - **Package**: `com.gamelovers.services` -- **Unity**: 6000.0+ +- **Unity**: minimum 6000.0; compatibility reference streams 6000.0.x, 6000.3.x, and 6000.5.x. Reference editors: 6000.0.81f1, 6000.3.21f1, 6000.5.7f1 (primary). Do not call a stream validated without current matrix artifacts. - **Dependencies** (see `package.json` — versions here must stay in sync) - `com.gamelovers.gamedata` (**1.0.0**) — provides `floatP`, used by `RngService` - `com.unity.addressables` (**1.21.20**) — asset loading and scene loading diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b772b..2096801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,12 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) -and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +**Changed**: +- Lowered the declared Unity minimum from 6000.3 to 6000.0 and documented 6000.0.x, 6000.3.x, and 6000.5.x as compatibility reference streams. ## [2.1.2] - 2026-08-04 diff --git a/MIGRATION.md b/MIGRATION.md index 2ca7c5d..02a8de0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -16,10 +16,10 @@ Remove `com.gamelovers.assetsimporter` and update `com.gamelovers.services` in y ```diff - "com.gamelovers.assetsimporter": "https://github.com/CoderGamester/Unity-AssetsImporter.git", - "com.gamelovers.services": "https://github.com/CoderGamester/Services.git", + "com.gamelovers.services": "https://github.com/CoderGamester/Unity-Services.git#2.1.2", ``` -`com.unity.addressables` and `com.cysharp.unitask` are now resolved automatically as transitive dependencies of `com.gamelovers.services`. +`com.unity.addressables` resolves from the Unity registry. When installing from Git, add UniTask directly to the consuming project's manifest; Unity does not resolve Git dependencies transitively. --- diff --git a/README.md b/README.md index bf97dcf..82fdb77 100644 --- a/README.md +++ b/README.md @@ -1,346 +1,86 @@ # GameLovers Services -[![Unity Version](https://img.shields.io/badge/Unity-6000.0%2B-blue.svg)](https://unity3d.com/get-unity/download) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Version](https://img.shields.io/github/v/tag/CoderGamester/Services?label=version)](CHANGELOG.md) +Small, composable Unity 6 services for installation, messaging, ticking, coroutines, pooling, persistence, RNG, time, commands, versioning, and Addressables workflows. -> **Quick Links**: [Installation](#installation) | [When to use](#when-to-use) | [Quick Start](#quick-start) | [Services](#services-at-a-glance) | [Docs](docs/README.md) | [Changelog](CHANGELOG.md) | [Migration Guide](MIGRATION.md) +[![Unity](https://img.shields.io/badge/Unity-6000.0%20%7C%206000.3%20%7C%206000.5-blue.svg)](https://unity.com/download) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) +[![Version](https://img.shields.io/github/v/tag/CoderGamester/Unity-Services?label=version)](CHANGELOG.md) -## Why Use This Package? +## When to use it -Building robust game architecture in Unity often leads to tightly coupled systems, scattered initialization logic, and memory management headaches. This **Services** package solves these pain points: +Use Services when you want independently selectable application services without adopting a full DI container. Use a scoped DI solution when you require hierarchical lifetimes or broad constructor injection. The package is pipeline-neutral. -| Problem | Solution | -|---------|----------| -| **Scattered dependencies** | Lightweight service locator (`MainInstaller`) for centralized dependency management | -| **Tightly coupled systems** | Message broker enables decoupled pub/sub communication | -| **Manual update management** | Tick service centralizes Update/FixedUpdate/LateUpdate callbacks | -| **Coroutines in pure C#** | Coroutine service runs Unity coroutines without MonoBehaviour | -| **Memory churn from instantiation** | Object pooling with lifecycle hooks for efficient reuse | -| **Inconsistent save/load** | Cross-platform data persistence with automatic serialization | -| **Non-deterministic gameplay** | Deterministic RNG service with state save/restore | -| **Version tracking complexity** | Build version service with git commit/branch metadata | +## Unity compatibility -**Built for production:** Minimal per-frame allocations. Used in real games. +| Item | Current policy | +| --- | --- | +| Minimum Unity version | `6000.0` | +| Reference streams | `6000.0.x`, `6000.3.x`, `6000.5.x` | +| Reference editors | `6000.0.81f1`, `6000.3.21f1`, `6000.5.7f1` (primary) | +| Render pipeline | Pipeline-neutral | +| Validation status | Compatibility target; fresh matrix artifacts are required for a validated claim. | ---- +## Install -## When to use - -**Use this package when** you want a lightweight set of standalone services you can pick and choose from, without committing to a full DI framework. - -**Consider alternatives** (e.g. VContainer, Zenject) when you need scoped lifetimes, factory bindings, or constructor injection across many types. In that case, use `Installer` directly (not `MainInstaller`) for multi-interface binding within your DI composition root. - ---- - -## System Requirements - -- **[Unity](https://unity.com/download)** 6000.0+ (Unity 6) -- **[GameLovers GameData](https://github.com/CoderGamester/Unity-GameData)** (v1.0.0) — automatically resolved -- **[Unity Addressables](https://docs.unity3d.com/Packages/com.unity.addressables@latest)** (≥ 1.21.20) — automatically resolved -- **[UniTask](https://github.com/Cysharp/UniTask)** (≥ 2.5.10) — automatically resolved - -| Unity Version | Status | -|---|---| -| 6000.0+ (Unity 6) | ✅ Fully Tested | -| 2022.3 LTS | ⚠️ Untested | - -## Installation - -### Via Unity Package Manager (Recommended) - -1. Open Unity Package Manager (`Window` → `Package Manager`) -2. Click `+` → `Add package from git URL` -3. Enter: `https://github.com/CoderGamester/Services.git` - -### Via manifest.json +Unity does not resolve Git dependencies transitively. Add the required Git packages explicitly; Addressables and the performance test framework resolve from the Unity registry. ```json { "dependencies": { - "com.gamelovers.services": "https://github.com/CoderGamester/Services.git" + "com.gamelovers.gamedata": "https://github.com/CoderGamester/Unity-GameData.git#1.0.3", + "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask#2.5.10", + "com.gamelovers.services": "https://github.com/CoderGamester/Unity-Services.git#2.1.2" } } ``` ---- - -## Key Components - -| Component | Responsibility | -|-----------|----------------| -| **MainInstaller** | Static service locator for global-scope single-interface bindings | -| **Installer** | Instance-based DI container (supports multi-interface binding) | -| **IMessageBrokerService** | Type-safe pub/sub messaging | -| **ITickService** | Centralized Update/FixedUpdate/LateUpdate callbacks | -| **ICoroutineService** | Run coroutines from pure C# classes | -| **IPoolService** | Object pool registry and management | -| **IDataService / IDataProvider** | Cross-platform data persistence (read-write / read-only) | -| **ITimeService / ITimeManipulator** | Unified time access with offset/sync manipulation | -| **IRngService** | Deterministic random number generation | -| **ICommandService\** | Typed command execution layer | -| **VersionServices** | Runtime access to build/git metadata | -| **AssetResolverService** | Addressables-based typed asset loading by id + asset type | -| **IAssetLoader / ISceneLoader** | Low-level addressable load/unload/instantiate interfaces | - ---- +## First success -## Quick Start +Install a service once, resolve it through the same facade, and release only the resources your application owns: ```csharp -using UnityEngine; using GameLovers.Services; +using UnityEngine; -public class GameBootstrap : MonoBehaviour +public sealed class GameBootstrap : MonoBehaviour { - void Awake() - { - var messageBroker = new MessageBrokerService(); - var tickService = new TickService(); - var dataService = new DataService(); + private TickService tickService; - MainInstaller.Bind(messageBroker); + private void Awake() + { + tickService = new TickService(); MainInstaller.Bind(tickService); - MainInstaller.Bind(dataService); } - void OnDestroy() + private void OnDestroy() { MainInstaller.CleanDispose(); - MainInstaller.Clean(); } } - -// Resolve anywhere -var broker = MainInstaller.Resolve(); -broker.Subscribe(OnPlayerDamaged); - -public struct PlayerDamagedMessage : IMessage -{ - public int PlayerId; - public float Damage; -} -``` - ---- - -## Services at a Glance - -Full API reference and recipes live in [`docs/`](docs/README.md). Short examples below. - -### Service Locator (MainInstaller / Installer) - -```csharp -MainInstaller.Bind(new MessageBrokerService()); -var broker = MainInstaller.Resolve(); -MainInstaller.TryResolve(out var ds); -MainInstaller.CleanDispose(); -MainInstaller.Clean(); - -// Multi-interface binding — use Installer directly -var installer = new Installer(); -installer.Bind(new TimeService()); -``` - -### Message Broker - -```csharp -// static method subscriptions are NOT supported -broker.Subscribe(OnEnemyDefeated); -broker.Publish(new EnemyDefeatedMessage { EnemyId = 42 }); -broker.PublishSafe(new EnemyDefeatedMessage { EnemyId = 42 }); // safe during publish -broker.Unsubscribe(this); -broker.UnsubscribeAll(this); -``` - -### Tick Service - -```csharp -var tick = new TickService(); -tick.SubscribeOnUpdate(OnUpdate); -tick.SubscribeOnUpdate(OnThrottled, deltaTime: 0.1f); // rate-limited -tick.SubscribeOnFixedUpdate(OnFixed); -tick.SubscribeOnLateUpdate(OnLate); -tick.UnsubscribeAll(this); -tick.Dispose(); // destroys host GameObject -``` - -### Coroutine Service - -```csharp -var cs = new CoroutineService(); -IAsyncCoroutine handle = cs.StartAsyncCoroutine(MyRoutine()); -handle.OnComplete(() => Debug.Log("Done!")); -cs.StartDelayCall(() => Debug.Log("2 s later"), delay: 2f); -cs.Dispose(); -``` - -### Pool Service - -```csharp -var pool = new PoolService(); -pool.AddPool(new GameObjectPool(50, prefab)); -var bullet = pool.Spawn(); -pool.Despawn(bullet); -``` - -### Data Service - -```csharp -var ds = new DataService(); -PlayerData player = ds.LoadData(); // loads from PlayerPrefs or creates fresh -player.Level = 10; -ds.SaveData(); ``` -### RNG Service - -```csharp -RngData rngData = RngService.CreateRngData(seed: 42); -var rng = new RngService(rngData); -int roll = rng.Range(1, 7); // 1–6 -int saved = rng.Counter; -rng.Restore(saved); // replay from saved point -``` - -### Time Service - -```csharp -var time = new TimeService(); -DateTime utc = time.DateTimeUtcNow; -float unity = time.UnityTimeNow; -long unixMs = time.UnixTimeNow; -time.AddTime(3600f); // fast-forward 1 hour (ITimeManipulator) -``` - -### Command Service - -```csharp -public struct LevelUpCommand : IGameCommand -{ - public void Execute(GameLogic gl, IMessageBrokerService mb) - { - gl.PlayerLevel++; - mb.Publish(new PlayerLevelledUpMessage { Level = gl.PlayerLevel }); - } -} - -ICommandService cmd = new CommandService(gameLogic, messageBroker); -cmd.ExecuteCommand(new LevelUpCommand()); -``` - -### Version Services - -```csharp -// No setup call needed — version metadata auto-loads at SubsystemRegistration, -// with a lazy-load fallback on first property access. -string branch = VersionServices.Branch; -string commit = VersionServices.Commit; -string ext = VersionServices.VersionExternal; // always safe, no load needed - -// Optional explicit pre-warm (idempotent — no-ops if already loaded): -// VersionServices.LoadVersionData(); // sync, recommended default -// await VersionServices.LoadVersionDataAsync(); // async — only useful for large VersionData blobs -``` - -### Asset Loading - -```csharp -// Low-level -var loader = new AddressablesAssetLoader(); -var texture = await loader.LoadAssetAsync("Textures/hero"); - -// High-level: typed by id -var resolver = new AssetResolverService(); -resolver.AddConfigs(spriteConfigs); // AssetConfigsScriptableObject -var sprite = await resolver.RequestAsset(SpriteId.Hero, true, false); -await resolver.LoadSceneAsync(SceneId.MainMenu, LoadSceneMode.Single, true); -``` - ---- - -## Editor Tools - -The package ships a set of editor utilities that work in both **Edit** and **Play** mode. - -### Services Explorer - -Open via `Tools > GameLovers > Services Explorer`. - -A dockable UIToolkit window with one tab per service. During Play mode each tab live-refreshes at 250 ms intervals. In Edit mode a snapshot banner is shown and data is read on demand. - -| Tab | What it shows | Primary CTA / Actions | -|---|---|---| -| **Overview** | Per-service card grid with bound/ready status and direct jump-links | Open (jumps to tab), per-service primary CTA | -| **Versioning** | `VersionExternal`, `VersionInternal`, `Branch`, `Commit`, `BuildNumber`; `version-data.txt` preview | **Reveal version-data.txt** | -| **Installer** | All `MainInstaller` bindings (interface → concrete type) | **Clean All**; Clean, CleanDispose per binding | -| **Message Broker** | All `IMessage` subscriptions with expandable subscriber lists | **Unsubscribe All**; Unsubscribe per type, Publish default(T) test | -| **Tick** | Update / FixedUpdate / LateUpdate subscriber lists with throttle settings | **Unsubscribe All**; Clear per list | -| **Coroutine** | Active `IAsyncCoroutine` handles (start time, running, completed) | **Stop All Coroutines**; Stop individual | -| **Pool** | All registered pools: spawned count, sample entity | **Clear All Pools**; DespawnAll, Dispose, RemovePool, Ping sample | -| **Data** | All loaded data types with indented JSON preview | **Save All Data**; Save, Load, Delete PlayerPrefs key | -| **Time** | Live `DateTimeUtcNow`, `UnityTimeNow`, `UnityScaleTimeNow`, `UnixTimeNow` | **Reset Time**; `AddTime` slider, `SetInitialTime` picker | -| **RNG** | Seed, Counter, peek-next N values | Restore(count) | -| **Asset Resolver** | `AssetMap` tree: asset type → id type → (id → ref, loaded status) | **Unload All** (behind destructive toggle); Unload per asset | -| **Assets Importer** | Discovered `IAssetConfigsImporter` list with per-importer path and status | **Import All**; Set Path, Import, Select per importer | -| **Addressable Ids** | Generator settings (`ScriptFilename`, `Namespace`, `AddressableLabel`) with output status | **Generate Addressable Ids**; Open Addressables Groups | - -### Custom Inspectors - -- **`AssetConfigsScriptableObject`** — diagnostics panel (duplicate keys, empty GUIDs) + default fields + "Regenerate Addressable Ids" button. -- **`AddressablesIdGeneratorSettings`** — settings are now configured in the Services Explorer **Addressable Ids** tab (`Tools > GameLovers > Addressable Ids > Open in Explorer`). -- **`AssetReferenceScene`** (property drawer) — resolved scene path label + "Open in Addressables Groups" button. - -### Scaffolders - -`Assets > Create > GameLovers Services > …` - -| Entry | Generates | -|---|---| -| **Message** | `struct : IMessage` | -| **Command** | `struct : IGameCommand` | -| **Service** | `IMyService` + `MyService : IMyService, IDisposable` | -| **Pool Entity** | class implementing `IPoolEntitySpawn` + `IPoolEntityDespawn` | - -File names and namespaces are set interactively in the Project window, identical to Unity's built-in "Create > C# Script" flow. - ---- - -## Samples - -Importable samples live under [`Samples~/`](Samples~/) and are exposed via the Unity Package Manager: - -| Sample | Addressables required? | Focus | -|---|---|---| -| **Services Playground** | No | All foundation services (`MainInstaller`, `MessageBroker`, `Tick`, `Coroutine`, `Pool`, `Data`, `Time`, `Rng`, `Commands`, `Versioning`) wired into a single scene. Doubles as the manual end-to-end protocol for the **Services Explorer** window | -| **Asset Resolver** | Yes (~2 minutes setup) | Typed asset loading via `AssetResolverService` + `AssetConfigsScriptableObject`, plus the Addressable Ids generator and Assets Importer pipeline | - -Each sample ships as a complete, runnable Unity scene with a programmatically-built UI — no per-import wiring step (the Asset Resolver sample requires marking your sprites Addressable; see its README). For the index, AI-assistant common-mistakes section, and full list of sample-only types (which are NOT part of the package public API), see [`Samples~/README.md`](Samples~/README.md). - -To import a sample: **Window > Package Manager > GameLovers Services > Samples > Import**. - ---- - -## Contributing - -Contributions are welcome! See [GitHub Issues](https://github.com/CoderGamester/Services/issues) to report bugs or request features. For development setup, architecture details, namespace conventions, and coding standards, see [AGENTS.md](AGENTS.md). +`Clean` unregisters an instance. `CleanDispose` disposes the registered service and unregisters it. Do not dispose a shared static facility unless this bootstrap exclusively owns it. ---- +## Services at a glance -## Related docs +| Area | Key API | +| --- | --- | +| Installation | `MainInstaller` for one global interface; `Installer` for multi-interface binding | +| Messaging | `IMessageBrokerService` pub/sub with explicit unsubscribe ownership | +| Frame work | `ITickService` and `ICoroutineService` | +| Pools | `IPoolService` and object-pool lifecycle hooks | +| State | `IDataService`, `ITimeService`, `IRngService`, commands, and version data | +| Assets | `IAssetLoader`, `ISceneLoader`, and `AssetResolverService` | -| Document | Purpose | -|---|---| -| [docs/README.md](docs/README.md) | Full per-service API reference | -| [AGENTS.md](AGENTS.md) | Contributor/agent guide (architecture, gotchas, workflows) | -| [CHANGELOG.md](CHANGELOG.md) | Version history | -| [MIGRATION.md](MIGRATION.md) | v1.x → v2.0.0 migration guide | +Data storage is appropriate for small, non-secret local state. Addressables handles must be released by the owner that acquired them; loading and instantiating have different release paths. -## Support +For Asset Resolver sample code, call `AddConfigs` through `IAssetAdderService` rather than the concrete resolver, and reference GameData directly from any asmdef that uses its public types. -- **Issues**: [Report bugs or request features](https://github.com/CoderGamester/Services/issues) -- **Discussions**: [Ask questions and share ideas](https://github.com/CoderGamester/Services/discussions) +## Samples, docs, and support -## License +| Sample | Focus | +| --- | --- | +| [Services Playground](Samples~/ServicesPlayground/README.md) | Foundation services in a ready-made scene | +| [Asset Resolver](Samples~/AssetResolver/README.md) | Typed Addressables asset lookup and importer tooling | -MIT — see [LICENSE.md](LICENSE.md). +Read [docs](docs/README.md), [CHANGELOG.md](CHANGELOG.md), and [MIGRATION.md](MIGRATION.md). Report issues at [Unity-Services](https://github.com/CoderGamester/Unity-Services/issues). diff --git a/Samples~/AssetResolver/README.md b/Samples~/AssetResolver/README.md index 1a38799..fd194ef 100644 --- a/Samples~/AssetResolver/README.md +++ b/Samples~/AssetResolver/README.md @@ -1,5 +1,7 @@ # Asset Resolver (Sample) +> **Unity compatibility:** Minimum Unity version `6000.0`; reference streams are `6000.0.x`, `6000.3.x`, and `6000.5.x`. Services is pipeline-neutral. See the [package compatibility matrix](../../README.md#unity-compatibility). + A focused demo of `AssetResolverService` end-to-end: register a typed-config ScriptableObject, request an asset by id-enum, unload references when done. > **Sample-only types**: `SpriteId`, `SpriteConfigs`, `AssetResolverExample`. These are NOT part of the `com.gamelovers.services` public API — they live in `GameLovers.Services.Samples.AssetResolver` to make that explicit. diff --git a/Samples~/README.md b/Samples~/README.md index 0ed98cc..6a714be 100644 --- a/Samples~/README.md +++ b/Samples~/README.md @@ -1,5 +1,7 @@ # Services Samples +> **Unity compatibility:** Minimum Unity version `6000.0`; reference streams are `6000.0.x`, `6000.3.x`, and `6000.5.x`. Services is pipeline-neutral. See the [package compatibility matrix](../README.md#unity-compatibility). + This folder contains the importable samples for the `com.gamelovers.services` package. Each sample is self-contained and demonstrates one cohesive slice of the package. > **For AI assistants and contributors**: every type prefixed `SAMPLE-ONLY` in the source files is **defined inside the sample**, NOT in the services package public API. Do not document them as if they were part of the package surface. The full list is at the bottom of this file. diff --git a/Samples~/ServicesPlayground/README.md b/Samples~/ServicesPlayground/README.md index 9097702..77d3b55 100644 --- a/Samples~/ServicesPlayground/README.md +++ b/Samples~/ServicesPlayground/README.md @@ -1,5 +1,7 @@ # Services Playground (Sample) +> **Unity compatibility:** Minimum Unity version `6000.0`; reference streams are `6000.0.x`, `6000.3.x`, and `6000.5.x`. Services is pipeline-neutral. See the [package compatibility matrix](../../README.md#unity-compatibility). + A single-scene, zero-Addressables walk-through that wires every foundation service via `MainInstaller`. Press Play and 10 of the 13 tabs in the **Services Explorer** light up with realistic state. > **Sample-only types**: `Bullet`, `PlayerData`, `TestMessage`, `PlayerLevelledUpMessage`, `GameLogic`, `LevelUpCommand`, `ServicesBootstrap`, `ServicesPlaygroundUI`. These are NOT part of the `com.gamelovers.services` public API — they live in `GameLovers.Services.Samples.ServicesPlayground` to make that explicit. diff --git a/docs/README.md b/docs/README.md index 0f869e5..af4f21f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,7 @@ # GameLovers Services — API Reference +> **Unity compatibility:** Minimum Unity version `6000.0`; reference streams are `6000.0.x`, `6000.3.x`, and `6000.5.x`. Services is pipeline-neutral. See the [package compatibility matrix](../README.md#unity-compatibility). + This folder contains the full API reference for each service. For quick-start examples see the [package README](../README.md). For architecture details and contributor guidance see [AGENTS.md](../AGENTS.md). ## Services diff --git a/package.json b/package.json index eff1a49..48e0a06 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "displayName": "GameLovers Services", "author": "Miguel Tomas", "version": "2.1.2", - "unity": "6000.3", + "unity": "6000.0", "license": "MIT", - "description": "Foundation services for a Unity based project (DI-lite, messaging, ticking, coroutines, pooling, persistence, RNG, time, commands, versioning) plus Addressables-based asset loading and importing tooling.", + "description": "Foundation services: lightweight installation, messaging, ticking, coroutines, pooling, persistence, RNG, time, commands, versioning, and Addressables tooling.", "type": "library", "hideInEditor": false, "dependencies": { @@ -26,4 +26,4 @@ "path": "Samples~/AssetResolver" } ] -} \ No newline at end of file +} From 39877af4cc0a8e79baf8d68a9b543f77360d44e7 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Thu, 13 Aug 2026 22:06:19 +0300 Subject: [PATCH 06/13] docs: focus agent guidance on durable rules --- AGENTS.md | 368 ++++++++------------------------------------- Tests/AGENTS.md | 387 ++++++++---------------------------------------- 2 files changed, 118 insertions(+), 637 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e8c079e..ee2071d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,310 +1,58 @@ -# GameLovers.Services - AI Agent Guide - -> **Companion files**: `CLAUDE.md` wraps this file for Claude Code — edit `AGENTS.md`, not `CLAUDE.md`. `README.md` is the user-facing entry point; `docs/` has deep per-service API references. - -## 1. Package Overview -- **Package**: `com.gamelovers.services` -- **Unity**: minimum 6000.0; compatibility reference streams 6000.0.x, 6000.3.x, and 6000.5.x. Reference editors: 6000.0.81f1, 6000.3.21f1, 6000.5.7f1 (primary). Do not call a stream validated without current matrix artifacts. -- **Dependencies** (see `package.json` — versions here must stay in sync) - - `com.gamelovers.gamedata` (**1.0.0**) — provides `floatP`, used by `RngService` - - `com.unity.addressables` (**1.21.20**) — asset loading and scene loading - - `com.cysharp.unitask` (**2.5.10**) — async/await support for asset loading - -This package provides a set of small, modular "foundation services" for Unity projects (service locator/DI-lite, messaging, ticking, coroutines, pooling, persistence, RNG, time, command pattern, and build version helpers) plus Addressables-based asset loading and importing tooling (absorbed from `com.gamelovers.assetsimporter` v0.5.2 in v2.0.0). - -**Audience**: This file is for contributors/agents working on the package. For user-facing docs, see `README.md` (quick start, per-service examples) and `docs/` (full API reference). - -## 2. Runtime Architecture (high level) - -```mermaid -flowchart TD - MainInstaller["MainInstaller (static)"] -->|"wraps"| Installer - Installer -->|"Bind/Resolve"| Services - - subgraph Services ["Bound Services"] - MessageBroker["MessageBrokerService"] - TickService["TickService"] - CoroutineService["CoroutineService"] - DataService["DataService"] - TimeService["TimeService"] - RngService["RngService"] - PoolService["PoolService"] - CommandService["CommandService<TGameLogic>"] - AssetResolver["AssetResolverService"] - end - - TickService -->|"DontDestroyOnLoad host"| TickMono["TickServiceMonoBehaviour"] - CoroutineService -->|"DontDestroyOnLoad host"| CoroutineMono["CoroutineServiceMonoBehaviour"] - CommandService -->|"uses"| MessageBroker - AssetResolver -->|"extends"| AddressablesLoader["AddressablesAssetLoader\n(IAssetLoader + ISceneLoader)"] -``` - -### Interface-to-Concrete Lookup - -| Interface | Namespace | Implementation | File | -|-----------|-----------|---------------|------| -| `IInstaller` | `GameLovers.Services` | `Installer` | `Runtime/DependencyInjection/Installer.cs` | -| `IMessageBrokerService` | `GameLovers.Services` | `MessageBrokerService` | `Runtime/MessageBrokerService.cs` | -| `ITickService` | `GameLovers.Services` | `TickService` | `Runtime/TickService.cs` | -| `ICoroutineService` | `GameLovers.Services` | `CoroutineService` | `Runtime/CoroutineService.cs` | -| `IPoolService` | `GameLovers.Services.Pooling` | `PoolService` (ns `GameLovers.Services`) | `Runtime/Pooling/IPoolService.cs`, `Runtime/PoolService.cs` | -| `IObjectPool` | `GameLovers.Services.Pooling` | `ObjectPool`, `GameObjectPool`, `GameObjectPool` | `Runtime/Pooling/` | -| `IDataProvider` / `IDataService` | `GameLovers.Services` | `DataService` | `Runtime/DataService.cs` | -| `ITimeService` / `ITimeManipulator` | `GameLovers.Services` | `TimeService` | `Runtime/TimeService.cs` | -| `IRngService` | `GameLovers.Services` | `RngService` | `Runtime/RngService.cs` | -| `ICommandService` | `GameLovers.Services.Commands` | `CommandService` (ns `GameLovers.Services`) | `Runtime/Commands/ICommandService.cs`, `Runtime/CommandService.cs` | -| `IGameCommand` / `IGameServerCommand` | `GameLovers.Services.Commands` | *(user-defined commands)* | `Runtime/Commands/IGameCommand.cs` | -| `IAssetLoader` | `GameLovers.Services.AssetsImporter` | `AddressablesAssetLoader` | `Runtime/AssetsImporter/AddressablesAssetLoader.cs` | -| `ISceneLoader` | `GameLovers.Services.AssetsImporter` | `AddressablesAssetLoader` | `Runtime/AssetsImporter/AddressablesAssetLoader.cs` | -| `IAssetResolverService` / `IAssetAdderService` | `GameLovers.Services` | `AssetResolverService` | `Runtime/AssetResolverService.cs` | - - -### Service Locator / Bindings -`Runtime/DependencyInjection/Installer.cs`, `Runtime/DependencyInjection/MainInstaller.cs` -- `Installer` stores interface type -> instance bindings; `MainInstaller` is a static global wrapper over one private `Installer`. -- Binding is **instance-based** (`Bind(T instance)`), not type-to-type or lifetime-managed DI. -- Only **interfaces** can be bound; non-interface binds throw `ArgumentException`. -- `Installer` supports multi-interface binds (`Bind` and `Bind`). `MainInstaller` exposes only single-interface `Bind`. -- Re-binding the same interface throws (`Dictionary.Add`); there is no overwrite behavior. - -### Messaging -`Runtime/MessageBrokerService.cs` -- Message contract: `IMessage` -- `Publish` iterates subscribers directly; use `PublishSafe` when handlers may subscribe/unsubscribe during publish (safe copy, extra allocation). -- `Subscribe` stores subscribers by `action.Target`; static method subscriptions throw. -- `Unsubscribe(null)` clears all subscribers for that message type; `UnsubscribeAll(null)` clears everything. - -### Tick / Update Fan-Out -`Runtime/TickService.cs` -- Creates a `DontDestroyOnLoad` GameObject with `TickServiceMonoBehaviour` to drive Unity callbacks. -- Subscriber APIs all take `Action`; `Unsubscribe(action)` removes from all Update/Fixed/Late lists. -- Type-specific unsubscribe and bulk clear APIs exist for Update, FixedUpdate, and LateUpdate. -- `deltaTime > 0` enables buffered ticking (rate-limited). `timeOverflowToNextTick` carries overflow to reduce drift. -- `realTime=true` uses `Time.realtimeSinceStartup`; `false` (default) uses `Time.time`. - -### Coroutine Host -`Runtime/CoroutineService.cs` -- Creates a `DontDestroyOnLoad` GameObject with `CoroutineServiceMonoBehaviour`. -- `StartCoroutine(IEnumerator)` returns a plain Unity `Coroutine`; async variants return `IAsyncCoroutine` / `IAsyncCoroutine` with completion callbacks and state. -- Delay-call argument order is action first, delay last: `StartDelayCall(Action call, float delay)` and `StartDelayCall(Action call, T data, float delay)`. -- `StopCoroutine(Coroutine)` and `StopAllCoroutines()` proxy through the host MonoBehaviour. - -### Pooling -`Runtime/PoolService.cs`, `Runtime/Pooling/ObjectPool.cs` -- Pool registry: `PoolService : IPoolService` — one pool per type. -- Pool implementations: - - `ObjectPool` — generic; lifecycle hooks via direct cast (`IPoolEntitySpawn`, `IPoolEntityDespawn`) - - `GameObjectPool` — `GameObject` pools; lifecycle hooks via `GetComponent<>()`; manages `SetActive` - - `GameObjectPool where T : Behaviour` — component-typed; same `GetComponent<>()` hook pattern -- `IObjectPool` covers spawn/despawn/reset/clear plus `SampleEntity` and `SpawnedReadOnly`; see `docs/pool-service.md` for the full surface. -- Lifecycle hook interfaces: `IPoolEntitySpawn`, `IPoolEntitySpawn`, `IPoolEntityDespawn`, `IPoolEntityObject`. -- `CallOnSpawned`/`CallOnDespawned` are **virtual** in `ObjectPoolBase` — override to customize lifecycle dispatch. - -### Persistence -`Runtime/DataService.cs` -- `IDataProvider` is read-only (`GetData()`, `HasData()`); `IDataService` adds add/load/save methods. -- In-memory store is keyed by `Type` (not string). Only **reference types** (`where T : class`) are supported. -- Disk persistence via `PlayerPrefs` + `Newtonsoft.Json` serialization. Key = `typeof(T).Name`. - -### Time + Manipulation -`Runtime/TimeService.cs` -- `ITimeService` is read-only time access + conversion methods; `ITimeManipulator` adds `AddTime(float)` and `SetInitialTime(DateTime)`. -- `TimeService` implements `ITimeManipulator`. Bind as `ITimeManipulator` for write access; `ITimeService` for read-only consumers. - -### Deterministic RNG -`Runtime/RngService.cs` -- `RngData` / `IRngData` hold deterministic state (`Seed`, `Count`, `State`). -- `IRngService` exposes consuming (`Next`, `Range`) and non-consuming (`Peek`, `PeekRange`) APIs plus `Restore(int count)`. -- `RngService.CreateRngData(int seed)` — static factory for `RngData`. -- Float API uses `floatP` from `com.gamelovers.gamedata`. - -### Command Pattern -`Runtime/CommandService.cs` -- Command contract: `IGameCommand` with `void Execute(TGameLogic, IMessageBrokerService)`. -- Server-only variant: `IGameServerCommand` with `void ExecuteLogic(TGameLogic)`. -- Service: `ICommandService` → `CommandService(TGameLogic, IMessageBrokerService)`. -- `CommandService` exposes `protected TGameLogic GameLogic` and `protected IMessageBrokerService MessageBroker` for subclassing (added in v0.15.1). -- Execution is **synchronous**. Use struct commands for fire-and-forget; class commands for reference semantics. - -### Build/Version Info -`Runtime/VersionServices.cs` -- Static class for `version-data` Resources metadata. `VersionExternal` is always safe. `VersionInternal`, `Branch`, `Commit`, and `BuildNumber` auto-load via a `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` `Bootstrap` hook and additionally lazy-load on first property access (via the private `EnsureLoaded()`) when the bootstrap hook has not yet fired. Consumers do NOT need to call `LoadVersionData()` / `LoadVersionDataAsync()` explicitly for the default flow. Both load methods remain public for explicit pre-warming. - -## 3. Key Directories / Files - -- **Runtime**: `Runtime/` - - Entry points: `Runtime/DependencyInjection/Installer.cs`, `Runtime/DependencyInjection/MainInstaller.cs` - - Foundation services (ns `GameLovers.Services`): `MessageBrokerService.cs`, `TickService.cs`, `CoroutineService.cs`, `PoolService.cs`, `DataService.cs`, `TimeService.cs`, `RngService.cs`, `VersionServices.cs`, `CommandService.cs`, `AssetResolverService.cs` - - Command contracts (ns `GameLovers.Services.Commands`, in `Commands/`): `IGameCommand.cs`, `ICommandService.cs` - - Pool contracts + implementations (ns `GameLovers.Services.Pooling`, in `Pooling/`): `IPoolService.cs`, `IObjectPool.cs`, `IPoolEntity.cs`, `ObjectPool.cs`, `GameObjectPool.cs` - - Asset loading contracts + implementations (ns `GameLovers.Services.AssetsImporter`, in `AssetsImporter/`): `IAssetLoader.cs`, `ISceneLoader.cs`, `AddressablesAssetLoader.cs`, `AddressableConfig.cs`, `AssetConfigsScriptableObject.cs`, `AssetLoaderUtils.cs`, `AssetReferenceScene.cs` -- **Editor**: `Editor/` — all code here is editor-only; do not reference from runtime assemblies - - `Editor/Versioning/` (ns `GameLovers.Services.Versioning.Editor`): `VersionEditorUtils.cs`, `GitEditorProcess.cs`, `VersioningEditorSettings.cs` (ScriptableSingleton → `ProjectSettings/VersioningEditorSettings.asset`), `VersioningMenu.cs` (`Tools > GameLovers > Versioning/...` stubs) - - `Editor/AssetsImporter/` (ns `GameLovers.Services.AssetsImporter.Editor`): `AssetConfigsImporter.cs` (public API, user code extends), `AssetsImporterEditorSettings.cs` (ScriptableSingleton → `ProjectSettings/AssetsImporterEditorSettings.asset`), `AssetsImporterEditorUtils.cs` (discovery + import logic), `AssetsImporterMenu.cs` (`Tools > GameLovers > Assets Importer/...` stubs) - - `Editor/AddressableIds/` (ns `GameLovers.Services.AddressableIds.Editor`): `AddressableIdsEditorSettings.cs` (ScriptableSingleton → `ProjectSettings/AddressableIdsEditorSettings.asset`, includes `IsValidIdentifier`/`IsValidNamespace` validators **and a persisted last-generation snapshot** — sorted address/label arrays + timestamp + filename/label-filter-used, written by `RecordGeneration` from inside `AddressableIdsGeneratorUtils.Generate`), `AddressableIdsGeneratorUtils.cs` (pure generation logic returning `GenerationResult`; also exposes `Diff` returning `DiffResult` and `ComputeFreshness` returning `FreshnessResult` for the Explorer tab — `Diff` is on-demand only because it runs the full `GetAssetList` + `ProcessData` scan, `ComputeFreshness` is file-stat-only and cheap), `AddressableIdsMenu.cs` (`Tools > GameLovers > Addressable Ids/...` stubs) - - `Editor/Explorer/Windows/` (ns `GameLovers.Services.Editor.Explorer`): `ServicesExplorerWindow.cs` (exposes `SelectTab()` and `OpenOnTab()`), `ServicesExplorerWindow.uxml`, `ServicesExplorerWindow.uss` - - `Editor/Explorer/Tabs/` (ns `GameLovers.Services.Editor.Explorer.Tabs`): `ServiceTab.cs` (abstract base; includes `MakePrimaryButton` helper) + 13 concrete tabs: `OverviewTab` (takes `ServicesExplorerWindow` reference for tab-jumping), `VersioningTab`, `InstallerTab`, `MessageBrokerTab`, `TickTab`, `CoroutineTab`, `PoolTab`, `DataTab`, `TimeTab`, `RngTab`, `AssetResolverTab`, `AssetsImporterTab`, `AddressableIdsTab` - - `Editor/Inspectors/` and `Editor/Scaffolders/`: UIToolkit inspectors/property drawers and `Assets > Create > GameLovers Services > ...` scaffolders; templates live in `Editor/Scaffolders/Templates~/` - - Assembly: `GameLovers.Services.Editor.asmdef` -- **Tests**: `Tests/` - - Before reading, editing, or creating any file in `Tests/`, you **MUST** read [`Tests/AGENTS.md`](Tests/AGENTS.md) first. - - `EditMode/Unit/` covers non-MonoBehaviour services plus AssetResolver/AssetLoaderUtils; `EditMode/Performance/` covers ObjectPool and MessageBroker. - - `PlayMode/Unit/` covers Unity-hosted services/pools; `PlayMode/Integration/` includes ServiceLifecycle, VersionServices, and explicit Addressables tests; PlayMode also has performance and smoke tests. -- **Samples**: `Samples~/` — importable via Unity Package Manager. Each sample ships as a complete runnable Unity scene with hand-authored deterministic `.cs.meta` / `.unity.meta` GUIDs (matching the `Packages/com.gamelovers.uiservice/Samples~/*` pattern); the UI inside each sample is built programmatically at runtime via `UnityEngine.UI` (legacy `Text`, no TMP). See [`Samples~/README.md`](Samples~/README.md) for the index, common-mistakes section sourced from §4 below, and the canonical list of sample-only types. - - `Samples~/ServicesPlayground/` — zero-Addressables scene exercising 10 of 13 Services Explorer tabs; uses `Bullet` (pooled MonoBehaviour), `PlayerData` (POCO), `TestMessage`/`PlayerLevelledUpMessage` (`IMessage` structs), `GameLogic`, `LevelUpCommand`, `ServicesBootstrap`, `ServicesPlaygroundUI` — all under namespace `GameLovers.Services.Samples.ServicesPlayground`. The pooled `Bullet` "sample entity" is generated as a sphere primitive at runtime in `ServicesBootstrap.GetOrCreateBulletPrefab()`. - - `Samples~/AssetResolver/` — Addressables-required scene covering the remaining 3 Explorer tabs (Asset Resolver, Assets Importer, Addressable Ids). Uses `SpriteId` (enum), `SpriteConfigs : AssetConfigsScriptableObject`, `AssetResolverExample` driver — all under namespace `GameLovers.Services.Samples.AssetResolver`. Ships three placeholder PNGs in `Sprites/Hero.png`/`Coin.png`/`Enemy.png` plus an empty `SpriteConfigs.asset`. Editor automation in `Samples~/AssetResolver/Editor/AssetResolverSampleSetup.cs` (assembly `GameLovers.Services.Samples.AssetResolver.Editor`, namespace `GameLovers.Services.Samples.AssetResolver.Editor`) auto-marks the sprites Addressable in a dedicated group `GameLoversServicesSamples_AssetResolver`, applies the Addressables label `services-sample-asset-resolver` (registered via `settings.AddLabel` and applied per-entry via `entry.SetLabel(force: true)`), renames non-canonical filenames to `Hero/Coin/Enemy` (substring-match first, alphabetical fallback), and wires `SpriteConfigs.asset` rows. Triggered by `AssetResolverSampleAssetPostprocessor.OnPostprocessAllAssets` when paths under `/Asset Resolver/Sprites/` change, plus `Tools > GameLovers > Samples > Asset Resolver > Refresh Addressables` and a sample-scoped inspector button on the package's `AssetConfigsScriptableObjectEditor` (visible only when the inspected asset path ends with `/Asset Resolver/SpriteConfigs.asset`). The group + label are NOT auto-removed when the sample is deleted (see "Sample-removal cannot self-cleanup" gotcha in §4); the per-sample README documents the manual cleanup steps as the user's undo. The inspector button invokes the menu via `EditorApplication.ExecuteMenuItem` so the package editor assembly stays decoupled from the sample editor assembly. The sample also ships `SpriteConfigsImporter : AssetsConfigsImporter` (empty subclass) so `AssetsImporterEditorUtils.DiscoverImporters` reflection-scan surfaces a sample row in the **Assets Importer** tab. `AssetResolverExample.Start()` calls `MainInstaller.Bind(...)` and `OnDestroy()` calls `MainInstaller.Clean()` — this is what populates the **Asset Resolver** tab's live `AssetMap` tree. A second sample-scoped menu `Tools > GameLovers > Samples > Asset Resolver > Open in Explorer` opens the Services Explorer focused on `AssetResolverTab`; the sample's runtime UI's "Open Services Explorer" button calls it via `EditorApplication.ExecuteMenuItem` (under `#if UNITY_EDITOR`) so the runtime assembly never references the package editor assembly. The sample's runtime files compile into a dedicated `GameLovers.Services.Samples.AssetResolver.asmdef` (NOT into the project's default `Assembly-CSharp` like `Samples~/ServicesPlayground/` does) — this is required so the sample editor assembly can `using` the sample's runtime types (`SpriteId`, `SpriteConfigs`) when declaring the concrete `SpriteConfigsImporter`. Asmdef-defined assemblies cannot reference `Assembly-CSharp`, so the moment a sample needs editor↔runtime type sharing it MUST have its own runtime asmdef. The sample editor asmdef (`GameLovers.Services.Samples.AssetResolver.Editor`) references `GameLovers.Services` (runtime), `GameLovers.Services.Editor` (for the importer base type and the `ServicesExplorerWindow.OpenOnTab<...>` call), `GameLovers.Services.Samples.AssetResolver` (the new sample runtime asmdef), and `GameLovers.GameData` (transitively required for `Pair` exposed by `AssetsConfigsImporterBase.OnImportIds`). The runtime asmdef references `GameLovers.Services`, `GameLovers.GameData` (for `Pair` returned by `AssetConfigsScriptableObject.Configs`), plus the engine packages the sample's runtime code uses (`Unity.TextMeshPro`, `UnityEngine.UI`, `UniTask`, `Unity.Addressables`, `Unity.ResourceManager`, `Unity.InputSystem`); when the sample is moved into a host project at `Assets/Samples/.../Asset Resolver/`, the asmdef boundary is preserved and the prefab continues to resolve `AssetResolverExample` via its `.cs.meta` GUID even though the assembly name in `m_EditorClassIdentifier` shifts from `Assembly-CSharp` to the new asmdef name on first import. - -### Folder Namespace Mapping - -With `"rootNamespace": "GameLovers.Services"` on the asmdef, Unity's *Create > C# Script* auto-derives namespaces from folder paths. That is already correct for all subfolders **except** `DependencyInjection/`. - -| Folder | Namespace | Notes | -|---|---|---| -| `Runtime/` (root) | `GameLovers.Services` | Concrete `*Service` classes + `AssetResolverService` | -| `Runtime/DependencyInjection/` | `GameLovers.Services` | **Carve-out** — new files here need manual namespace fix (strip `DependencyInjection` segment) | -| `Runtime/Commands/` | `GameLovers.Services.Commands` | Command contracts (interfaces only) | -| `Runtime/Pooling/` | `GameLovers.Services.Pooling` | Pool contracts + pool implementations | -| `Runtime/AssetsImporter/` | `GameLovers.Services.AssetsImporter` | Asset loading interfaces + Addressables loader | - -The concrete `PoolService` stays in `Runtime/` root under `GameLovers.Services` but references types from `GameLovers.Services.Pooling` — the file declares `using GameLovers.Services.Pooling;` at the top. `CommandService` follows the same pattern with `using GameLovers.Services.Commands;`. - -## 4. Important Behaviors / Gotchas - -### MainInstaller API -- `MainInstaller` exposes only single-interface `Bind`. Multi-interface `Bind` is on `IInstaller`/`Installer` directly. -- There is no `MainInstaller.Instance` — it is a static class wrapping a private `Installer`. - -### Message Broker Mutation Safety -- `Publish` iterates subscribers directly; calling `Subscribe`/`Unsubscribe` during publish **throws**. -- Use `PublishSafe` if handlers may subscribe/unsubscribe during message handling (copies delegates first, at allocation cost). -- `Subscribe` uses `action.Target` as key — **static method subscriptions throw `ArgumentException`**. - -### Tick / Coroutine Host GameObjects -- `TickService` and `CoroutineService` each create a `DontDestroyOnLoad` GameObject. Call `Dispose()` to tear them down (tests, game reset, domain reload). -- These services do **not** enforce a singleton; constructing multiple instances creates multiple host GameObjects. - -### IAsyncCoroutine.StopCoroutine(triggerOnComplete) -- `StopCoroutine(triggerOnComplete)` honors its parameter as of v2.0.0: `true` invokes the registered `OnComplete` callback, `false` suppresses it. After either path the coroutine flips to `IsCompleted == true` / `IsRunning == false`, and the call is a no-op if it had already completed. -- The Services Explorer's editor-only `_activeAsyncCoroutines` tracking subscribes to a separate internal `InternalCleanup` event on `AsyncCoroutine` (NOT to the public `OnComplete` setter). Do NOT route tracking through `OnComplete(...)` — the public setter has *replace* semantics and would silently overwrite (or be overwritten by) user callbacks. Any future Coroutine-tab introspection should hook `InternalCleanup` instead. - -### DataService Persistence -- Keys in `PlayerPrefs` are `typeof(T).Name` — name collisions are possible across assemblies with types sharing the same name. -- `LoadData` uses `Activator.CreateInstance()` when no saved data exists; `T` must have a **parameterless constructor**. -- Only reference types (`class`) are supported; value types (`struct`) are not. - -### Pool Lifecycle -- `PoolService` keeps **one pool per type**; `AddPool()` will throw (`Dictionary.Add`) if the type is already registered. -- `GameObjectPool.Dispose(bool disposeSampleEntity)` destroys the `SampleEntity` GameObject when `true`. `GameObjectPool.Dispose()` destroys all pooled instances but not the sample reference. -- `GameObjectPool` / `GameObjectPool` use `GetComponent<>()` for lifecycle hooks on components. `ObjectPool` casts the entity directly. This determines where `IPoolEntitySpawn` etc. must be implemented. -- **External destruction resilience**: `GameObjectPool.Dispose()` and `GameObjectPool.Dispose()` skip pooled entries whose underlying `GameObject` was destroyed by an external owner (e.g. a parent GameObject was destroyed while pooled instances were still reparented under it via `DespawnToSampleParent`). Any new code path that iterates pool internals (`Stack` / `SpawnedEntities` / `Clear()` output) and dereferences entities or `.gameObject` on `Behaviour` entries MUST use the same Unity fake-null guard (`if (obj == null) continue;`) that `SpawnEntity` already uses. Without the guard, dereferencing `.gameObject` on a destroyed `Behaviour` throws `MissingReferenceException`. - -### Asset Loading (AddressablesAssetLoader) -- `UnloadAsset` is **synchronous** and returns `void`. It only decrements the Addressables reference count via `Addressables.Release(asset)` and invokes `onCompleteCallback`. The method was renamed from `UnloadAssetAsync` and its return type changed from `UniTask` to `void` in v2.0.0 to accurately reflect its behaviour. Memory reclamation (`Resources.UnloadUnusedAssets()`) is the caller's responsibility at appropriate moments (scene transitions, boot, memory-pressure events). Do NOT add `GC.Collect()` / `Resources.UnloadUnusedAssets()` back into per-asset unload paths — they were removed in v2.0.0 because they caused PlayMode Test Runner crashes and O(total-assets-in-memory) stalls per call. Note: for prefab instances returned by `InstantiateAsync`, `UnloadAsset` does not destroy the `GameObject`; callers must `Object.Destroy` the instance separately. -- `AddressablesAssetLoader` implements both `IAssetLoader` and `ISceneLoader`. `AssetResolverService` extends it and sits in the root `GameLovers.Services` namespace while its dependencies live in `GameLovers.Services.AssetsImporter`. -- `AssetResolverService.RequestAsset` and `LoadSceneAsync` require assets to be pre-registered via `AddConfigs` / `AddAssets` / `AddAsset` (throws `MissingMemberException` otherwise). -- `AssetConfigsScriptableObject` inherits `AssetConfigsScriptableObjectBase` (not ``). The generic `TAsset` is captured only as `AssetType`. This is intentional for the Addressables weak-link pattern. -- **`IAssetAdderService.AddConfigs` is a C# 8 default interface method.** It is defined inline in the interface body (`void AddConfigs(...) => AddAssets(configs.AssetType, configs.Configs);`) and is NOT overridden in `AssetResolverService`. C# only dispatches default interface methods through the interface — calling `_resolverService.AddConfigs(...)` on a field typed `AssetResolverService` produces `CS1061: 'AssetResolverService' does not contain a definition for 'AddConfigs'`. Type the field as `IAssetAdderService` (or cast at the call site) before calling `AddConfigs`. The same rule applies to any future default interface methods added to `IAssetAdderService`, `IAssetResolverService`, or any other interface in this package. - -### AssetsConfigsImporter (Editor) -- The `TId` type parameter on `AssetsConfigsImporter` must satisfy `where TId : Enum`. Passing a non-enum identifier type will not compile. -- Editor-heavy methods (`AssetsConfigsImporter.Import`, `AddressableIdsGeneratorUtils.Generate`, `AddressableIdsGeneratorUtils.Diff`) are intentionally not covered by automated tests — they require `AssetDatabase` access and are validated manually via `Tools > GameLovers > Assets Importer / Import Assets Data`, `Tools > GameLovers > Addressable Ids / Generate Addressable Ids`, or the Services Explorer tabs. -- Settings for both tools are persisted via `ScriptableSingleton` in `ProjectSettings/` (not `Assets/*.asset`): `AssetsImporterEditorSettings.asset` and `AddressableIdsEditorSettings.asset`. -- **Addressable Ids last-generation snapshot**: `AddressableIdsEditorSettings.asset` stores the sorted address/label set, timestamp, and filename/label-filter that were used by the last successful `Generate()` call. The Services Explorer tab uses this snapshot for the "Check for stale Ids" diff. The asset is **project-shared** (lives under `ProjectSettings/`) — commit it to VCS so all contributors see the same diff baseline; do **not** add it to `.gitignore`. The snapshot is rewritten on every `Generate()` call, so no separate maintenance is required. -- **Cost separation in `AddressableIdsTab`**: the per-tick `Refresh()` path is intentionally restricted to `ComputeFreshness` (~20 file-stats) + snapshot read-back (in-memory). The "Check for stale Ids" button is the only call site for `Diff`, which runs the full `GetAssetList` + `ProcessData` pipeline (proportional to total Addressable entries). Do not move the diff into `Refresh()` "to make it live" — the design choice is on-demand-only by intent. If a future change adds an event-driven invalidation, prefer wiring `AddressableAssetSettings.OnModification` over polling. - -### CommandService Inheritance -- `CommandService` has `protected TGameLogic GameLogic` and `protected IMessageBrokerService MessageBroker` accessible in subclasses. -- `ExecuteCommand` is not declared `virtual`; to intercept execution, subclass and shadow with `new`, or implement `ICommandService` directly. - -### ScriptableSingleton and [SerializeField] -- Editor settings classes that extend `ScriptableSingleton` and use `[SerializeField]` fields **must** include `using UnityEngine;`. `SerializeFieldAttribute` lives in `UnityEngine`, not `UnityEditor` — the compiler will report `CS0246` if only `using UnityEditor;` is present. - -### Services Explorer Tab-Jump API -- `ServicesExplorerWindow.SelectTab()` and `ServicesExplorerWindow.OpenOnTab()` are constrained `where T : ServiceTab`. Cards, inspector buttons, and menu stubs that navigate to a tab must pass **tab types** (e.g. `AssetsImporterTab`, `AddressableIdsTab`, `VersioningTab`), not **service interface types** (`IAssetResolverService`, `IDataService`, etc.). Do not relax the constraint to `where T : class` — it exists specifically so the `_tabs` list lookup is strongly typed. -- `OverviewTab` holds a `ServicesExplorerWindow` reference injected via its constructor (`new OverviewTab(this)` in `RegisterTabs()`). Each card's `Open` button calls `_window.SelectTab()`. New cards follow the same pattern — add a `BuildXCard()` method that returns a `VisualElement` and registers the tab type at the call site. - -### Services Explorer Play→Edit Refresh Lifecycle -- `ServiceTab.OnPlayModeChanged(ExitingPlayMode)` does THREE things in this order: (1) call `OnExitingPlayMode()` synchronously to clear populated UI widgets, (2) call `UpdateBannerVisibility()`, (3) schedule a deferred `Refresh()` via `EditorApplication.delayCall`. The defer in step 3 is required so the refresh runs AFTER scene teardown (i.e., after consumer `MonoBehaviour.OnDestroy → MainInstaller.Clean()`); steps 1+2 give the user instant visual feedback that the session ended. -- The `EnteredEditMode` event issues another `Refresh()` as belt-and-braces in case `delayCall` was wiped by a domain reload. Together these guarantee that tabs surfacing populated state flip to their empty state immediately on Stop instead of holding a frozen last-play snapshot. -- **`ServiceTab.OnExitingPlayMode()` virtual** — populated-state tabs (`InstallerTab`, `MessageBrokerTab`, `DataTab`, `RngTab`, `PoolTab`, `CoroutineTab`, `TickTab`) override this to forcibly clear their lists/labels synchronously when Stop is pressed. Combined with a `!EditorApplication.isPlaying` short-circuit at the top of each `Refresh()`, this DECOUPLES the tab's empty state from `MainInstaller`/`*Service` static lifetime. Even if the consumer's bootstrap forgets to call `MainInstaller.Clean()` (or skips `TryCleanDispose` for `IPoolService` / `ICoroutineService` / `ITickService`) in `OnDestroy`, the tabs stay clean in edit mode. Do not collapse the override + short-circuit into either pattern alone — the override handles the synchronous transition tick (before scene teardown), the short-circuit handles all subsequent edit-mode refreshes. Any new tab that surfaces populated runtime state MUST add both pieces. -- The `tab-banner` text is context-sensitive: it shows `"Not in Play mode — showing last snapshot"` only before the first play session of the editor session and `"Play session ended — services unbound"` thereafter. The `_hasSeenPlay` latch is per-tab-instance (reset on domain reload, not persisted). Do not re-introduce a single static banner string without re-evaluating the cleanup-perception bug it fixed. - -### Services Explorer Sticky Foldouts -- Tabs whose `Refresh()` rebuilds the hierarchy from scratch (`AssetResolverTab`, `MessageBrokerTab`, and any future tab that calls `_tree.Clear()` then re-creates `Foldout`s) MUST use `ServiceTab.MakeStickyFoldout(key, text, defaultExpanded)` instead of `new Foldout { text = ..., value = true }`. Without it, the 250ms `RefreshIntervalMs` timer creates fresh `Foldout` instances every tick, which default to expanded (`value = true`) — collapse appears broken to the user because every refresh silently re-opens what they just closed. The sticky helper persists user-collapsed state in a per-tab `HashSet` keyed by a stable identity (e.g., `messageType.FullName`, `assetType.FullName`, `assetType.FullName + "/" + idType.FullName`). State lives only as long as the tab instance — domain reload resets it, which is fine for an editor-only diagnostic surface. Tabs whose foldouts are created ONCE in `BuildUi()` and only have their *contents* rebuilt in `Refresh()` (e.g., `TickTab`'s three top-level Update / FixedUpdate / LateUpdate foldouts) do NOT need the sticky helper — the foldout instance itself survives the refresh. The trap is specifically the rebuild-foldout-per-data-row pattern. -- **Nested-foldout `ChangeEvent` bubbling**: `MakeStickyFoldout`'s value-changed callback filters with `if (evt.target != foldout) return;`. This filter is REQUIRED — UIToolkit `ChangeEvent` bubbles up the visual tree, so a user click on an inner foldout's `Toggle` propagates as a ChangeEvent through every ancestor `Foldout` and would otherwise reach the outer foldout's callback (target = the inner toggle, not the outer foldout). Without the filter, collapsing an inner foldout silently marks the OUTER foldout collapsed too in the keys set, and the next periodic refresh re-renders both as collapsed — the visible bug is "the inner foldout's chevron collapses everything". Do not relax this filter when adding new ancestor-aware foldout interactions; if you need to react to a descendant's value change, register a separate `RegisterCallback>` with explicit target/source checks rather than relaxing the filter inside `MakeStickyFoldout`. -- **Rapid-click resilience via digest short-circuit**: `ServiceTab.TryShortCircuitRefresh(string digest)` lets a tab return early from `Refresh()` when the displayed data is unchanged. This is required for tabs with rebuild-the-tree refresh (`AssetResolverTab`, `MessageBrokerTab`). Without the short-circuit, the every-250-ms timer destroys mouse-captured `VisualElement`s mid-click and Unity loses the mouse-up — rapid foldout clicks appear lost because the click was never recorded against any live target. The digest must capture *every* piece of state the rebuild path conditions on (e.g. for `AssetResolverTab` that includes the `_destructiveToggle.value` flag, since it gates per-row Unload buttons; the toggle wires `RegisterValueChangedCallback(_ => InvalidateRefreshDigest())` so flipping it forces the next refresh to rebuild). Action paths that mutate the displayed data don't need explicit invalidation — the data change naturally produces a different digest. Action paths that mutate `VisualElement`s directly (e.g. `MessageBrokerTab.OnExitingPlayMode` clears `_list` outside the rebuild) should rely on the base class invalidating the digest on play-mode transitions (`OnAttach`, `EnteredPlayMode`, `ExitingPlayMode`, `EnteredEditMode`, and the deferred exit refresh) — see the `InvalidateRefreshDigest()` calls in `ServiceTab`. New rebuild-style tabs MUST add a `ComputeDigest(...)` method and call `if (TryShortCircuitRefresh(digest)) return;` at the top of `Refresh()`, otherwise the rapid-click lost-input bug returns. - -### Services Explorer Destructive-Action Styling -- Use `MakePrimaryDangerButton(...)` (not `MakePrimaryButton`) for any tab action-bar primary that removes or invalidates state — `Stop All Coroutines`, `Unsubscribe All`, `Clean All`, etc. The helper applies the `.action-primary-danger` USS class (red-tinted bg + border + bold light-pink text). The previous convention of using the plain blue `.action-primary` for destructive actions is gone. -- The companion per-row `.row-btn-danger` class follows the same tinted-bg + border + bold light-text pattern. Do NOT regress either class to text-color-only styling — red text on Unity's default mid-grey button background fails contrast in both Personal (light) and Professional (dark) editor skins. - -### Version Data Pipeline -- Runtime expects a Resources TextAsset named `version-data` (`VersionServices.VersionDataFilename`). The filename is a runtime `const` and is not configurable. -- `VersionEditorUtils` writes `version-data.txt` on every domain reload (`[InitializeOnLoadMethod]`) and can be invoked by build pipelines. It uses git CLI; failures are handled gracefully. -- The **write folder** is configurable per-project via `VersioningEditorSettings.instance.ResourcesFolderPath` (default `Assets/Configs/Resources`). Change it from the Versioning tab in the Services Explorer (browse + reset). The chosen folder must contain a `Resources` path segment so `Resources.Load("version-data")` can locate the file at runtime. -- `VersioningEditorSettings` persists to `ProjectSettings/VersioningEditorSettings.asset` (editor-only, not committed to version control by default). -- `VersionExternal` is always safe (reads `Application.version` directly). `VersionInternal`, `Branch`, `Commit`, and `BuildNumber` auto-bootstrap at `[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]` (private `Bootstrap` method calling `LoadVersionData()`), and additionally lazy-load on first property access via the private `EnsureLoaded()` — covering the case where a sibling-assembly `SubsystemRegistration` callback fires before this package's. When the `version-data` Resource is missing or fails to parse the accessors return their documented fallbacks (`VersionInternal` → `Application.version`, others → `string.Empty`) and a `Debug.LogError` is emitted; no exception is raised. -- **Sync vs async load**: `LoadVersionData()` uses `Resources.Load` (synchronous, main-thread); `LoadVersionDataAsync()` uses `Resources.LoadAsync` wrapped in a `TaskCompletionSource`. Both funnel into the private `ApplyTextAsset(TextAsset, bool asyncContext)` helper that parses JSON, flips `_loaded`, and calls `Resources.UnloadAsset`. Behaviour is identical apart from the wording in the failure log line (`"Could not async load …"` vs `"Could not load …"`). The sync variant is what `Bootstrap` calls and what `EnsureLoaded()` falls back to; both methods remain public for callers that want explicit pre-warming. The async variant is only worth the ceremony if `VersionData` is extended with large embedded blobs (e.g. a baked manifest) that would noticeably stall the main thread. -- `VersionServices.IsOutdatedVersion(string)` requires a 3-part `Major.Minor.Patch` semver and throws `IndexOutOfRangeException` on any 1- or 2-part input — the parser unconditionally accesses `Split('.')[0..2]`. Consumers that compare against `Application.version` must ensure `ProjectSettings.bundleVersion` is 3-part (defaults to `0.1` / `1.0` for fresh projects, which will throw). Either bump `bundleVersion` to a 3-part value, guard with `parts.Length < 3` at the call site, or harden the method itself with a length guard. Tests calling this against the host editor's `Application.version` should `Assert.Inconclusive` on shorter strings rather than `Assert.Throws` — the throw is a real production bug surface, not a documented contract. - -### Editor Introspection (InternalsVisibleTo) - -`Runtime/AssemblyInfo.cs` grants `[assembly: InternalsVisibleTo("GameLovers.Services.Editor")]`. -Services expose minimal `internal` read-only accessors so the Services Explorer can display state without widening the public API: -- `Installer.Bindings`, `MainInstaller.InstallerInstance`, `MessageBrokerService.Subscriptions` / `IsPublishing` -- Tick lists (`OnUpdateList`, `OnFixedUpdateList`, `OnLateUpdateList`) plus internal `TickData`; `CoroutineService.ActiveAsyncCoroutines` under `#if UNITY_EDITOR` -- `PoolService.Pools`, `DataService.DataEntries`, `TimeService.ExtraTime` / `InitialTime`, `AssetResolverService.AssetMap` - -**Rule**: if you add a new service and want to surface it in the Explorer, add a new `internal` read-only accessor (no behavior change) and create a new `ServiceTab` subclass in `Editor/Explorer/Tabs/`. Do not add public accessors solely for editor introspection — use `internal` + `InternalsVisibleTo`. - -### Sample Zero-Setup Invariant -- `Samples~/ServicesPlayground/` MUST remain Addressables-free and Resources-load-free at runtime. The pooled `Bullet` sample entity is generated programmatically (`GameObject.CreatePrimitive(PrimitiveType.Sphere)` in `ServicesBootstrap.GetOrCreateBulletPrefab()`); `VersionServices.LoadVersionDataAsync()` reads `version-data.txt` that is written automatically by the host project's `Editor/Versioning/VersionEditorUtils` on every domain reload — the sample does not own that file. -- `ServicesBootstrap.ApplyBulletMaterialColor` MUST set both `_BaseColor`/`_Color` (diffuse tint) AND enable `_EMISSION` with an `_EmissionColor` for the bullet material — the playground scene ships with no lights, and Lit shaders (URP / HDRP / Built-in Standard) render near-black under no lighting. The emission keyword + `globalIlluminationFlags = None` make the sphere self-illuminate so it is visible regardless of pipeline. Do not strip the emission setup "for simplicity" — it is the single thing keeping the pool demo readable. -- `ServicesPlaygroundUI.Coroutine_StartAsync` MUST use a wait long enough to span multiple Services Explorer refresh cycles (`RefreshIntervalMs = 250` on `CoroutineTab`). The current implementation uses `WaitForSeconds(3f)`. Do not regress to short `WaitFrames(60)`-style helpers — at editor framerates above ~60 fps the coroutine completes inside a single refresh cycle and the user sees no entry in the Coroutine tab, making the demo button look broken even though the runtime is correct. -- The sample UI is shipped as a hand-authored prefab (`ServicesPlaygroundUI.prefab` / `AssetResolverUI.prefab`). The runtime script holds `[SerializeField]` references to every Button + the `TMP_Text` log/live-status panes and wires `onClick.AddListener` in `Awake`. The driver also calls `EnsureInputModuleOnEventSystem()` so the scene's `EventSystem` carries the right input module for the consumer's Active Input Handling setting (legacy `StandaloneInputModule` vs `InputSystemUIInputModule`, picked at runtime via `#if ENABLE_INPUT_SYSTEM`). UI text uses `TextMeshProUGUI` from the `com.unity.textmeshpro` package — consumers must import TMP Essentials once on first use. -- **Re-generating sample prefabs from code**: prefabs are stored in source as fully serialized YAML, but they were originally generated by a one-shot `Assets/Editor/Tools/GenerateSamplePrefabs.cs` utility (deleted after first validation). If a future change requires a wholesale prefab rebuild (e.g. swapping uGUI for UI Toolkit, or restructuring the section grid), restore that utility — its menu items did `PrefabUtility.SaveAsPrefabAsset` + `PrefabUtility.InstantiatePrefab` + `EditorSceneManager.SaveScene` and mirrored prefab/scene from `Assets/Samples/...` to `Packages/com.gamelovers.services/Samples~/...` via `System.IO.File.Copy`. The mirror direction matters: `PrefabUtility` can only write under `Assets/`; raw file copy is required to push the result back into the package source folder. -- Do NOT migrate `ServicesPlayground` to use `AssetResolverService` or `Addressables.LoadAssetAsync` "to consolidate samples". The `AssetResolver` sample is the explicit place for the Addressables setup story. -- **AssetResolver sample editor automation is sample-scoped, not package-wide.** `Samples~/AssetResolver/Editor/AssetResolverSampleSetup.cs` lives inside the sample folder (assembly `GameLovers.Services.Samples.AssetResolver.Editor`) so it is imported only when the user imports the sample, and removed when they remove it. The script DOES use `[InitializeOnLoadMethod]` as a safety net (UPM-import chicken-and-egg: `OnPostprocessAllAssets` fires for the sample's sprites BEFORE the post-processor itself compiles in the very first import, so the post-processor would otherwise miss its own first invocation), but because the script lives in the sample folder, removing the sample also removes this `InitializeOnLoad` handler — there is no orphan handler running in consumer projects after sample deletion. The package's main `Editor/` assembly does NOT reference the sample editor assembly; the inspector button in `AssetConfigsScriptableObjectEditor` invokes the sample's menu item via `EditorApplication.ExecuteMenuItem("Tools/GameLovers/Samples/Asset Resolver/Refresh Addressables")` to keep the boundary clean. Do not move `AssetResolverSampleSetup` into the main `Editor/` assembly. The automation is idempotent — repeated runs are no-ops, suppress all logs when nothing changed (`silent` path), and existing user mappings on `SpriteConfigs.asset` rows that point at a different sprite are NEVER overwritten (the wiring code skips the row when `m_AssetGUID` is non-empty and differs from the canonical sprite's GUID). -- **AssetResolver sample binds via `MainInstaller`** (matching `ServicesPlayground`). `AssetResolverExample.Start()` calls `MainInstaller.Bind(_resolver)` and `OnDestroy()` calls `MainInstaller.Clean()`. Without the bind, the Services Explorer **Asset Resolver** tab shows `"IAssetResolverService not bound"` while the sample is the only thing running (it falls back to `TryResolve()` against `MainInstaller`). The field is typed as the concrete `AssetResolverService` so `AddConfigs` (a C# 8 default interface method on `IAssetAdderService`) can be called via an explicit cast — see also the `IAssetAdderService.AddConfigs` gotcha higher in §4. -- **AssetResolver sample-scoped Explorer-jump menu**. The sample editor assembly registers `Tools > GameLovers > Samples > Asset Resolver > Open in Explorer` (calling `ServicesExplorerWindow.OpenOnTab()`). The sample's runtime UI's "Open Services Explorer" button invokes that menu via `EditorApplication.ExecuteMenuItem` under `#if UNITY_EDITOR`, so the sample's RUNTIME assembly never has to take a reference on `GameLovers.Services.Editor`. This is the documented pattern for runtime sample UI to cross into editor code (matching `AssetConfigsScriptableObjectEditor`'s sample-scoped refresh button). Do not collapse this indirection by giving the sample's runtime assembly a direct reference on the editor assembly — that breaks player builds (editor assemblies are not included). -- **AssetResolver sample applies the Addressables label `services-sample-asset-resolver`** to every sprite the post-processor marks Addressable (`settings.AddLabel(LabelName)` once + `entry.SetLabel(LabelName, true, force: true)` per entry). This is the single piece that lets the **Addressable Ids** tab demo a sample-scoped Generate against this sample without leaking into the user's other Addressables. The sample DOES NOT auto-mutate `AddressableIdsEditorSettings` (a `ScriptableSingleton` persisted in the user's `ProjectSettings/`) — the per-sample README walks the user through typing the three field values manually so the demo teaches the tab's UX rather than mutating user-owned state. Do not "improve" the sample by writing those settings on import. -- **Sample-removal cannot self-cleanup** (this was tried, this is why it doesn't work). Unity Package Manager has no per-sample Remove button — sample uninstall happens when the user manually deletes the imported folder under `Assets/Samples/...//` from the Project window. A natural-feeling fix would be `AssetPostprocessor.OnPostprocessAllAssets` watching `deletedAssets` for the sample's editor script's own path, but that does NOT work: when a `.cs` file is deleted, Unity recompiles BEFORE firing `OnPostprocessAllAssets` for the deletion batch. The recompile drops the just-deleted script from the new assemblies, so the post-processor class itself no longer exists by the time the deletion-batch callback would fire — the callback never reaches a live class. The post-processor cannot watch for its own funeral because it's already in the casket. Therefore: any sample whose import path mutates `AddressableAssetSettings`, `ProjectSettings/`-stored singletons, or any other project-state outside `Assets/Samples/...//` MUST document the manual cleanup steps in its per-sample README. Do not attempt a self-cleanup `OnPostprocessAllAssets` deletion-watch — it's dead code dressed up as a fix. The two viable paths if cleanup is genuinely required are (a) move the cleanup logic into the package's main `Editor/` assembly with hardcoded constants for the sample's group/label names + an `[InitializeOnLoadMethod]` orphan-detector that runs every domain reload (violates the sample-scoped boundary; runs in every consumer's project forever), or (b) accept the original "removing this group is the user's undo" design — for the AssetResolver sample, we chose (b). -- Sample-only types live in `GameLovers.Services.Samples.` namespaces specifically so contributors and AI assistants cannot mistake them for package public API. When updating `Samples~/README.md` or per-sample READMEs, never describe these types as part of the services API surface — UiService's master README has historical drift on this exact point (presenter-defined `OnCloseRequested` events surfaced as if they were API). -- **Sample meta-file policy**: `Samples~/ServicesPlayground/*.cs.meta`, `*.unity.meta`, etc. are hand-authored with **deterministic GUIDs** so the shipped `.unity` scene can reference scripts at commit time (matching the UiService pattern — see `Packages/com.gamelovers.uiservice/Samples~/BasicUiFlow/BasicUiExamplePresenter.cs.meta`). When adding a new `.cs` or scene/prefab to a sample, pick a stable random GUID and write the `.meta` yourself — do not let Unity generate a fresh random one, otherwise the scene's `m_Script: {fileID: 11500000, guid: ..., type: 3}` references will break for every fresh import. Folder `.meta` files inside `Samples~/` are NOT required (Unity treats `Samples~` as a non-asset folder); only file-level `.meta` are needed. - -### Error Quick-Reference -- `Installer.Bind` throws `ArgumentException` for non-interfaces or duplicate bindings; `MainInstaller.Resolve` throws `KeyNotFoundException` when missing. -- `MessageBrokerService.Subscribe` rejects static methods; direct `Publish` can throw if the subscription list is mutated during dispatch. -- `DataService.GetData` throws when missing; `LoadData` requires a parameterless constructor. -- Duplicate `PoolService.AddPool` calls throw; `AssetResolverService` requests throw `MissingMemberException` until assets/scenes are registered. -- `VersionInternal`, `Branch`, `Commit`, and `BuildNumber` no longer throw — auto-bootstrap + lazy-load make access safe at any phase; missing-Resource cases fall back to `Application.version` / `string.Empty` with a `Debug.LogError`. - -## 5. Coding Standards (Unity 6 / C# 9.0) -- **C#**: C# 9.0 syntax; explicit namespaces; no global usings. -- **Assemblies** - - Runtime must not reference `UnityEditor`. - - Editor tooling must live under `Editor/` (or be guarded with `#if UNITY_EDITOR` if absolutely necessary). -- **Performance** - - Be mindful of allocations in hot paths (e.g., `PublishSafe` allocates; tick lists mutate; avoid per-frame allocations). - -## 6. External Package Sources (for API lookups) -Prefer local UPM cache / local packages when needed: -- GameData (`floatP`, `MathfloatP`): `Packages/com.gamelovers.gamedata/` -- Unity Newtonsoft JSON: check `Library/PackageCache/` if you need source details -- Unity Addressables API: `Library/PackageCache/com.unity.addressables@/` -- UniTask API: `Library/PackageCache/com.cysharp.unitask@/` - -## 7. Package Dev Workflows (common changes) -- **Add a service**: add runtime interface + implementation under `Runtime/`, keep UnityEngine usage minimal, add/adjust tests, and use the `TickService`/`CoroutineService` host pattern for Unity callbacks. -- **Add a command**: implement `IGameCommand` with `void Execute(TGameLogic, IMessageBrokerService)` and add unit coverage in `Tests/EditMode/Unit/CommandServiceTest.cs`. -- **Update versioning**: ensure `version-data.txt` still lands under a Resources folder and update both runtime parsing and `VersionEditorUtils` writing when `VersionServices.VersionData` changes. - -## 8. Update Policy -Update this file when: -- Binding/service-locator APIs, core service behavior, asset loading/import, or versioning behavior changes -- Dependencies, package layout, namespace mapping, or external type requirements change -- New services, editor tabs, inspectors, scaffolders, or internal Explorer accessors are added -- Menu paths under `Tools/GameLovers/...` change (update §3 Editor folder map and §4 gotcha) -- New `ScriptableSingleton` settings files are added or their `[FilePath]` changes (update §3) -- `AddressableIdsEditorSettings` or `AssetsImporterEditorSettings` validators change (update §4) -- Sample folder structure, sample-only types, or per-sample setup requirements change → update [`Samples~/README.md`](Samples~/README.md), the matching per-sample `README.md`, the `samples[]` block in [`package.json`](package.json), AND the `Samples` row in §3 above. Adding a new sample requires all four edits in lockstep to avoid the README-vs-source drift documented in `Samples~/README.md`. +# GameLovers Services — Agent Guide + +This guide adds package-specific rules to the host repository guide. Consumer usage belongs in `README.md` and `docs/`. + +## Scope + +- Package: `com.gamelovers.services`; minimum Unity version and dependencies are authoritative in `package.json`. +- Runtime includes installation/service lookup, messaging, ticking, coroutines, pooling, persistence, time, deterministic RNG, commands, version metadata, and Addressables-backed asset resolution. +- Editor tooling includes the Services Explorer, asset/config importers, Addressable ID generation, scaffolders, and version-data generation. +- This package is render-pipeline-neutral. + +## Namespace and assembly boundaries + +- Concrete services at `Runtime/` root use `GameLovers.Services`. +- `Runtime/DependencyInjection/` is a namespace carve-out and also uses `GameLovers.Services`. +- Contracts and implementations under `Runtime/Commands/`, `Runtime/Pooling/`, and `Runtime/AssetsImporter/` use their corresponding child namespaces. +- Runtime must not reference `UnityEditor`. Editor introspection uses `internal` read-only accessors plus `InternalsVisibleTo`; do not widen runtime APIs solely for the Services Explorer. + +## Runtime invariants + +- `Installer` binds interface types to instances. Non-interface and duplicate bindings throw. `MainInstaller` exposes only single-interface binding; multi-interface binding is on `Installer`/`IInstaller`. +- `Publish` iterates the live subscriber collection and cannot tolerate subscription mutation. Use `PublishSafe` when handlers may subscribe or unsubscribe. Static-method subscriptions are unsupported because subscribers are keyed by `action.Target`. +- `Unsubscribe(null)` clears subscribers for that message type; `UnsubscribeAll(null)` clears all message types. Preserve these explicit bulk-clear contracts. +- `TickService` and `CoroutineService` each own a `DontDestroyOnLoad` host and are not singletons. Dispose every created instance. +- Public async-coroutine completion callbacks have replace semantics. Editor tracking uses the separate internal cleanup event and must not overwrite consumer callbacks. +- `PoolService` permits one pool per entity type. Pool code that touches Unity objects must guard Unity fake-null because consumers may destroy pooled objects externally. +- `ObjectPool` dispatches lifecycle hooks by casting the entity. `GameObjectPool` and `GameObjectPool` discover hooks with `GetComponent`; implement hooks on the object appropriate to the chosen pool type. +- `DataService` stores reference types by `Type`; persisted keys use `typeof(T).Name`, and absent saved values require a parameterless constructor. +- `AddressablesAssetLoader.UnloadAsset` releases synchronously and does not destroy instantiated GameObjects or force global memory collection. +- `IAssetAdderService.AddConfigs` is a default interface method and dispatches through an interface-typed reference, not through `AssetResolverService` directly. +- `AssetConfigsScriptableObject` stores `AssetReference` entries through its base class; `TAsset` describes the resolved asset type. Do not replace the weak-link storage with serialized concrete assets. +- `VersionServices` auto-loads and lazy-loads `version-data`; missing data returns documented fallbacks and logs an error. `IsOutdatedVersion` requires three-part semantic versions unless the method is hardened. +- `CommandService` exposes protected `GameLogic` and `MessageBroker` for subclasses, but `ExecuteCommand` is not virtual. Interception requires an intentional interface implementation or explicit shadowing. + +## Editor invariants + +- Settings derived from `ScriptableSingleton` live under `ProjectSettings/`. Include `using UnityEngine;` when using `SerializeField`. +- Addressable ID generation records its last successful sorted address/label snapshot in `AddressableIdsEditorSettings.asset`. That project-shared baseline is rewritten by generation and should not be silently ignored or recomputed during every Explorer refresh. +- Services Explorer tab navigation is typed to `ServiceTab`; callers pass tab types, never service interfaces. +- Tabs that rebuild foldouts on refresh must preserve expansion state and short-circuit unchanged refreshes so periodic rebuilds do not lose pointer input. Filter nested foldout change events by target. +- Runtime-state tabs clear synchronously when leaving Play mode and also reject populated refreshes in Edit mode. Do not depend on consumer teardown to clean the display. +- Keep expensive Addressable ID diffs user-triggered; periodic refresh may use only cheap freshness checks. + +## Samples and tests + +- `ServicesPlayground` remains free of Addressables/Resources setup and uses the shipped TMP-backed prefab UI. +- `AssetResolver` owns its sample-scoped Addressables automation, runtime/editor asmdefs, group, and label. Do not move sample automation into the package Editor assembly. +- The AssetResolver sample binds its real resolver through `MainInstaller` so the Explorer sees the running instance. Crosses into Editor code through sample-scoped menu commands, not runtime assembly references. +- Sample automation is idempotent and preserves existing non-empty consumer mappings. Manual cleanup of the sample-owned Addressables group/label remains documented because a deleted postprocessor cannot observe its own removal. +- Sample scenes/prefabs and scripts use deterministic file-level `.meta` GUIDs so serialized script references survive a fresh Package Manager import. Preserve or deliberately update those GUID relationships when changing sample assets. +- Before changing anything under `Tests/`, read `Tests/AGENTS.md`. + +## Verification and documentation + +- Lifecycle changes require ownership, idempotent disposal, and multi-instance isolation tests. +- Addressables/importer changes require explicit catalog or `AssetDatabase` verification in addition to unit tests. +- Update `README.md`/`docs/` for consumer behavior and `CHANGELOG.md` for notable changes. Update the sample index, per-sample README, and `package.json` together when sample structure changes. +- Update this guide only for durable package invariants, boundaries, or test conventions. diff --git a/Tests/AGENTS.md b/Tests/AGENTS.md index 5f3a002..f88a696 100644 --- a/Tests/AGENTS.md +++ b/Tests/AGENTS.md @@ -1,361 +1,94 @@ -# GameLovers.Services Tests — AI Agent Guide +# GameLovers Services Tests — Agent Guide -This file contains testing conventions for the `com.gamelovers.services` package. It is the source of truth when reading, editing, or creating test files under `Tests/`. +This guide adds test-only rules to the package and host guides. -For runtime architecture, gotchas, and package-level context, see the parent [`AGENTS.md`](../AGENTS.md). +## Shared test rules -§1 and §2 are shared verbatim across every GameLovers package. A change to either must be applied to all six `Tests/AGENTS.md` files in the same working session, one commit per submodule. + +### Admission -## 1. ADMIT — Test Admission Test +A new test is admitted only when all six answers are yes: -A proposed test is admitted only if all five answers are YES. Record the first two -as comments on the test itself. - -| | Question | +| Check | Requirement | |---|---| -| **A1 DEFECT** | Can you name the defect in one sentence, referencing a production file and symbol? "It could break" is not a defect. | -| **A2 RED** | Can you name the exact production edit — one line or one branch, identified by `file` + `symbol` — that makes this test fail? If no such single edit exists, the test pins nothing. | -| **A3 PACKAGE** | Does every assertion read a value this package computed? Reject assertions on `new X() != new X()`, `!= null` on a freshly constructed object, default struct/enum values, or anything the C# spec or the Unity engine already guarantees. | -| **A4 CHEAPEST** | Is this the cheapest tier that covers the defect? EditMode beats PlayMode; a `[TestCase]` row on an existing fixture beats a new `[Test]`; a new `[Test]` beats a new fixture. Grep before writing. | -| **A5 UNIQUE** | Does no existing test already fail on the A2 edit? Grep the symbol under test across `Tests/` first. | -| **A6 ENVIRONMENT** | Would this assertion's outcome change if project configuration changed — a renderer feature installed or removed, an Addressables catalog built, a sample imported, a quality tier switched? If yes, the test must **read** that state, not assume one value of it. | - -**A6 in practice.** A6 is not A3. A3 asks whether the package computed the value; A6 -asks whether the test assumed which value it would be. A test can satisfy A3 and still -fail A6 — reading a package-computed flag is fine, hard-coding the expectation that the -flag is `false` is not. - -The concrete instance: `UiBackdropBlurPresenterFeatureTests` unconditionally expected -the "no renderer feature installed" error. Production only logs it when -`UiBackdropBlurRendererFeature.IsInstalled` is false. Batchmode never instantiates the -URP renderer, so the flag was false and all five tests passed; in the Editor the feature -registers from the project's renderer asset, the flag is true, production correctly stays -silent, and all five failed. The fixture was really asserting *"this project has no blur -renderer feature"* — a fact about the repo, not about the code under test. - -The fix shape is always the same: branch the expectation on the state instead of assuming -it, and leave the assertions that are actually the subject untouched. - -```csharp -if (UiBackdropBlurRendererFeature.IsInstalled) return; // production logs nothing -LogAssert.Expect(LogType.Error, ...); -``` +| **A1 Defect** | Name the production file/symbol and the incorrect behavior in one sentence. “It could break” is not a defect. | +| **A2 Red** | Name a plausible production edit that should make the assertion fail. Prefer one line/branch; shared-path integration mutations are allowed when isolation is dishonest. | +| **A3 Package-owned** | Every assertion must read behavior this package computes, not C# defaults, fresh-object non-nullness, or Unity guarantees. | +| **A4 Cheapest** | Use the cheapest honest tier: a test case before a new test, EditMode before PlayMode, and an existing fixture before a new fixture. | +| **A5 Unique** | Grep the symbol, derived/wrapper types, and paired setup fields. Do not add a test already reddened by the same narrow defect. | +| **A6 Environment** | Control ambient renderer, Addressables, sample, static, and project state, or branch the expectation on the state actually observed. | -If a test genuinely needs one specific value of ambient state, it must establish that -state itself in `[SetUp]` and restore it in `[TearDown]` — never inherit it. +Two additional rejects apply: -**A5-bis — inherited-type coverage.** Before proposing a fixture for a type that -derives from or wraps another tested type, grep `Tests/` for the derived type's -name and for paired `[SetUp]` fields. Base-and-derived pairs are tested jointly in -the base's fixture unless the derived type adds new public surface. +- **D1 Tautology:** a lone `DoesNotThrow`, freshly-created non-null assertion, language default, or input-derived substring match pins no package behavior unless used by a named harness sentinel. +- **D2 Name/body mismatch:** deleting or bypassing the behavior promised by the test name must not leave the test green. Strengthen the assertion or rename the test to its actual claim. -**Two mechanical disqualifiers** — violate one and the test is rejected: +Fixtures under `Smoke/` are exempt from A1/A2 and may assert construction/bootstrap viability. The exemption is directory-scoped, not permission to use smoke assertions in Unit or Integration fixtures. -- **D1 — tautology.** If the only assertion is `Assert.DoesNotThrow`, - `Assert.IsNotNull`, or a disjunction of `Contains(...)` substrings, the test - fails A2 unless you write down what *would* throw, be null, or not match. A - substring disjunction that includes a string the input itself embeds is - unfalsifiable by construction. -- **D2 — name/body contract.** The test name is a claim. If deleting the - production feature the name mentions leaves the test green, the name is a lie. +### Revert and Confirm Red (RCR) -**Smoke exemption, by directory.** Fixtures under `Smoke/` are exempt from A1 and -A2 and may assert construction-without-throwing only. Their defect class is "the -assembly no longer loads / bootstrap regressed", which is real and not expressible -otherwise. The exemption is by directory, not by assertion shape — a Unit test -that only asserts `IsNotNull` is still rejected. +Every new or strengthened behavioral test must be observed failing once against a plausible production mutation before commit: -## 2. RCR — Revert and Confirm Red +1. Run the new test against normal production code and observe GREEN. +2. Preserve the exact working patch or use an isolated worktree; then apply the A2 mutation. Never restore a dirty file from `HEAD`. +3. Run the smallest attributable filter. RED must come from the intended assertion with a diagnostic failure, not a compile error or unrelated `NullReferenceException`. +4. Restore the saved production state, confirm the mutation is gone without losing other edits, and observe GREEN again. +5. Record the observation on the test using `file + symbol`, never a line number. -> Every new or strengthened test must be observed failing, once, against a -> one-line production revert, before it is committed. - -Line coverage proves a line executed. It does not prove any test would notice if -that line were wrong. RCR is the cheap substitute for mutation testing, and it is -what makes a coverage number trustworthy. - -**Procedure** (~90 seconds per test): - -1. Write the test. Run it. Green. -2. Apply the A2 edit — invert the comparison, delete the guard clause, return - early, comment out the one line. **One line only**: a broad deletion proves - nothing, because it would also "fail" a tautological test via a compile error. -3. Run only that test. It must be **RED**, and the failure message must name the - thing you broke. A red-by-`NullReferenceException` does not count — that is the - test crashing, not asserting. -4. `git checkout -- `. Re-run. Green. -5. Record the mutation in the test's header comment. - -**Recording format** — on the test, not in a separate ledger. A ledger rots the -moment a test is renamed; a comment travels with the test, appears in every diff -that touches it, and lets a reviewer re-run the mutation in 30 seconds. +Use this compact form, targeting four lines and never exceeding six: ```csharp [Test] -// ADMIT: -// RCR: — → RED (). +// ADMIT: +// RCR: — → RED (). public void Method_Condition_ExpectedResult() ``` -**Anchor on `file` + `symbol`, never `file:line`.** Line numbers rot on the first -unrelated edit above them — a stale `:474` pointing at a method that moved to `:464` -sends the next reader to the wrong code and quietly destroys the comment's value. - -**Budget: four lines is the target, six is the ceiling.** One sentence of ADMIT, -one of RCR, wrapped. This obeys the repo-wide rule in the root `AGENTS.md` -(§ Code comments): *"One sentence usually suffices. Multi-paragraph rationale is a -smell."* Anything past the ceiling belongs in the commit body or `docs/`, not on the -test. Two things in particular must NOT appear here: -- **Change narration.** *"An earlier version of this test was a tautology"* is diff - context; the root `AGENTS.md` forbids it outright. A comment states the code's - permanent condition, not its history. Put it in the commit message. -- **Investigation transcript.** The empirical detail that convinced *you* is not - what the next reader needs. They need the mutation and the expected failure. - -The one extension worth its lines is a **negative** result: naming a nearby edit -that looks like a valid mutation but is NOT one (because it is already guarded, or -because it reddens a sibling test instead). That stops the next reader repeating a -dead end, and it cannot be recovered from the code. - -Also add one line per new test to the commit body: `RCR: ← `. -That makes `git log --grep=RCR` the audit surface. +Do not narrate investigation history in the test. A nearby mutation that looked valid but stayed green may be recorded when that negative result prevents repeated work. -**UNFALSIFIABLE — the one honest exemption.** Some correct tests provably have no -one-line mutation. The commonest case is **double-guarded validation**: an -unconfigured object trips two independent guards, so disabling either leaves the -other throwing. Deleting such a test would lose real coverage, so it is exempt — -but only on the same terms as §13, never as a shrug: +When a test resists an isolated mutation, classify it before acting: -```csharp -// RCR: none exists — trips both and ; disabling either -// leaves the other throwing (verified). Double-covered, not single-line falsifiable. -``` - -The reason must be falsifiable and must record that a mutation was actually tried -and observed green. "Couldn't find one" is not a reason — that is an unfinished RCR, -not an exemption. - -**Verdicts for a test that resists mutation.** Work out which of four it is; they -have different answers: - -| Finding | Test | Action | +| Verdict | Meaning | Action | |---|---|---| -| **A3 reject** — no line in `Runtime/` or `Editor/` participates; the assertion is C#- or Unity-guaranteed | pins nothing, ever | **Delete** | -| **A5 duplicate** — the only mutation that reddens it already belongs to a sibling | pins nothing new | **Delete**, naming the surviving sibling in the commit body | -| **D2 overclaim** — the name promises behaviour the body cannot detect | name is a lie | **Strengthen the assertion**, or rename to what it actually checks | -| **UNFALSIFIABLE** — real behaviour, but double-guarded or otherwise unbreakable one line at a time | valid | **Keep**, with the exemption comment above | -| **SHARED-PATH** — no unique one-line pin, but the test was *observed* reddening under a broader mutation | valid | **Keep**, recording the covering mutation and its blast radius | - -**SHARED-PATH exists because blast radius measures specificity, not value.** A test that -only reddens under a broad mutation still catches that regression — an integration test -that dies when `UiService.CloseUi` is gutted is doing its job, even though no single line -is *its* line. Without this row the table offers only delete-or-strengthen, and such tests -get deleted for the crime of being integration tests. - -```csharp -// ADMIT: exercises 's ; no unique one-line pin. -// RCR: no isolated mutation — reddens under 's mutation (radius N, verified). -// Shared-path coverage, not a duplicate. -``` - -The radius must be a **recorded observation**, not an estimate. This is also the row most -easily abused: "some mutation somewhere reddened it" is not the standard. Distinguish it -from A5 by asking what the covering mutation actually broke — if it broke the one narrow -guard the sibling owns, this is a duplicate; if it broke a path both tests legitimately -traverse, this is shared-path coverage. - -A cluster of tests that all die to the same broad mutation is **over-provisioned, not -individually worthless**. Thinning it is a deliberate editorial decision made by a human -looking at what each assertion adds — never an automatic consequence of the verdict pass. - -**A3 is checked first, and it is the commonest way the exemption gets abused.** -UNFALSIFIABLE is for behaviour this package genuinely owns but cannot be broken one -line at a time. It is *never* for behaviour the package does not own. The tell is in -the reason itself: if you find yourself writing "no line in Runtime/ participates", -"the only edit is a compile error", or "these are C#'s zero-init values", you have -found an A3 reject and the verdict is **delete** — a field-only struct's assignment -and default values are the language's guarantees, not yours. Writing that sentence -under an UNFALSIFIABLE heading launders a test §1 would never have admitted. - -Prove the class before acting. An A5 duplicate is confirmed when the sibling's -mutation is observed reddening both; a D2 overclaim is confirmed when the mutation -the name implies leaves the test green; an A3 reject is confirmed when no production -symbol appears anywhere in the causal chain behind the assertion. - -**Two consequences, stated so RCR does not become theatre:** - -- A test with no `// RCR:` line — and no UNFALSIFIABLE exemption — is not trusted - coverage. In an audit it is a suspect by default. **`Smoke/` is exempt here too**, on the - same directory basis as §1: its defect class is "the assembly no longer loads", which has - no one-line mutation, so demanding an RCR line there flags those fixtures forever. The - exemption is the directory, not the assertion shape. -- **"Unannotated" is three states, not one, and they need different actions.** A test with no - `// RCR:` line may have been (a) observed RED with the write-back lost, (b) seen reddening - only as collateral inside another test's blast radius, or (c) never probed. Only (c) needs a - probe; (a) needs the recorded observation written back; (b) is SHARED-PATH evidence, not a - unique pin. Check `.test-all/rcr/` before probing, and never write prepared annotation text - without a matching `RED-OK` for that test — prepared text also exists for tests that were - never probed, and writing it fabricates a verified claim. -- **Benchmarks are included, inverted:** a performance test must be observed - *changing its number* when the measured operation is removed from the measured - body. A benchmark whose measured region does not contain the workload is a - tautology in `Measure` clothing. - -## 3. Placement Rules (EditMode vs PlayMode) -- **EditMode / Unit** (`EditMode/Unit/`): Pure-logic services with no `MonoBehaviour` or `GameObject` dependency. Use `[Test]`. NSubstitute is available (referenced only in the EditMode asmdef). -- **EditMode / Performance** (`EditMode/Performance/`): Perf benchmarks that do not need a running player. Require `PerformanceTestSetup` (see below). -- **PlayMode / Unit** (`PlayMode/Unit/`): Services that create `DontDestroyOnLoad` GameObjects (`TickService`, `CoroutineService`, `GameObjectPool`, `GameObjectPool`). Use `[UnityTest]` returning `IEnumerator`. -- **PlayMode / Integration** (`PlayMode/Integration/`): Cross-service or async workflows that span multiple bound services or exercise a real load path (e.g., full service bootstrap/teardown sequences, async resource loading). -- **PlayMode / Performance** (`PlayMode/Performance/`): Perf benchmarks that need a running player. -- **PlayMode / Smoke** (`PlayMode/Smoke/`): Lightweight "construct without throwing" tests that confirm services instantiate and basic bind/resolve works. - -**Decision tree**: if the service under test creates a `GameObject` or relies on Unity callbacks → **PlayMode**; otherwise → **EditMode**. - -## 4. Namespace and Suppression -All test files use `namespace GameLoversEditor.Services.Tests` with the suppression comment: -```csharp -// ReSharper disable once CheckNamespace -``` - -## 5. Naming -- **Test class**: `{ServiceName}Test` (e.g., `ObjectPoolTest`, `TickServiceTest`). Performance tests use `{ServiceName}PerformanceTest`. Integration tests use `{ServiceName}IntegrationTest`. -- **Test method**: `MethodOrBehavior_Condition_ExpectedResult` — e.g., `Spawn_Successfully`, `Range_MinEqualsMax_ReturnsMin`, `Despawn_NotSpawnedObject_ReturnsFalse`. -- **SetUp method**: Named `Init()`. -- **TearDown method**: Named `Dispose()` (when calling `service.Dispose()`) or `Cleanup()` (when doing `Object.Destroy` / `MainInstaller.Clean()`). - -## 6. Mock / Helper Types -- Define mock interfaces and classes as **nested types** inside the test class (e.g., `IMockEntity`, `MockEntity`, `MockBehaviour`, `IMockSubscriber`). -- EditMode tests use **NSubstitute** (`Substitute.For()`) for interface mocking. PlayMode tests use concrete `MonoBehaviour` stubs with manual counters (NSubstitute is not referenced in the PlayMode asmdef). - -### NSubstitute limitation on Unity's Mono runtime -NSubstitute 4.4.0 (bundled Castle.Core DynamicProxy) cannot generate a proxy for a generic interface whose type argument is a **self-referentially-constrained interface**. Example: `Substitute.For>()` where `IMockEntity : IPoolEntityObject` fails with `ArgumentNullException: localType` deep in `Castle.DynamicProxy.Generators.Emitters.SimpleAST.LocalReference.Generate` → `ILGenerator.DeclareLocal(null)`. Root cause is Castle's IL emitter resolving a generic parameter to `null` during type-building on Mono. - -When a test would otherwise substitute such an interface, do ONE of: -- Use the real concrete implementation and verify via observable state (e.g., `new ObjectPool(...)` + assertions on `SpawnedReadOnly.Count`). This is preferred — see `EntityDespawn_Successfully` in `ObjectPoolTest`. -- Hand-write a minimal fake class implementing the interface. -Do not "work around" the proxy failure by restructuring the type hierarchy — `IMockEntity : IPoolEntityObject` is a legitimate modelling choice that the runtime code relies on. - -## 7. Black-Box / Reflection Policy - -### Authorized reflection sites (storage-assertion exception) -Reflection on private state is also authorized when a setter has no observable readback path through the public API and exercising the side-effect would require a runtime environment the EditMode harness cannot provide (e.g., a partially-loaded `AssetReference`). In those cases, asserting the storage field directly via `BindingFlags.NonPublic | BindingFlags.Instance` is acceptable and preferable to a Red-testability skip. The test method MUST be a single setter-storage assertion (not a multi-step behavioural assertion); if behaviour is what you need to verify, refactor to expose an `internal` accessor under `InternalsVisibleTo` instead. - -Currently authorized: -- `AssetResolverServiceTest.AddDebugConfigs_StoresAllProvided` — reads the private `AssetResolverService._errorMaterial` field to confirm `AddDebugConfigs` stored its argument. The fallback-material lookup path (`AssetResolverService.SelectAsset` in `Runtime/AssetResolverService.cs`, called by the private `Convert` wrapper) only fires when `!assetReference.IsDone`, which the EditMode harness cannot fabricate without a real Addressables catalog. Documented here per the Type B audit run on 2026-05-04 (Referee §4 missed-anti-pattern finding, parent picked option A). - -## 8. Fields and Setup -- Fields are prefixed with `_` and use **concrete service types** (not interfaces): `private TickService _tickService;`, `private ObjectPool _pool;`. -- Constants use `PascalCase`: `private const int Seed = 12345;`. -- `[SetUp]` creates fresh service instances. Services that create GameObjects (`TickService`, `CoroutineService`) **must** call `Dispose()` in `[TearDown]`; `GameObjectPool` tests also `Object.Destroy` the sample GameObject. -- Use `[Order(n)]` when tests must run in sequence (e.g., `VersionServicesIntegrationTest` resets static state, then loads, then reads). -- Reset shared static state in `[SetUp]` (reflection into private fields is acceptable for static classes like `VersionServices`). - -## 9. Assertion Style -- NUnit classic model is the **default**: `Assert.AreEqual`, `Assert.AreSame`, `Assert.IsTrue`, `Assert.Throws`, `Assert.DoesNotThrow`, etc. -- `Assert.That(...)` (constraint model) is permitted **ONLY** for tolerance/range constraints that classic asserts cannot express (`Is.EqualTo(x).Within(t)`, `Is.LessThan(x)`, and similar). The only authorized sites are `TimeServiceTest.cs:67,83`. Any other use is a review reject. - -## 10. PlayMode Test Cleanup - -This package's `PlayMode/Unit/` and `PlayMode/Integration/` fixtures create `DontDestroyOnLoad` GameObjects (`TickService`, `CoroutineService`, `GameObjectPool`, `GameObjectPool`) and bind services through `MainInstaller`. These survive scene teardown between tests unless explicitly torn down, and will leak into the next test's domain if left alive. - -- `[TearDown]` **must** call `Dispose()` on any `TickService` / `CoroutineService` instance created in `[SetUp]` — this destroys the host `DontDestroyOnLoad` GameObject. -- `GameObjectPool` / `GameObjectPool` tests must additionally `Object.Destroy` the sample GameObject (or call `Dispose(disposeSampleEntity: true)`) so no pooled instances survive into the next test. -- Fixtures that bind through `MainInstaller.Bind(...)` (e.g. `ServiceLifecycleTest`, `VersionServicesIntegrationTest`) **must** call `MainInstaller.Clean()` in `[TearDown]` to clear bindings; a missing `Clean()` call causes the next fixture's `Bind` call to throw (`Installer` re-bind throws via `Dictionary.Add`). -- Do not rely on domain reload or Unity's own scene-unload to clean these up between tests — the Unity Test Runner does not guarantee a domain reload between every test in a fixture. - -## 11. Performance Tests -- Annotate with `[Test, Performance]` and `[Category("Performance")]`. -- Apply `[PrebuildSetup(typeof(PerformanceTestSetup))]` at the class level and call `PerformanceTestSetup.InitializePerformanceTestMetadata()` in `[OneTimeSetUp]`. -- Use `Measure.Method(() => { ... }).WarmupCount(n).MeasurementCount(n).Run()`. - -### `PerformanceTestSetup` PlayerPref contract (do NOT regress) -`InitializePerformanceTestMetadata()` MUST prime **two** PlayerPref keys before any `Measure.Method(...).Run()` call — dropping either one ships a latent NRE that masks the actual perf-test logic: -- `PT_Run` — full Run metadata (editor info, dependencies, build settings); consumed by `Metadata.SetRuntimeSettings()` when results are emitted. -- `PT_Settings` — RunSettings JSON (use `"{\"MeasurementCount\":-1}"`); consumed by `MethodMeasurement.SettingsOverride()` *before* the first warmup. - -Why both keys: `RunSettings.Instance` is a lazy-loaded singleton (`ResourcesLoader.Load("PerformanceTestRunSettings", "PT_Settings")`). In Editor it falls back to `PlayerPrefs.GetString("PT_Settings")`; if the value is empty, `JsonUtility.FromJson` throws, the loader silently swallows the exception and returns `null`, and `SettingsOverride()` then NREs at `RunSettings.Instance.MeasurementCount`. The failure surfaces at `MethodMeasurement.cs:288` with no hint that the setup is incomplete. - -`MeasurementCount = -1` is the package's "no override" sentinel — `SettingsOverride()` early-returns when `count < 0`, so each fixture's per-test `WarmupCount(...).MeasurementCount(...)` is preserved. - -`PerformanceTestSetupTest.MeasureMethod_AfterInitialize_DoesNotThrow` is the regression sentinel for this contract: a no-op `Measure.Method(() => {}).WarmupCount(1).MeasurementCount(1).Run()` wrapped in `Assert.DoesNotThrow`. If a future change to `PerformanceTestSetup` drops either PlayerPref, this test fails first with a class name that points directly at the harness — keep it green. - -## 12. Test Directory Layout - -| Directory | Contents | -|-----------|----------| -| `EditMode/Unit/` | NUnit + NSubstitute; tests all non-MonoBehaviour services, incl. `AddressableConfigTest`, `AssetLoaderUtilsTest`, `AssetResolverServiceTest` | -| `EditMode/Performance/` | `Unity.PerformanceTesting`; ObjectPool, MessageBroker perf | -| `PlayMode/Unit/` | TickService, CoroutineService, GameObjectPool, GameObjectPool\ (require a runtime) | -| `PlayMode/Integration/` | `ServiceLifecycleTest` full bootstrap/teardown, `VersionServicesIntegrationTest` async resource loading | -| `PlayMode/Performance/` | TickService, GameObjectPool perf | -| `PlayMode/Smoke/` | `ServicesBootstrapSmokeTest` | - -## 13. Coverage Register +| **A3 reject** | No package production behavior participates. | Delete the test. | +| **A5 duplicate** | The same narrow mutation already belongs to a sibling. | Delete it and name the surviving sibling in review/commit context. | +| **D2 overclaim** | The mutation implied by the name leaves the body green. | Strengthen or rename. | +| **UNFALSIFIABLE** | Real package behavior is double-guarded or cannot be broken by a safe isolated edit. | Keep only after attempted mutations are recorded with the specific reason. | +| **SHARED-PATH** | A broader mutation reddens this legitimate integration path together with siblings. | Keep, recording the observed mutation and blast radius. | -**Baseline — runtime assembly: 84.4% (1050/1244), measured 2026-08-04.** -Editor assembly: **4.8% (153/3162)** — near-zero by policy; the ACCEPTED (iii) rows below are why. -Repo-wide runtime coverage is **74.1% (6609/8922)** across all 11 assemblies. +Unannotated tests have three possible histories: observed RED with lost write-back, collateral RED under another test's mutation, or never probed. Check `.test-all/rcr/` before probing and never write prepared annotation text without matching observed evidence. Mutation records stay under `.test-all/rcr/`, not `/tmp`. -Regenerate with `Tools/coverage.sh`, which prints the runtime/Editor split. Steer by -the **runtime** figure: Editor code is ~48% of coverable lines and accepted-untestable, -so the combined number (41.1%) can never meaningfully move. Sanity-check any rerun by -confirming `MathfloatP` reports ~1002 coverable lines — a smaller figure means -`-debugCodeOptimization` was missing and the denominator silently shrank ~40%. +Benchmarks use the inverted check: removing the workload from the measured body must materially change the result. Run the actual test assembly; a plain Unity open does not compile assemblies constrained by `UNITY_INCLUDE_TESTS`. + +## Placement -Every untested symbol worth naming is ACCEPTED (justified — do not re-report), -OPEN (a real gap, owed a test), or CLOSED (the gap was filled). An untested symbol -in none of the three is an audit finding. +- `EditMode/Unit/`: non-MonoBehaviour services and pure logic. `EditMode/Performance/`: benchmarks not requiring frames. +- `PlayMode/Unit/`: services and pools that create GameObjects or depend on Unity callbacks. +- `PlayMode/Integration/`: cross-service, bootstrap, Addressables, resource, and async workflows. +- `PlayMode/Performance/` and `PlayMode/Smoke/`: runtime benchmarks and bootstrap checks only. -**A CLOSED row must name the commit AND the observation that closed it, including the -environment the observation came from.** A row closed on "the fix landed" is still OPEN: -the fix is the edit, the closure is the evidence. This is what kept the uiservice A6 row -open until the Editor half ran — the edit was in and batchmode was green, and neither of -those was the thing in doubt. +## Package conventions -**Closing a row means re-deriving its claim against current source, never reading the -commit that claimed to fix it.** Re-check every symbol and fixture the row names. A partial -fix and a complete one produce the same green suite and the same confident commit message, -so the commit cannot be the evidence for its own completeness. Recorded instance: the -mobileservices editor-static row nearly closed on a commit that genuinely did stop fixtures -inheriting statics — for two of the three fixtures the row named. The third was found by -grepping which fixtures touch each static, and it was passing only because its siblings -happened to restore the static in their `finally` blocks. +- Tests use namespace `GameLoversEditor.Services.Tests` with the existing namespace-suppression comment. +- EditMode may use NSubstitute. PlayMode uses concrete MonoBehaviour fakes because its asmdef does not reference NSubstitute. +- NSubstitute on Unity Mono cannot proxy self-referentially constrained generic interfaces such as `IObjectPool`. Use the real implementation or a hand-written fake. +- Prefer observable behavior or internal seams. The only documented private-reflection exception is the single-field storage assertion in `AssetResolverServiceTest.AddDebugConfigs_StoresAllProvided`; new exceptions require a named harness blocker and a narrow setter-storage assertion. +- Use NUnit classic assertions by default; constraints are appropriate for tolerance/range semantics. -An ACCEPTED row needs one of exactly three falsifiable reasons: -- **(i) no branching** — zero conditionals, so there is no behaviour to pin. -- **(ii) engine-owned** — the assertion would target Unity/OS behaviour - (`[DllImport]`, `AndroidJavaObject`, Addressables statics). -- **(iii) harness-impossible** — the state cannot be fabricated in EditMode or - PlayMode, **with the specific blocker named**. +## Cleanup -"Low value", "hard to test", and "covered by manual QA" are NOT valid reasons. If -none of the three applies, the row is OPEN. +- Dispose every `TickService` and `CoroutineService`, destroy pool sample GameObjects or dispose them with sample ownership, and call `MainInstaller.Clean()` after bindings. +- Reset static `VersionServices` state in fixtures that change it. Never rely on domain reload between tests. -ACCEPTED is dated and **expires on edit**: if the symbol's file changes, the -reason is re-checked in that PR. A `(i) no branching` row is void the moment -someone adds an `if`. +## Performance harness -OPEN is the only place a deletion may park coverage. A test removed for weakness -either had a stronger sibling (named in the commit body) or leaves an OPEN row. -The count of OPEN rows is the honest coverage-debt number. +- Performance fixtures use `PerformanceTestSetup` and `Measure.Method` with per-iteration state reset. +- `InitializePerformanceTestMetadata()` must prime both `PT_Run` and `PT_Settings`. `PT_Settings` uses `{"MeasurementCount":-1}` so fixture measurement counts remain authoritative. +- Keep the harness sentinel that runs a minimal `Measure.Method`; it detects missing metadata before service benchmarks fail obscurely. -| Symbol (file:line) | State | Reason / Owed | Recorded | -|---|---|---|---| -| `AddressablesAssetLoader` (`Runtime/AssetsImporter/AddressablesAssetLoader.cs`) | ACCEPTED | (i) no branching — thin wrapper over `UnityEngine.AddressableAssets.Addressables` static APIs with no branching logic: every method is `LoadAssetAsync → ToUniTask → throw-on-failure → return`. Live integration would require a pre-built Addressables catalog plus a manually registered asset in the host project, and would validate Unity code rather than package code. The consumer layer (`AssetResolverService`) has full unit coverage via `AssetResolverServiceTest`, and the wrapper's behaviour is documented in `docs/asset-loading.md`. | 2026-07-31 | -| 25 public `Editor/` types + `ServicesScaffolders` (`Editor/**`) | ACCEPTED | (iii) harness-impossible — blocker: require `AssetDatabase` access, validated manually. Already stated in the package root [`AGENTS.md`](../AGENTS.md) §4 ("AssetsConfigsImporter (Editor)"); cross-referenced here rather than restated. | 2026-07-31 | -| `ServicesScaffolders` `#if UNITY_6000_4_OR_NEWER` guard (`Editor/Scaffolders/ServicesScaffolders.cs`) | ACCEPTED | (iii) harness-impossible — blocker: compile-time branch, only one side is reachable per Unity version, so a single test run can only ever exercise one branch of the `#if`. | 2026-07-31 | -| `VersionServices.IsOutdatedVersion` (`Runtime/VersionServices.cs`) | OPEN | Owed: only coverage was a local reimplementation of the algorithm in `VersionServicesTest.cs`; a real test against the actual method is owed. | 2026-07-31 | -| `AddressableIdsGeneratorUtils.ResolveSanitizedEnumName` 3-way collision (`Editor/AddressableIds/AddressableIdsGeneratorUtils.cs:531-539`) | OPEN | The two-address collision was fixed 2026-08-01 (`AppendAddressEnumMembers` now emits the disambiguated `name`). A deeper edge case remains: a THIRD address colliding with the same base name AND filetype re-derives the identical `"{name}_{filetype}"` suffix (the fallback only ever adds one suffix level and the collision check is against the original `name`, not against previously-suffixed candidates), so 3+ colliding addresses can still emit duplicates. Owed: either a numeric fallback (`_2`, `_3`, ...) or checking collision against the full history of emitted names, not just base names. | 2026-08-01 | -| `ObjectPoolBase.Despawn` `onlyFirst` break (`Runtime/Pooling/ObjectPool.cs`) | OPEN | Owed: the branch is uncovered **repo-wide**, proven not inferred — inverting `if (onlyFirst)` left `Despawn_WithCondition_FirstOnly_Successfully` GREEN (the EditMode fixture spawns one entity; the PlayMode typed sibling's predicate matches only one). A test must spawn two matching entities and assert exactly one is despawned. | 2026-08-04 | -| `TimeService.UnityTimeFromDateTimeUtc` / `UnixTimeFromDateTimeUtc` shrink direction (`Runtime/TimeService.cs`) | CLOSED | Closed 2026-08-04 by `07abdf1`: the three assertion pairs now bound `Math.Abs(converted - now)` from both sides. The 1000x `TotalMilliseconds`→`TotalSeconds` shrink and the `_initialUnityTime` negation are now both observed RED (unix comparisons use a separate `UnixErrorMillis`, since they are in milliseconds). | 2026-08-04 | -| `RngService.Peekfloat` state-advance detection (`Runtime/RngService.cs`) | OPEN | Owed: `Peekfloat` draws over `(0, floatP.MaxValue)`, where consecutive draws saturate to the same `floatP` — so making `PeekRange` consume the LIVE state was observed GREEN. The test is precision-blind to the advance its name claims to catch. | 2026-08-04 | -| 64 production edits reddening only collaterally (`Runtime/RngService.cs`, `Runtime/CoroutineService.cs`, `Runtime/TickService.cs`) | OPEN | Measured 2026-08-04 from `.test-all/rcr/unowned-edits.json`: 64 edits produced RED but never an `isolated` verdict — `RngService.cs` (11), `CoroutineService.cs` (9), `TickService.cs` (7), `ObjectPool.cs` (6). **Not 64 missing tests**; see the gamedata row for the reasoning. Owed: a judgement pass on whether the deterministic-RNG and tick-fan-out paths warrant narrow pins, since both are shared setup for many fixtures. | 2026-08-04 | -| `TickServiceTest.MultipleInstances_CreateMultipleGameObjects` exemption is an unfinished RCR (`Runtime/TickService.cs`) | CLOSED | Closed 2026-08-04. The one-line mutation exists and was run: `private readonly TickServiceMonoBehaviour _tickObject;` -> `private static ...` makes production's own ctor guard fire, and the test went RED with `InvalidOperationException: The tick service is being initialized for the second time`. Observed in the **Editor**; `TickService.cs` restored byte-identical. Also removed 15 lines of narration and commented-out production code from the test body. See the new dead-guard row below. | 2026-08-04 | -| `TickService` ctor singleton guard is unreachable dead code (`Runtime/TickService.cs`) | CLOSED | Closed 2026-08-04: the dead guard is deleted (`CHANGELOG.md` **Fixed**), which also makes `TickService` consistent with `CoroutineService` — same host-spawning shape, never carried such a guard. `MultipleInstances_CreateMultipleGameObjects` was re-pointed, because removing the guard voided its previous mutation: with the guard gone, `_tickObject` `readonly`->`static` was observed leaving the test **GREEN**. The replacement pins the ctor's own `AddComponent` — routing it through `Object.FindAnyObjectByType() ?? ...` (a simulated singleton) was observed RED with "Expected: greater than or equal to 2". Green -> RED -> green, `TickService.cs` restored to the guard-deleted state (md5 verified). | 2026-08-04 | +## Verification -## 14. Update Policy -Update this file when: -- Test conventions change (new asmdef references, assertion style, naming patterns, new test categories) -- New test directories or categories are added -- Mock/stub patterns change (e.g., NSubstitute added to PlayMode asmdef) +- Run EditMode for pure services and PlayMode for hosts, pools, lifecycle, resource loading, and integration behavior. +- Addressables and Editor importer changes require explicit catalog/`AssetDatabase` verification beyond the automated suite. +- Update this guide only when a stable assembly, placement, cleanup, helper, or performance-harness convention changes. From be5c1839f04cf898514a095ddd6793586cf4fe6e Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Thu, 27 Aug 2026 02:20:21 +0300 Subject: [PATCH 07/13] fix: fix the agentic rules --- CLAUDE.md | 11 ----------- Tests/CLAUDE.md | 11 ----------- 2 files changed, 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9a16afe..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1 @@ -# Claude Code Guide — GameLovers Services - -This package's contributor/agent guide lives in `AGENTS.md`. -Claude Code will automatically import it below. - @AGENTS.md - -## Claude-Specific Notes - -- Treat `AGENTS.md` as the source of truth. -- If anything in this file appears to conflict with `AGENTS.md`, prefer `AGENTS.md`. -- For user-facing usage, see `README.md`. diff --git a/Tests/CLAUDE.md b/Tests/CLAUDE.md index b80da31..43c994c 100644 --- a/Tests/CLAUDE.md +++ b/Tests/CLAUDE.md @@ -1,12 +1 @@ -# Claude Code Guide — GameLovers Services Tests - -This folder's testing conventions live in `AGENTS.md`. -Claude Code will automatically import it below. - @AGENTS.md - -## Claude-Specific Notes - -- Treat `AGENTS.md` as the source of truth. -- If anything in this file appears to conflict with `AGENTS.md`, prefer `AGENTS.md`. -- For package-level architecture and runtime gotchas, see `../AGENTS.md`. From b0e357e65a73733e656bf86e3fb710b6790c4569 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Mon, 14 Sep 2026 13:00:44 +0300 Subject: [PATCH 08/13] fix(samples): assign default actions to the AssetResolver input module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sample added an InputSystemUIInputModule without AssignDefaultActions(), so its buttons looked interactive, received nothing, and emitted no error — a failure that survives a green compile, a green test run, and a screenshot. Its sibling sample already chains the call. The shared block now says the harness already prints the fixture count, that the post-fix observation is one re-run of the same probe, and that correcting an existing ADMIT or RCR line is itself a claim about production which must be read and re-checked. Edited identically in all six packages; Tools/lint-agent-guides.py fails if they diverge. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +++ Samples~/AssetResolver/AssetResolverExample.cs | 2 +- Tests/AGENTS.md | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2096801..a904dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. **Changed**: - Lowered the declared Unity minimum from 6000.3 to 6000.0 and documented 6000.0.x, 6000.3.x, and 6000.5.x as compatibility reference streams. + +**Fixed**: +- The AssetResolver sample now assigns default actions to the Input System UI module it adds at runtime, so its buttons respond to input instead of looking interactive and receiving nothing. ## [2.1.2] - 2026-08-04 diff --git a/Samples~/AssetResolver/AssetResolverExample.cs b/Samples~/AssetResolver/AssetResolverExample.cs index d494162..62cb492 100644 --- a/Samples~/AssetResolver/AssetResolverExample.cs +++ b/Samples~/AssetResolver/AssetResolverExample.cs @@ -82,7 +82,7 @@ private static void EnsureInputModuleOnEventSystem() { DestroyImmediate(legacy); } - go.AddComponent(); + go.AddComponent().AssignDefaultActions(); #else if (go.GetComponent() == null) { diff --git a/Tests/AGENTS.md b/Tests/AGENTS.md index f88a696..d54cd72 100644 --- a/Tests/AGENTS.md +++ b/Tests/AGENTS.md @@ -29,11 +29,12 @@ Fixtures under `Smoke/` are exempt from A1/A2 and may assert construction/bootst Every new or strengthened behavioral test must be observed failing once against a plausible production mutation before commit: -1. Run the new test against normal production code and observe GREEN. +1. Run the new test against normal production code and observe GREEN. The harness prints `baseline green: N test cases`; that line is the fixture count, not a reason to run it again. 2. Preserve the exact working patch or use an isolated worktree; then apply the A2 mutation. Never restore a dirty file from `HEAD`. 3. Run the smallest attributable filter. RED must come from the intended assertion with a diagnostic failure, not a compile error or unrelated `NullReferenceException`. -4. Restore the saved production state, confirm the mutation is gone without losing other edits, and observe GREEN again. +4. Restore the saved production state, confirm the mutation is gone without losing other edits, and observe GREEN again as one re-run of the same filtered probe. 5. Record the observation on the test using `file + symbol`, never a line number. +6. Correcting an existing `ADMIT`/`RCR` line is itself a claim about production: read the symbol it names, re-run the mutation when the claim is about RED, and run `python3 Tools/rcr-anchor-check.py` before committing. Use this compact form, targeting four lines and never exceeding six: From 1d3d3a27ab7e6c7e972ac5683adf79d025852dd2 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Mon, 14 Sep 2026 14:08:01 +0300 Subject: [PATCH 09/13] style: prefix private fields with underscore and name constants PascalCase AGENTS.md gives private fields _camelCase, says explicitly that static fields are included, and gives private constants plain PascalCase, but nothing enforced it: this repository has no .editorconfig and the style audit gated documentation and ordering only. A hand-rolled checker in an earlier session mis-classified three of its four hits, which is what a rule with no mechanical owner costs. Renames are identifier-only; no comment prose and no string literal changed. Co-Authored-By: Claude Opus 5 --- Editor/Explorer/Tabs/AddressableIdsTab.cs | 36 +++++++++---------- Editor/Versioning/GitEditorProcess.cs | 18 +++++----- Runtime/RngService.cs | 42 +++++++++++------------ Runtime/TimeService.cs | 8 ++--- 4 files changed, 52 insertions(+), 52 deletions(-) diff --git a/Editor/Explorer/Tabs/AddressableIdsTab.cs b/Editor/Explorer/Tabs/AddressableIdsTab.cs index 3c14fe1..a529bfa 100644 --- a/Editor/Explorer/Tabs/AddressableIdsTab.cs +++ b/Editor/Explorer/Tabs/AddressableIdsTab.cs @@ -18,10 +18,10 @@ public class AddressableIdsTab : ServiceTab { private const string PendingPlaceholderText = "Click to compute"; private const string PendingFoldoutText = "Details (click 'Check for stale Ids' first)"; - private static readonly Color WarnColor = new Color(0.95f, 0.75f, 0.30f); - private static readonly Color ErrorColor = new Color(0.9f, 0.5f, 0.4f); - private static readonly Color OkColor = new Color(0.6f, 0.9f, 0.6f); - private static readonly Color MutedColor = new Color(0.7f, 0.7f, 0.7f); + private static readonly Color _warnColor = new Color(0.95f, 0.75f, 0.30f); + private static readonly Color _errorColor = new Color(0.9f, 0.5f, 0.4f); + private static readonly Color _okColor = new Color(0.6f, 0.9f, 0.6f); + private static readonly Color _mutedColor = new Color(0.7f, 0.7f, 0.7f); /// public override string DisplayName => "Addressable Ids"; @@ -131,7 +131,7 @@ protected override void BuildUi() _lastGenerationLabel = new Label(); _lastGenerationLabel.style.fontSize = 10; - _lastGenerationLabel.style.color = new StyleColor(MutedColor); + _lastGenerationLabel.style.color = new StyleColor(_mutedColor); _lastGenerationLabel.style.marginTop = 2; scroll.Add(_lastGenerationLabel); @@ -152,7 +152,7 @@ protected override void BuildUi() _pendingSummaryLabel = new Label(PendingPlaceholderText); _pendingSummaryLabel.AddToClassList("row-label"); - _pendingSummaryLabel.style.color = new StyleColor(MutedColor); + _pendingSummaryLabel.style.color = new StyleColor(_mutedColor); pendingRow.Add(_pendingSummaryLabel); scroll.Add(pendingRow); @@ -197,20 +197,20 @@ private void RefreshOutput() if (!freshness.ScriptExists) { _freshnessBanner.text = "Not generated yet — click 'Generate Addressable Ids'."; - _freshnessBanner.style.color = new StyleColor(ErrorColor); + _freshnessBanner.style.color = new StyleColor(_errorColor); _freshnessBanner.style.display = DisplayStyle.Flex; } else if (freshness.IsStale) { _freshnessBanner.text = $"Out of date — Addressables modified after the file was generated " + $"({freshness.LatestSourceWriteTime:HH:mm:ss} > {freshness.ScriptWriteTime:HH:mm:ss})."; - _freshnessBanner.style.color = new StyleColor(WarnColor); + _freshnessBanner.style.color = new StyleColor(_warnColor); _freshnessBanner.style.display = DisplayStyle.Flex; } else { _freshnessBanner.text = $"Up to date — script newer than Addressables ({freshness.ScriptWriteTime:HH:mm:ss})."; - _freshnessBanner.style.color = new StyleColor(OkColor); + _freshnessBanner.style.color = new StyleColor(_okColor); _freshnessBanner.style.display = DisplayStyle.Flex; } @@ -235,7 +235,7 @@ private void RefreshOutput() private void ResetPendingChanges() { _pendingSummaryLabel.text = PendingPlaceholderText; - _pendingSummaryLabel.style.color = new StyleColor(MutedColor); + _pendingSummaryLabel.style.color = new StyleColor(_mutedColor); _pendingFoldout.text = PendingFoldoutText; _pendingFoldout.value = false; _addedList.Clear(); @@ -269,24 +269,24 @@ private void PopulateDiffFoldout(DiffResult diff) _removedList.Clear(); _warningsList.Clear(); - AppendDiffSubsection(_addedList, $"Added addresses ({diff.AddedAddresses.Count})", diff.AddedAddresses, OkColor); - AppendDiffSubsection(_addedList, $"Added labels ({diff.AddedLabels.Count})", diff.AddedLabels, OkColor); + AppendDiffSubsection(_addedList, $"Added addresses ({diff.AddedAddresses.Count})", diff.AddedAddresses, _okColor); + AppendDiffSubsection(_addedList, $"Added labels ({diff.AddedLabels.Count})", diff.AddedLabels, _okColor); - AppendDiffSubsection(_removedList, $"Removed addresses ({diff.RemovedAddresses.Count})", diff.RemovedAddresses, ErrorColor); - AppendDiffSubsection(_removedList, $"Removed labels ({diff.RemovedLabels.Count})", diff.RemovedLabels, ErrorColor); + AppendDiffSubsection(_removedList, $"Removed addresses ({diff.RemovedAddresses.Count})", diff.RemovedAddresses, _errorColor); + AppendDiffSubsection(_removedList, $"Removed labels ({diff.RemovedLabels.Count})", diff.RemovedLabels, _errorColor); AppendDiffSubsection(_warningsList, $"Sanitized-name collisions ({diff.SanitizedNameCollisions.Count})", - diff.SanitizedNameCollisions, WarnColor); + diff.SanitizedNameCollisions, _warnColor); AppendDiffSubsection(_warningsList, $"Entries with null AssetType ({diff.NullAssetTypeAddresses.Count})", - diff.NullAssetTypeAddresses, WarnColor); + diff.NullAssetTypeAddresses, _warnColor); if (!diff.HasSnapshot) { var lbl = new Label("No snapshot recorded yet — generate once to enable add/remove diffs."); lbl.style.fontSize = 10; - lbl.style.color = new StyleColor(MutedColor); + lbl.style.color = new StyleColor(_mutedColor); lbl.style.marginTop = 4; _addedList.Add(lbl); } @@ -338,7 +338,7 @@ private static string FormatSummary(DiffResult diff) private static Color SummaryColor(DiffResult diff) { - return TotalDiffCount(diff) == 0 ? OkColor : WarnColor; + return TotalDiffCount(diff) == 0 ? _okColor : _warnColor; } private static int TotalDiffCount(DiffResult diff) diff --git a/Editor/Versioning/GitEditorProcess.cs b/Editor/Versioning/GitEditorProcess.cs index 48490de..2c256c9 100644 --- a/Editor/Versioning/GitEditorProcess.cs +++ b/Editor/Versioning/GitEditorProcess.cs @@ -13,12 +13,12 @@ public class GitEditorProcess : IDisposable { private const string DefaultPathToGitBinary = "git"; - private readonly Process Process; + private readonly Process _process; /// /// /// - public int ExitCode => Process.ExitCode; + public int ExitCode => _process.ExitCode; public GitEditorProcess(string workingDir, string pathToGitBinary = DefaultPathToGitBinary) { @@ -31,7 +31,7 @@ public GitEditorProcess(string workingDir, string pathToGitBinary = DefaultPathT WorkingDirectory = workingDir }; - Process = new Process { StartInfo = startInfo }; + _process = new Process { StartInfo = startInfo }; } /// @@ -64,21 +64,21 @@ public string GetCommitHash() /// public string GetDiffFromCommit(string commitHash) { - return ExecuteCommand($"diff --word-diff=porcelain {commitHash} -- {Process.StartInfo.WorkingDirectory}"); + return ExecuteCommand($"diff --word-diff=porcelain {commitHash} -- {_process.StartInfo.WorkingDirectory}"); } /// public void Dispose() { - Process?.Dispose(); + _process?.Dispose(); } private string ExecuteCommand(string args) { - Process.StartInfo.Arguments = args; - Process.Start(); - var output = Process.StandardOutput.ReadToEnd().Trim(); - Process.WaitForExit(); + _process.StartInfo.Arguments = args; + _process.Start(); + var output = _process.StandardOutput.ReadToEnd().Trim(); + _process.WaitForExit(); return output; } } diff --git a/Runtime/RngService.cs b/Runtime/RngService.cs index 9e6af50..a723111 100644 --- a/Runtime/RngService.cs +++ b/Runtime/RngService.cs @@ -119,10 +119,10 @@ public interface IRngService /// public class RngService : IRngService { - private const int _basicSeed = 161803398; - private const int _stateLength = 56; - private const int _helperInc = 21; - private const int _valueIndex = 0; + private const int BasicSeed = 161803398; + private const int StateLength = 56; + private const int HelperInc = 21; + private const int ValueIndex = 0; private RngData _rngData; @@ -248,15 +248,15 @@ public static floatP Range(floatP min, floatP max, int[] rndState, bool maxInclu /// public static int[] CopyRngState(int[] state) { - if (state == null || state.Length != _stateLength) + if (state == null || state.Length != StateLength) { throw new IndexOutOfRangeException($"The Random data created has the wrong state date." + - $"It should have a lenght of {_stateLength.ToString()} but has {state?.Length}"); + $"It should have a lenght of {StateLength.ToString()} but has {state?.Length}"); } - var newState = new int[_stateLength]; + var newState = new int[StateLength]; - Array.Copy(state, newState, _stateLength); + Array.Copy(state, newState, StateLength); return newState; } @@ -282,16 +282,16 @@ public static RngData CreateRngData(int seed) /// public static int[] GenerateRngState(int seed) { - var value = _basicSeed - (seed == int.MinValue ? int.MaxValue : System.Math.Abs(seed)); - var state = new int[_stateLength]; + var value = BasicSeed - (seed == int.MinValue ? int.MaxValue : System.Math.Abs(seed)); + var state = new int[StateLength]; - state[_stateLength - 1] = value; - state[_valueIndex] = 0; + state[StateLength - 1] = value; + state[ValueIndex] = 0; //Apparently the range [1..55] is special (Knuth) - for (int i = 1, j = 1; i < _stateLength - 1; i++) + for (int i = 1, j = 1; i < StateLength - 1; i++) { - var index = (_helperInc * i) % (_stateLength - 1); + var index = (HelperInc * i) % (StateLength - 1); state[index] = j; @@ -307,9 +307,9 @@ public static int[] GenerateRngState(int seed) for (var k = 1; k < 5; k++) { - for (var i = 1; i < _stateLength; i++) + for (var i = 1; i < StateLength; i++) { - state[i] -= state[1 + (i + 30) % (_stateLength - 1)]; + state[i] -= state[1 + (i + 30) % (StateLength - 1)]; if (state[i] < 0) { @@ -323,18 +323,18 @@ public static int[] GenerateRngState(int seed) private static int NextNumber(int[] rndState) { - var index1 = rndState[_valueIndex] + 1; - var index2 = index1 + _helperInc + 1; + var index1 = rndState[ValueIndex] + 1; + var index2 = index1 + HelperInc + 1; - index1 = index1 < _stateLength ? index1 : 1; - index2 = index2 < _stateLength ? index2 : 1; + index1 = index1 < StateLength ? index1 : 1; + index2 = index2 < StateLength ? index2 : 1; var ret = rndState[index1] - rndState[index2]; ret = ret < 0 ? ret + int.MaxValue : ret; rndState[index1] = ret; - rndState[_valueIndex] = index1; + rndState[ValueIndex] = index1; return ret; } diff --git a/Runtime/TimeService.cs b/Runtime/TimeService.cs index 146f03e..ae2b072 100644 --- a/Runtime/TimeService.cs +++ b/Runtime/TimeService.cs @@ -73,7 +73,7 @@ public interface ITimeManipulator : ITimeService /// public class TimeService : ITimeManipulator { - private static readonly DateTime UnixInitialTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _unixInitialTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); private float _initialUnityTime; private float _extraTime; @@ -95,7 +95,7 @@ public class TimeService : ITimeManipulator /// public float UnityScaleTimeNow => Time.time + _extraTime; /// - public long UnixTimeNow => (long)( DateTimeUtcNow - UnixInitialTime ).TotalMilliseconds; + public long UnixTimeNow => (long)( DateTimeUtcNow - _unixInitialTime ).TotalMilliseconds; public TimeService() { @@ -110,7 +110,7 @@ public TimeService() /// public long UnixTimeFromDateTimeUtc(DateTime time) { - return (long)( time.ToUniversalTime() - UnixInitialTime ).TotalMilliseconds; + return (long)( time.ToUniversalTime() - _unixInitialTime ).TotalMilliseconds; } /// @@ -122,7 +122,7 @@ public long UnixTimeFromUnityTime(float time) /// public DateTime DateTimeUtcFromUnixTime(long time) { - return UnixInitialTime.AddMilliseconds(time).ToUniversalTime(); + return _unixInitialTime.AddMilliseconds(time).ToUniversalTime(); } /// From 5f82dfd4e2247f2e98b2d0d0edd52ffe65afa189 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Sat, 17 Oct 2026 22:29:21 +0300 Subject: [PATCH 10/13] feat(data): add pluggable storage backends and a key prefix to DataService DataService now persists through IDataStorage. PlayerPrefsDataStorage stays the default, so existing saves keep loading under typeof(T).Name. - FileDataStorage writes {key}.json through a temp file and File.Replace so an interrupted save keeps the previous value. - BrowserLocalDataStorage uses localStorage through a WebGL-only jslib; it throws PlatformNotSupportedException outside a WebGL player and InvalidOperationException when the browser blocks storage. - An optional key prefix stores data under {prefix}.{typeof(T).Name}. - The Services Explorer Data tab reads and deletes through the bound service's storage. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- CHANGELOG.md | 7 ++ Editor/Explorer/Tabs/DataTab.cs | 19 ++-- README.md | 2 +- Runtime/BrowserLocalDataStorage.cs | 80 ++++++++++++++++ Runtime/BrowserLocalDataStorage.cs.meta | 11 +++ Runtime/DataService.cs | 58 ++++++++--- Runtime/DataStorage.cs | 73 ++++++++++++++ Runtime/DataStorage.cs.meta | 11 +++ Runtime/FileDataStorage.cs | 95 +++++++++++++++++++ Runtime/FileDataStorage.cs.meta | 11 +++ Runtime/Plugins.meta | 8 ++ Runtime/Plugins/WebGL.meta | 8 ++ .../WebGL/BrowserLocalDataStorage.jslib | 44 +++++++++ .../WebGL/BrowserLocalDataStorage.jslib.meta | 32 +++++++ .../Unit/BrowserLocalDataStorageTest.cs | 21 ++++ .../Unit/BrowserLocalDataStorageTest.cs.meta | 11 +++ Tests/EditMode/Unit/DataServiceTest.cs | 45 +++++++++ Tests/EditMode/Unit/FileDataStorageTest.cs | 91 ++++++++++++++++++ .../EditMode/Unit/FileDataStorageTest.cs.meta | 11 +++ .../Unit/PlayerPrefsDataStorageTest.cs | 48 ++++++++++ .../Unit/PlayerPrefsDataStorageTest.cs.meta | 11 +++ docs/data-service.md | 58 +++++++++-- 23 files changed, 724 insertions(+), 34 deletions(-) create mode 100644 Runtime/BrowserLocalDataStorage.cs create mode 100644 Runtime/BrowserLocalDataStorage.cs.meta create mode 100644 Runtime/DataStorage.cs create mode 100644 Runtime/DataStorage.cs.meta create mode 100644 Runtime/FileDataStorage.cs create mode 100644 Runtime/FileDataStorage.cs.meta create mode 100644 Runtime/Plugins.meta create mode 100644 Runtime/Plugins/WebGL.meta create mode 100644 Runtime/Plugins/WebGL/BrowserLocalDataStorage.jslib create mode 100644 Runtime/Plugins/WebGL/BrowserLocalDataStorage.jslib.meta create mode 100644 Tests/EditMode/Unit/BrowserLocalDataStorageTest.cs create mode 100644 Tests/EditMode/Unit/BrowserLocalDataStorageTest.cs.meta create mode 100644 Tests/EditMode/Unit/FileDataStorageTest.cs create mode 100644 Tests/EditMode/Unit/FileDataStorageTest.cs.meta create mode 100644 Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs create mode 100644 Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs.meta diff --git a/AGENTS.md b/AGENTS.md index ee2071d..47215fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,8 @@ This guide adds package-specific rules to the host repository guide. Consumer us - Public async-coroutine completion callbacks have replace semantics. Editor tracking uses the separate internal cleanup event and must not overwrite consumer callbacks. - `PoolService` permits one pool per entity type. Pool code that touches Unity objects must guard Unity fake-null because consumers may destroy pooled objects externally. - `ObjectPool` dispatches lifecycle hooks by casting the entity. `GameObjectPool` and `GameObjectPool` discover hooks with `GetComponent`; implement hooks on the object appropriate to the chosen pool type. -- `DataService` stores reference types by `Type`; persisted keys use `typeof(T).Name`, and absent saved values require a parameterless constructor. +- `DataService` stores reference types by `Type` and persists them through one `IDataStorage` (`PlayerPrefsDataStorage` by default). Storage keys are `typeof(T).Name`, prefixed `{keyPrefix}.` only when a key prefix is given, so the default keys stay compatible with existing saves. Absent saved values require a parameterless constructor. +- `BrowserLocalDataStorage` keeps its `[DllImport]` declarations behind `UNITY_WEBGL && !UNITY_EDITOR`; elsewhere its construction throws `PlatformNotSupportedException`. Never make it fall back silently. - `AddressablesAssetLoader.UnloadAsset` releases synchronously and does not destroy instantiated GameObjects or force global memory collection. - `IAssetAdderService.AddConfigs` is a default interface method and dispatches through an interface-typed reference, not through `AssetResolverService` directly. - `AssetConfigsScriptableObject` stores `AssetReference` entries through its base class; `TAsset` describes the resolved asset type. Do not replace the weak-link storage with serialized concrete assets. diff --git a/CHANGELOG.md b/CHANGELOG.md index a904dca..3aa4749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +**New**: +- **Data storage backends**: `DataService` now persists through a pluggable `IDataStorage` (`HasKey`, `Read`, `Write`, `Delete` by string key). `new DataService()` keeps using `PlayerPrefs` through the new `PlayerPrefsDataStorage`, so existing saves load unchanged. +- **File saves**: `FileDataStorage` stores each type as a `{key}.json` file in `Application.persistentDataPath` or a directory you choose. Every write goes to a temporary file that then replaces the previous one, so an interrupted save keeps the last complete value. +- **WebGL saves**: `BrowserLocalDataStorage` stores data in the browser's `localStorage`, which is keyed by page origin and survives new uploads on hosts that change the build path every upload, unlike `PlayerPrefs` and `persistentDataPath` on WebGL. Writes are synchronous; it throws in the Editor and on other platforms instead of silently storing nothing. +- **Key prefix**: `new DataService(storage, keyPrefix)` stores each type under `{keyPrefix}.{typeof(T).Name}`, so separate profiles or modes keep separate saves. Without a prefix, keys stay `typeof(T).Name`. + **Changed**: +- The Services Explorer Data tab checks and deletes saved values through the bound service's storage instead of always using `PlayerPrefs`. - Lowered the declared Unity minimum from 6000.3 to 6000.0 and documented 6000.0.x, 6000.3.x, and 6000.5.x as compatibility reference streams. **Fixed**: diff --git a/Editor/Explorer/Tabs/DataTab.cs b/Editor/Explorer/Tabs/DataTab.cs index 17311bd..14bc940 100644 --- a/Editor/Explorer/Tabs/DataTab.cs +++ b/Editor/Explorer/Tabs/DataTab.cs @@ -9,7 +9,7 @@ namespace GameLovers.Services.Editor.Explorer.Tabs { /// /// Displays all data entries held by . - /// Supports Save, Load, AddOrReplace, and Delete PlayerPrefs key. + /// Supports Save, Load, and deleting the saved value from the service's storage. /// public class DataTab : ServiceTab { @@ -81,13 +81,13 @@ protected override void Refresh() var dataType = kvp.Key; var data = kvp.Value; var json = TrySerializeJson(data); - var prefsKey = dataType.Name; - var hasPrefs = PlayerPrefs.HasKey(prefsKey); + var storageKey = dataService.GetStorageKey(dataType); + var isSaved = dataService.Storage.HasKey(storageKey); var container = new VisualElement(); container.style.marginBottom = 6; - var row = MakeRow(dataType.Name, hasPrefs ? "[saved]" : "[in-memory only]"); + var row = MakeRow(dataType.Name, isSaved ? "[saved]" : "[in-memory only]"); var saveBtn = MakeRowButton("Save", () => OnSave(dataService, dataType)); row.Add(saveBtn); @@ -95,7 +95,7 @@ protected override void Refresh() var loadBtn = MakeRowButton("Load", () => { OnLoad(dataService, dataType); Refresh(); }); row.Add(loadBtn); - var deleteBtn = MakeRowButton("Del PlayerPrefs", () => OnDeletePrefs(prefsKey), danger: true); + var deleteBtn = MakeRowButton("Del Saved", () => OnDeleteSaved(dataService, storageKey), danger: true); row.Add(deleteBtn); container.Add(row); @@ -173,16 +173,15 @@ private void OnLoad(DataService dataService, Type dataType) } } - private void OnDeletePrefs(string key) + private void OnDeleteSaved(DataService dataService, string key) { - if (!EditorUtility.DisplayDialog("Delete PlayerPrefs Key", - $"Delete PlayerPrefs key \"{key}\"? This cannot be undone.", "Delete", "Cancel")) + if (!EditorUtility.DisplayDialog("Delete Saved Data", + $"Delete the saved value \"{key}\"? This cannot be undone.", "Delete", "Cancel")) { return; } - PlayerPrefs.DeleteKey(key); - PlayerPrefs.Save(); + dataService.Storage.Delete(key); Refresh(); } } diff --git a/README.md b/README.md index 82fdb77..39e5ff3 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ public sealed class GameBootstrap : MonoBehaviour | State | `IDataService`, `ITimeService`, `IRngService`, commands, and version data | | Assets | `IAssetLoader`, `ISceneLoader`, and `AssetResolverService` | -Data storage is appropriate for small, non-secret local state. Addressables handles must be released by the owner that acquired them; loading and instantiating have different release paths. +Data storage is appropriate for small, non-secret local state. `DataService` persists through `PlayerPrefs` by default; pass `FileDataStorage` for atomic file saves, `BrowserLocalDataStorage` for WebGL saves that survive new builds, and an optional key prefix to scope saves per profile. See [Data Service](docs/data-service.md#storage-backends). Addressables handles must be released by the owner that acquired them; loading and instantiating have different release paths. For Asset Resolver sample code, call `AddConfigs` through `IAssetAdderService` rather than the concrete resolver, and reference GameData directly from any asmdef that uses its public types. diff --git a/Runtime/BrowserLocalDataStorage.cs b/Runtime/BrowserLocalDataStorage.cs new file mode 100644 index 0000000..fdb3eca --- /dev/null +++ b/Runtime/BrowserLocalDataStorage.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.Services +{ + /// + /// Stores data in the browser's localStorage in WebGL players. localStorage is scoped to the page + /// origin, not the build, so saved data survives new uploads on hosts that change the build path every upload. + /// Writes are synchronous. Construction throws outside a WebGL + /// player, including in the Editor, and when the browser blocks + /// localStorage. + /// + public class BrowserLocalDataStorage : IDataStorage + { + public BrowserLocalDataStorage() + { + if (!LocalStorageIsAvailable()) + { + throw new InvalidOperationException("The browser blocks localStorage for this page."); + } + } + + /// + public bool HasKey(string key) + { + return LocalStorageHasKey(key); + } + + /// + public string Read(string key) + { + return LocalStorageRead(key); + } + + /// + public void Write(string key, string value) + { + if (!LocalStorageWrite(key, value ?? string.Empty)) + { + throw new IOException($"localStorage rejected the write for '{key}', for example because it is full."); + } + } + + /// + public void Delete(string key) + { + LocalStorageDelete(key); + } + +#if UNITY_WEBGL && !UNITY_EDITOR + [DllImport("__Internal", EntryPoint = "GameLoversLocalStorageIsAvailable")] + private static extern bool LocalStorageIsAvailable(); + + [DllImport("__Internal", EntryPoint = "GameLoversLocalStorageHasKey")] + private static extern bool LocalStorageHasKey(string key); + + [DllImport("__Internal", EntryPoint = "GameLoversLocalStorageRead")] + private static extern string LocalStorageRead(string key); + + [DllImport("__Internal", EntryPoint = "GameLoversLocalStorageWrite")] + private static extern bool LocalStorageWrite(string key, string value); + + [DllImport("__Internal", EntryPoint = "GameLoversLocalStorageDelete")] + private static extern void LocalStorageDelete(string key); +#else + // Outside a WebGL player construction fails here, so the other stubs are never reached. + private static bool LocalStorageIsAvailable() => throw new PlatformNotSupportedException( + $"{nameof(BrowserLocalDataStorage)} only runs in WebGL players. Use another {nameof(IDataStorage)} " + + "in the Editor and on other platforms."); + + private static bool LocalStorageHasKey(string key) => throw new PlatformNotSupportedException(); + private static string LocalStorageRead(string key) => throw new PlatformNotSupportedException(); + private static bool LocalStorageWrite(string key, string value) => throw new PlatformNotSupportedException(); + private static void LocalStorageDelete(string key) => throw new PlatformNotSupportedException(); +#endif + } +} diff --git a/Runtime/BrowserLocalDataStorage.cs.meta b/Runtime/BrowserLocalDataStorage.cs.meta new file mode 100644 index 0000000..8624e8c --- /dev/null +++ b/Runtime/BrowserLocalDataStorage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b0d1aae7b18a94e169b64c3567c1a183 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/DataService.cs b/Runtime/DataService.cs index e35c358..22e8316 100644 --- a/Runtime/DataService.cs +++ b/Runtime/DataService.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; -using UnityEngine; // ReSharper disable once CheckNamespace @@ -30,17 +29,17 @@ public interface IDataProvider public interface IDataService : IDataProvider { /// - /// Saves the game's given data to disk + /// Saves the game's given data to its storage /// void SaveData() where T : class; /// - /// Saves all game's data to disk + /// Saves all game's data to its storage /// void SaveAllData(); /// - /// Loads the game's given data from disk and returns it + /// Loads the game's given data from its storage and returns it /// T LoadData() where T : class; @@ -50,14 +49,34 @@ public interface IDataService : IDataProvider void AddOrReplaceData(T data) where T : class; } - /// + /// + /// Keeps game data in memory by type and persists it as JSON through an , + /// by default. Each type is stored under typeof(T).Name, or under + /// {keyPrefix}.{typeof(T).Name} when a key prefix is given, so services with different prefixes keep + /// separate saves in the same storage. + /// public class DataService : IDataService { private readonly IDictionary _data = new Dictionary(); + private readonly IDataStorage _storage; + private readonly string _keyPrefix; /// The in-memory store, keyed by data type. Editor introspection only — see AGENTS.md §4. internal IReadOnlyDictionary DataEntries => (IReadOnlyDictionary)_data; + /// The storage this service persists to. Editor introspection only. + internal IDataStorage Storage => _storage; + + public DataService() : this(new PlayerPrefsDataStorage()) + { + } + + public DataService(IDataStorage storage, string keyPrefix = null) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _keyPrefix = keyPrefix; + } + /// public bool HasData() where T : class { @@ -73,11 +92,7 @@ public T GetData() where T : class /// public void SaveData() where T : class { - var type = typeof(T); - - PlayerPrefs.SetString(type.Name, JsonConvert.SerializeObject(_data[type])); - PlayerPrefs.Save(); - OnDataSaved(type.Name, _data[type], type); + Save(typeof(T), _data[typeof(T)]); } /// @@ -85,17 +100,14 @@ public void SaveAllData() { foreach (var data in _data) { - PlayerPrefs.SetString(data.Key.Name, JsonConvert.SerializeObject(data.Value)); - OnDataSaved(data.Key.Name, data.Value, data.Key); + Save(data.Key, data.Value); } - - PlayerPrefs.Save(); } /// public T LoadData() where T : class { - var json = PlayerPrefs.GetString(typeof(T).Name, ""); + var json = _storage.Read(GetStorageKey(typeof(T))); var instance = string.IsNullOrEmpty(json) ? Activator.CreateInstance() : JsonConvert.DeserializeObject(json); AddOrReplaceData(instance); @@ -116,11 +128,27 @@ public void AddOrReplaceData(T data) where T : class } } + /// + /// Returns the storage key that data of the given is persisted under. + /// + internal string GetStorageKey(Type type) + { + return string.IsNullOrEmpty(_keyPrefix) ? type.Name : $"{_keyPrefix}.{type.Name}"; + } + /// /// Runs after a save has been written; override to mirror the data elsewhere. /// protected virtual void OnDataSaved(string key, object data, Type type) { } + + private void Save(Type type, object data) + { + var key = GetStorageKey(type); + + _storage.Write(key, JsonConvert.SerializeObject(data)); + OnDataSaved(key, data, type); + } } } \ No newline at end of file diff --git a/Runtime/DataStorage.cs b/Runtime/DataStorage.cs new file mode 100644 index 0000000..1e95873 --- /dev/null +++ b/Runtime/DataStorage.cs @@ -0,0 +1,73 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.Services +{ + /// + /// A persistent string store that reads and writes serialized data through. + /// Implement it to persist data somewhere other than the shipped backends. + /// + public interface IDataStorage + { + /// + /// Requests if a value is stored under the given + /// + /// The storage key. + /// True when a value is stored under . + bool HasKey(string key); + + /// + /// Reads the value stored under the given + /// + /// The storage key. + /// The stored value, or null when nothing is stored under . + string Read(string key); + + /// + /// Writes the given under the given , replacing any previous value. + /// The value is persisted when the call returns. + /// + /// The storage key. + /// The value to store. + void Write(string key, string value); + + /// + /// Deletes the value stored under the given . Does nothing when no value is stored. + /// + /// The storage key. + void Delete(string key); + } + + /// + /// Stores data in . This is the default storage. + /// + public class PlayerPrefsDataStorage : IDataStorage + { + /// + public bool HasKey(string key) + { + return PlayerPrefs.HasKey(key); + } + + /// + public string Read(string key) + { + return PlayerPrefs.HasKey(key) ? PlayerPrefs.GetString(key) : null; + } + + /// + public void Write(string key, string value) + { + PlayerPrefs.SetString(key, value); + PlayerPrefs.Save(); + } + + /// + public void Delete(string key) + { + PlayerPrefs.DeleteKey(key); + PlayerPrefs.Save(); + } + } +} diff --git a/Runtime/DataStorage.cs.meta b/Runtime/DataStorage.cs.meta new file mode 100644 index 0000000..7374f01 --- /dev/null +++ b/Runtime/DataStorage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4904da7b8bafd4eba8b2d0f31d05f6b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/FileDataStorage.cs b/Runtime/FileDataStorage.cs new file mode 100644 index 0000000..c95e463 --- /dev/null +++ b/Runtime/FileDataStorage.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using System.Text; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.Services +{ + /// + /// Stores each key as a UTF-8 {key}.json file in one directory: the directory passed to the + /// constructor, created when missing, or by default. + /// Writes go to a temporary file that then replaces the previous file, so an interrupted write never + /// leaves a partially written value behind. + /// + public class FileDataStorage : IDataStorage + { + private const string FileExtension = ".json"; + private const string TempExtension = ".tmp"; + + private static readonly Encoding _utf8NoBom = new UTF8Encoding(false); + private static readonly char[] _invalidKeyChars = Path.GetInvalidFileNameChars(); + + private readonly string _directory; + + public FileDataStorage() : this(Application.persistentDataPath) + { + } + + public FileDataStorage(string directory) + { + if (string.IsNullOrEmpty(directory)) + { + throw new ArgumentException("A storage directory is required.", nameof(directory)); + } + + _directory = directory; + Directory.CreateDirectory(_directory); + } + + /// + public bool HasKey(string key) + { + return File.Exists(GetPath(key)); + } + + /// + public string Read(string key) + { + var path = GetPath(key); + + return File.Exists(path) ? File.ReadAllText(path, _utf8NoBom) : null; + } + + /// + public void Write(string key, string value) + { + var path = GetPath(key); + var tempPath = path + TempExtension; + + using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + var bytes = _utf8NoBom.GetBytes(value ?? string.Empty); + + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + if (File.Exists(path)) + { + File.Replace(tempPath, path, null); + } + else + { + File.Move(tempPath, path); + } + } + + /// + public void Delete(string key) + { + File.Delete(GetPath(key)); + } + + private string GetPath(string key) + { + if (string.IsNullOrEmpty(key) || key.IndexOfAny(_invalidKeyChars) >= 0) + { + throw new ArgumentException($"'{key}' is not a valid file name.", nameof(key)); + } + + return Path.Combine(_directory, key + FileExtension); + } + } +} diff --git a/Runtime/FileDataStorage.cs.meta b/Runtime/FileDataStorage.cs.meta new file mode 100644 index 0000000..302a2bf --- /dev/null +++ b/Runtime/FileDataStorage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a9a7a8a8bc0fe4c6b8e03b1b2c8aef54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Plugins.meta b/Runtime/Plugins.meta new file mode 100644 index 0000000..4c85a78 --- /dev/null +++ b/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fc38a224f5d22495593a72eb79dc5b87 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Plugins/WebGL.meta b/Runtime/Plugins/WebGL.meta new file mode 100644 index 0000000..4505c1a --- /dev/null +++ b/Runtime/Plugins/WebGL.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 842fbaa9676c6482eb53ce53d5abfd00 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Plugins/WebGL/BrowserLocalDataStorage.jslib b/Runtime/Plugins/WebGL/BrowserLocalDataStorage.jslib new file mode 100644 index 0000000..db27354 --- /dev/null +++ b/Runtime/Plugins/WebGL/BrowserLocalDataStorage.jslib @@ -0,0 +1,44 @@ +// Native side of BrowserLocalDataStorage. Every call is synchronous; localStorage throws when the page +// blocks storage or the quota is full, so each access is guarded and reported to C# as a status. +mergeInto(LibraryManager.library, { + GameLoversLocalStorageIsAvailable: function () { + try { + var probe = "__gamelovers_probe__"; + window.localStorage.setItem(probe, probe); + window.localStorage.removeItem(probe); + return 1; + } catch (e) { + return 0; + } + }, + + GameLoversLocalStorageHasKey: function (key) { + return window.localStorage.getItem(UTF8ToString(key)) !== null ? 1 : 0; + }, + + GameLoversLocalStorageRead: function (key) { + var value = window.localStorage.getItem(UTF8ToString(key)); + + if (value === null) { + return 0; + } + + var size = lengthBytesUTF8(value) + 1; + var buffer = _malloc(size); + stringToUTF8(value, buffer, size); + return buffer; + }, + + GameLoversLocalStorageWrite: function (key, value) { + try { + window.localStorage.setItem(UTF8ToString(key), UTF8ToString(value)); + return 1; + } catch (e) { + return 0; + } + }, + + GameLoversLocalStorageDelete: function (key) { + window.localStorage.removeItem(UTF8ToString(key)); + } +}); diff --git a/Runtime/Plugins/WebGL/BrowserLocalDataStorage.jslib.meta b/Runtime/Plugins/WebGL/BrowserLocalDataStorage.jslib.meta new file mode 100644 index 0000000..a5d1981 --- /dev/null +++ b/Runtime/Plugins/WebGL/BrowserLocalDataStorage.jslib.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 8281ca4f4f4454d9c9ed4bed1c7313de +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + WebGL: WebGL + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EditMode/Unit/BrowserLocalDataStorageTest.cs b/Tests/EditMode/Unit/BrowserLocalDataStorageTest.cs new file mode 100644 index 0000000..abf136d --- /dev/null +++ b/Tests/EditMode/Unit/BrowserLocalDataStorageTest.cs @@ -0,0 +1,21 @@ +using System; +using GameLovers.Services; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.Services.Tests +{ + [TestFixture] + public class BrowserLocalDataStorageTest + { + [Test] + // ADMIT: BrowserLocalDataStorage refuses construction outside a WebGL player instead of silently storing nothing. + // RCR: BrowserLocalDataStorage.cs LocalStorageIsAvailable — return true in the non-WebGL stub → RED (nothing thrown). + // 2026-10-17 + public void Constructor_InEditor_ThrowsPlatformNotSupportedException() + { + Assert.Throws(() => new BrowserLocalDataStorage()); + } + } +} diff --git a/Tests/EditMode/Unit/BrowserLocalDataStorageTest.cs.meta b/Tests/EditMode/Unit/BrowserLocalDataStorageTest.cs.meta new file mode 100644 index 0000000..d6fcebc --- /dev/null +++ b/Tests/EditMode/Unit/BrowserLocalDataStorageTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9df8d032cb2cc4dcca6a02df26186663 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EditMode/Unit/DataServiceTest.cs b/Tests/EditMode/Unit/DataServiceTest.cs index cd06786..28b446f 100644 --- a/Tests/EditMode/Unit/DataServiceTest.cs +++ b/Tests/EditMode/Unit/DataServiceTest.cs @@ -36,6 +36,16 @@ protected override void OnDataSaved(string key, object data, System.Type type) } } + private class MemoryDataStorage : IDataStorage + { + public readonly Dictionary Values = new Dictionary(); + + public bool HasKey(string key) => Values.ContainsKey(key); + public string Read(string key) => Values.TryGetValue(key, out var value) ? value : null; + public void Write(string key, string value) => Values[key] = value; + public void Delete(string key) => Values.Remove(key); + } + private const string PersistentDataKey = nameof(PersistentData); private DataService _dataService; @@ -190,5 +200,40 @@ public void OnDataSaved_SubclassHook_FiresAfterSave() Assert.AreSame(data, subclass.LastInstance); Assert.AreEqual(typeof(PersistentData), subclass.LastType); } + + [Test] + // ADMIT: DataService.SaveData writes through the storage passed to its constructor, under typeof(T).Name when + // no key prefix is set. + // RCR: DataService.cs Save — write through a new PlayerPrefsDataStorage → RED (injected storage stays empty). Also + // reddens KeyPrefix_SharedStorage_IsolatesSaves. 2026-10-17 + public void SaveData_CustomStorage_WritesThroughInjectedStorage() + { + var storage = new MemoryDataStorage(); + var service = new DataService(storage); + service.AddOrReplaceData(new PersistentData { Name = "Stored", Value = 7 }); + + service.SaveData(); + + CollectionAssert.AreEquivalent(new[] { PersistentDataKey }, storage.Values.Keys); + Assert.AreEqual("Stored", new DataService(storage).LoadData().Name); + } + + [Test] + // ADMIT: DataService prepends its key prefix to every storage key, so services with different prefixes keep + // separate saves in one storage. + // RCR: DataService.cs GetStorageKey — ignore the prefix → RED (stored key is "PersistentData"). 2026-10-17 + public void KeyPrefix_SharedStorage_IsolatesSaves() + { + var storage = new MemoryDataStorage(); + var profileA = new DataService(storage, "ProfileA"); + profileA.AddOrReplaceData(new PersistentData { Name = "A", Value = 1 }); + + profileA.SaveAllData(); + + CollectionAssert.AreEquivalent(new[] { "ProfileA." + PersistentDataKey }, storage.Values.Keys); + Assert.IsNull(new DataService(storage, "ProfileB").LoadData().Name); + Assert.IsNull(new DataService(storage).LoadData().Name); + Assert.AreEqual("A", new DataService(storage, "ProfileA").LoadData().Name); + } } } diff --git a/Tests/EditMode/Unit/FileDataStorageTest.cs b/Tests/EditMode/Unit/FileDataStorageTest.cs new file mode 100644 index 0000000..498f086 --- /dev/null +++ b/Tests/EditMode/Unit/FileDataStorageTest.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using GameLovers.Services; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.Services.Tests +{ + [TestFixture] + public class FileDataStorageTest + { + private const string Key = "PersistentData"; + + private string _directory; + private FileDataStorage _storage; + + [SetUp] + public void Init() + { + _directory = Path.Combine(Path.GetTempPath(), nameof(FileDataStorageTest), Guid.NewGuid().ToString("N")); + _storage = new FileDataStorage(_directory); + } + + [TearDown] + public void Cleanup() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, true); + } + } + + [Test] + // ADMIT: FileDataStorage persists a value to a file that a new instance over the same directory reads back, + // and Delete removes it. + // RCR: FileDataStorage.cs Delete — skip File.Delete → RED (HasKey still true after Delete). 2026-10-17 + public void Write_Read_Delete_RoundTripThroughNewInstance() + { + Assert.IsNull(_storage.Read(Key)); + + _storage.Write(Key, "{\"Name\":\"Hérø\"}"); + + var reopened = new FileDataStorage(_directory); + + Assert.IsTrue(reopened.HasKey(Key)); + Assert.AreEqual("{\"Name\":\"Hérø\"}", reopened.Read(Key)); + + reopened.Delete(Key); + + Assert.IsFalse(_storage.HasKey(Key)); + Assert.IsNull(_storage.Read(Key)); + } + + [Test] + // ADMIT: FileDataStorage.Write over an existing key replaces its file and leaves no temporary file behind. + // RCR: FileDataStorage.cs Write — drop the File.Replace call for an existing file → RED (Read returns "first"). 2026-10-17 + public void Write_ExistingKey_ReplacesValueAndLeavesNoTempFile() + { + _storage.Write(Key, "first"); + _storage.Write(Key, "second"); + + Assert.AreEqual("second", _storage.Read(Key)); + CollectionAssert.AreEquivalent(new[] { Key + ".json" }, + Array.ConvertAll(Directory.GetFileSystemEntries(_directory), Path.GetFileName)); + } + + [Test] + // ADMIT: FileDataStorage.Write stages the value in a temporary file, so a write that fails midway leaves the + // previous value intact instead of a truncated save. + // RCR: FileDataStorage.cs Write — write the target file directly with File.WriteAllText → RED (no exception thrown). + // 2026-10-17 + public void Write_TempFileFails_KeepsPreviousValue() + { + _storage.Write(Key, "first"); + Directory.CreateDirectory(Path.Combine(_directory, Key + ".json.tmp")); + + Assert.Catch(() => _storage.Write(Key, "second")); + Assert.AreEqual("first", _storage.Read(Key)); + } + + [Test] + // ADMIT: FileDataStorage rejects keys that are not plain file names, so a key cannot address a file outside its + // directory. + // RCR: FileDataStorage.cs GetPath — drop the invalid file-name check → RED (no ArgumentException). 2026-10-17 + public void Write_KeyWithPathSeparator_ThrowsArgumentException() + { + Assert.Throws(() => _storage.Write("../" + Key, "value")); + } + } +} diff --git a/Tests/EditMode/Unit/FileDataStorageTest.cs.meta b/Tests/EditMode/Unit/FileDataStorageTest.cs.meta new file mode 100644 index 0000000..f372f50 --- /dev/null +++ b/Tests/EditMode/Unit/FileDataStorageTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 911cd34fe027f4aa998a147e2fa4ece0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs b/Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs new file mode 100644 index 0000000..f66c710 --- /dev/null +++ b/Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs @@ -0,0 +1,48 @@ +using GameLovers.Services; +using NUnit.Framework; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.Services.Tests +{ + [TestFixture] + public class PlayerPrefsDataStorageTest + { + private const string Key = nameof(PlayerPrefsDataStorageTest); + + private PlayerPrefsDataStorage _storage; + + [SetUp] + public void Init() + { + _storage = new PlayerPrefsDataStorage(); + PlayerPrefs.DeleteKey(Key); + } + + [TearDown] + public void Cleanup() + { + PlayerPrefs.DeleteKey(Key); + } + + [Test] + // ADMIT: PlayerPrefsDataStorage.Read reports a missing key as null, and Write/Delete persist through PlayerPrefs. + // RCR: DataStorage.cs PlayerPrefsDataStorage.Read — return GetString without the HasKey guard → RED (expected null, + // was string.Empty). 2026-10-17 + public void Write_Read_Delete_RoundTrip() + { + Assert.IsNull(_storage.Read(Key)); + + _storage.Write(Key, "{\"Value\":1}"); + + Assert.IsTrue(_storage.HasKey(Key)); + Assert.AreEqual("{\"Value\":1}", new PlayerPrefsDataStorage().Read(Key)); + + _storage.Delete(Key); + + Assert.IsFalse(_storage.HasKey(Key)); + Assert.IsNull(_storage.Read(Key)); + } + } +} diff --git a/Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs.meta b/Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs.meta new file mode 100644 index 0000000..d313587 --- /dev/null +++ b/Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93888626ca4164654bfbab2bf1fc7ce3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/data-service.md b/docs/data-service.md index 76ad265..027c736 100644 --- a/docs/data-service.md +++ b/docs/data-service.md @@ -1,13 +1,14 @@ -# Data Service +# Data Service [← Back to index](README.md) -Cross-platform persistent data storage with JSON serialization. +Cross-platform persistent data storage with JSON serialization and a pluggable storage backend. **Key Points:** - Data is keyed by **type** (`typeof(T)`) — no string keys at the API level - Only **reference types** (`class`) are supported; value types (`struct`) are not -- Disk keys in `PlayerPrefs` use `typeof(T).Name` — watch for name collisions across assemblies with types sharing the same short name +- Storage keys use `typeof(T).Name`, or `{keyPrefix}.{typeof(T).Name}` with a key prefix — watch for name collisions across assemblies with types sharing the same short name +- Data persists through `PlayerPrefs` unless you pass another [storage backend](#storage-backends) - `GetData()` throws `KeyNotFoundException` if the type has not been loaded or added — use `HasData()` to guard - `LoadData` calls `Activator.CreateInstance()` when no saved data exists; `T` must have a **parameterless constructor** @@ -22,20 +23,20 @@ public class PlayerData var dataService = new DataService(); -// Load from disk (or create fresh if not saved yet) +// Load from storage (or create fresh if not saved yet) PlayerData player = dataService.LoadData(); // Modify in memory player.Name = "Hero"; player.Level = 10; -// Save one type to disk +// Save one type to storage dataService.SaveData(); -// Save all loaded types to disk +// Save all loaded types to storage dataService.SaveAllData(); -// Add or replace in memory without saving to disk +// Add or replace in memory without saving dataService.AddOrReplaceData(new PlayerData { Name = "Alt", Level = 5 }); // Read back from memory @@ -48,6 +49,44 @@ if (dataService.HasData()) } ``` +## Storage backends + +`DataService` serializes each type to JSON and hands the string to an `IDataStorage`. Pass one to the constructor, with an optional key prefix: + +```csharp +// Default: PlayerPrefs, keys are typeof(T).Name +var dataService = new DataService(); + +// One JSON file per type in Application.persistentDataPath +var fileData = new DataService(new FileDataStorage()); + +// Files in a folder you choose, with saves scoped to one profile +var profileData = new DataService(new FileDataStorage(Path.Combine(Application.persistentDataPath, "Saves")), "Profile1"); +``` + +| Backend | Where data lives | Use for | +|---------|------------------|---------| +| `PlayerPrefsDataStorage` | `PlayerPrefs`, saved after every write | The default; small state on any platform | +| `FileDataStorage` | `{key}.json` files in one directory, `Application.persistentDataPath` by default | Desktop and mobile saves | +| `BrowserLocalDataStorage` | The browser's `localStorage` for the page origin | WebGL players | + +- `FileDataStorage` writes each value to `{key}.json.tmp` first and then replaces `{key}.json`, so a crash or power loss mid-save keeps the previous value. Keys must be valid file names. +- On WebGL, `PlayerPrefs` and `Application.persistentDataPath` are stored in IndexedDB under a path that includes the build. Hosts that publish each upload under a new path therefore lose those saves on every update. `localStorage` is keyed by the page origin, so `BrowserLocalDataStorage` saves survive new builds. Several games served from one host origin share one `localStorage`, so give each game its own key prefix. +- `BrowserLocalDataStorage` writes synchronously and throws `IOException` when the browser rejects a write, for example when its quota is full. Its constructor throws `PlatformNotSupportedException` in the Editor and on non-WebGL platforms, and `InvalidOperationException` when the browser blocks `localStorage`, so choose the backend per platform: + +```csharp +#if UNITY_WEBGL && !UNITY_EDITOR +IDataStorage storage = new BrowserLocalDataStorage(); +#else +IDataStorage storage = new FileDataStorage(); +#endif +var dataService = new DataService(storage, "MyGame"); +``` + +Implement `IDataStorage` (`HasKey`, `Read`, `Write`, `Delete`; `Read` returns `null` for a missing key) to persist anywhere else. `Write` must persist the value before it returns. + +Changing the backend or the key prefix changes where `LoadData` looks, so data saved under the old backend or prefix is not migrated automatically. + ## IDataProvider vs IDataService | Interface | Methods | Use for | @@ -63,3 +102,8 @@ Bind `IDataService` for systems that need to save; bind `IDataProvider` for read |------|-----------|-----------| | `GetData()` | `KeyNotFoundException` | `T` not loaded or added | | `LoadData()` | `MissingMethodException` | `T` has no parameterless constructor | +| `new DataService(null)` | `ArgumentNullException` | No storage given | +| `new FileDataStorage(...)` methods | `ArgumentException` | Key is not a valid file name | +| `new BrowserLocalDataStorage()` | `PlatformNotSupportedException` | Not running in a WebGL player | +| `new BrowserLocalDataStorage()` | `InvalidOperationException` | The browser blocks `localStorage` | +| `BrowserLocalDataStorage.Write` | `IOException` | The browser rejected the write | From 9e04ba525b2f5efd8d640afd82c832ba06ab8474 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Sat, 17 Oct 2026 22:30:11 +0300 Subject: [PATCH 11/13] docs: point the install snippet at 2.2.0 and note the UniTask install fix Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ README.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa4749..b7fd81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. **Fixed**: - The AssetResolver sample now assigns default actions to the Input System UI module it adds at runtime, so its buttons respond to input instead of looking interactive and receiving nothing. + +**Docs**: +- The install instructions now list UniTask as an explicit Git dependency, because Unity does not resolve Git dependencies transitively. ## [2.1.2] - 2026-08-04 diff --git a/README.md b/README.md index 39e5ff3..403a7df 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Unity does not resolve Git dependencies transitively. Add the required Git packa "dependencies": { "com.gamelovers.gamedata": "https://github.com/CoderGamester/Unity-GameData.git#1.0.3", "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask#2.5.10", - "com.gamelovers.services": "https://github.com/CoderGamester/Unity-Services.git#2.1.2" + "com.gamelovers.services": "https://github.com/CoderGamester/Unity-Services.git#2.2.0" } } ``` From f623aa489f332c4c8f447f1ecd7196f784397a3c Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Sat, 17 Oct 2026 22:30:34 +0300 Subject: [PATCH 12/13] chore: prepare release 2.2.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 34 +++++++++++++++++----------------- package.json | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7fd81b..9b7f5da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,23 +4,23 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [Unreleased] - -**New**: -- **Data storage backends**: `DataService` now persists through a pluggable `IDataStorage` (`HasKey`, `Read`, `Write`, `Delete` by string key). `new DataService()` keeps using `PlayerPrefs` through the new `PlayerPrefsDataStorage`, so existing saves load unchanged. -- **File saves**: `FileDataStorage` stores each type as a `{key}.json` file in `Application.persistentDataPath` or a directory you choose. Every write goes to a temporary file that then replaces the previous one, so an interrupted save keeps the last complete value. -- **WebGL saves**: `BrowserLocalDataStorage` stores data in the browser's `localStorage`, which is keyed by page origin and survives new uploads on hosts that change the build path every upload, unlike `PlayerPrefs` and `persistentDataPath` on WebGL. Writes are synchronous; it throws in the Editor and on other platforms instead of silently storing nothing. -- **Key prefix**: `new DataService(storage, keyPrefix)` stores each type under `{keyPrefix}.{typeof(T).Name}`, so separate profiles or modes keep separate saves. Without a prefix, keys stay `typeof(T).Name`. - -**Changed**: -- The Services Explorer Data tab checks and deletes saved values through the bound service's storage instead of always using `PlayerPrefs`. -- Lowered the declared Unity minimum from 6000.3 to 6000.0 and documented 6000.0.x, 6000.3.x, and 6000.5.x as compatibility reference streams. - -**Fixed**: -- The AssetResolver sample now assigns default actions to the Input System UI module it adds at runtime, so its buttons respond to input instead of looking interactive and receiving nothing. - -**Docs**: -- The install instructions now list UniTask as an explicit Git dependency, because Unity does not resolve Git dependencies transitively. +## [2.2.0] - 2026-10-17 + +**New**: +- **Data storage backends**: `DataService` now persists through a pluggable `IDataStorage` (`HasKey`, `Read`, `Write`, `Delete` by string key). `new DataService()` keeps using `PlayerPrefs` through the new `PlayerPrefsDataStorage`, so existing saves load unchanged. +- **File saves**: `FileDataStorage` stores each type as a `{key}.json` file in `Application.persistentDataPath` or a directory you choose. Every write goes to a temporary file that then replaces the previous one, so an interrupted save keeps the last complete value. +- **WebGL saves**: `BrowserLocalDataStorage` stores data in the browser's `localStorage`, which is keyed by page origin and survives new uploads on hosts that change the build path every upload, unlike `PlayerPrefs` and `persistentDataPath` on WebGL. Writes are synchronous; it throws in the Editor and on other platforms instead of silently storing nothing. +- **Key prefix**: `new DataService(storage, keyPrefix)` stores each type under `{keyPrefix}.{typeof(T).Name}`, so separate profiles or modes keep separate saves. Without a prefix, keys stay `typeof(T).Name`. + +**Changed**: +- The Services Explorer Data tab checks and deletes saved values through the bound service's storage instead of always using `PlayerPrefs`. +- Lowered the declared Unity minimum from 6000.3 to 6000.0 and documented 6000.0.x, 6000.3.x, and 6000.5.x as compatibility reference streams. + +**Fixed**: +- The AssetResolver sample now assigns default actions to the Input System UI module it adds at runtime, so its buttons respond to input instead of looking interactive and receiving nothing. + +**Docs**: +- The install instructions now list UniTask as an explicit Git dependency, because Unity does not resolve Git dependencies transitively. ## [2.1.2] - 2026-08-04 diff --git a/package.json b/package.json index 48e0a06..8ea875f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "com.gamelovers.services", "displayName": "GameLovers Services", "author": "Miguel Tomas", - "version": "2.1.2", + "version": "2.2.0", "unity": "6000.0", "license": "MIT", "description": "Foundation services: lightweight installation, messaging, ticking, coroutines, pooling, persistence, RNG, time, commands, versioning, and Addressables tooling.", From f4eb45e8b7c9deb0444e2111cc3e94641e162be4 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Sat, 17 Oct 2026 22:46:49 +0300 Subject: [PATCH 13/13] refactor(data): split IDataStorage and PlayerPrefsDataStorage into their own files Co-Authored-By: Claude Opus 5.5 --- Runtime/{DataStorage.cs => IDataStorage.cs} | 34 ----------------- Runtime/IDataStorage.cs.meta | 11 ++++++ Runtime/PlayerPrefsDataStorage.cs | 38 +++++++++++++++++++ ...cs.meta => PlayerPrefsDataStorage.cs.meta} | 0 .../Unit/PlayerPrefsDataStorageTest.cs | 2 +- 5 files changed, 50 insertions(+), 35 deletions(-) rename Runtime/{DataStorage.cs => IDataStorage.cs} (67%) create mode 100644 Runtime/IDataStorage.cs.meta create mode 100644 Runtime/PlayerPrefsDataStorage.cs rename Runtime/{DataStorage.cs.meta => PlayerPrefsDataStorage.cs.meta} (100%) diff --git a/Runtime/DataStorage.cs b/Runtime/IDataStorage.cs similarity index 67% rename from Runtime/DataStorage.cs rename to Runtime/IDataStorage.cs index 1e95873..93e8852 100644 --- a/Runtime/DataStorage.cs +++ b/Runtime/IDataStorage.cs @@ -1,5 +1,3 @@ -using UnityEngine; - // ReSharper disable once CheckNamespace namespace GameLovers.Services @@ -38,36 +36,4 @@ public interface IDataStorage /// The storage key. void Delete(string key); } - - /// - /// Stores data in . This is the default storage. - /// - public class PlayerPrefsDataStorage : IDataStorage - { - /// - public bool HasKey(string key) - { - return PlayerPrefs.HasKey(key); - } - - /// - public string Read(string key) - { - return PlayerPrefs.HasKey(key) ? PlayerPrefs.GetString(key) : null; - } - - /// - public void Write(string key, string value) - { - PlayerPrefs.SetString(key, value); - PlayerPrefs.Save(); - } - - /// - public void Delete(string key) - { - PlayerPrefs.DeleteKey(key); - PlayerPrefs.Save(); - } - } } diff --git a/Runtime/IDataStorage.cs.meta b/Runtime/IDataStorage.cs.meta new file mode 100644 index 0000000..6e19d84 --- /dev/null +++ b/Runtime/IDataStorage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 126aab319be874da99e1b7a2bfbe4d3b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/PlayerPrefsDataStorage.cs b/Runtime/PlayerPrefsDataStorage.cs new file mode 100644 index 0000000..18e77d4 --- /dev/null +++ b/Runtime/PlayerPrefsDataStorage.cs @@ -0,0 +1,38 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.Services +{ + /// + /// Stores data in . This is the default storage. + /// + public class PlayerPrefsDataStorage : IDataStorage + { + /// + public bool HasKey(string key) + { + return PlayerPrefs.HasKey(key); + } + + /// + public string Read(string key) + { + return PlayerPrefs.HasKey(key) ? PlayerPrefs.GetString(key) : null; + } + + /// + public void Write(string key, string value) + { + PlayerPrefs.SetString(key, value); + PlayerPrefs.Save(); + } + + /// + public void Delete(string key) + { + PlayerPrefs.DeleteKey(key); + PlayerPrefs.Save(); + } + } +} diff --git a/Runtime/DataStorage.cs.meta b/Runtime/PlayerPrefsDataStorage.cs.meta similarity index 100% rename from Runtime/DataStorage.cs.meta rename to Runtime/PlayerPrefsDataStorage.cs.meta diff --git a/Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs b/Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs index f66c710..aeabf11 100644 --- a/Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs +++ b/Tests/EditMode/Unit/PlayerPrefsDataStorageTest.cs @@ -28,7 +28,7 @@ public void Cleanup() [Test] // ADMIT: PlayerPrefsDataStorage.Read reports a missing key as null, and Write/Delete persist through PlayerPrefs. - // RCR: DataStorage.cs PlayerPrefsDataStorage.Read — return GetString without the HasKey guard → RED (expected null, + // RCR: PlayerPrefsDataStorage.cs PlayerPrefsDataStorage.Read — return GetString without the HasKey guard → RED (expected null, // was string.Empty). 2026-10-17 public void Write_Read_Delete_RoundTrip() {