From 7daaf73b8529807de1ff89256d62afeb480d52f0 Mon Sep 17 00:00:00 2001 From: VyronLee Date: Mon, 6 Apr 2026 23:23:31 +0800 Subject: [PATCH] feat(core): modernize runtime foundations Consolidate lifecycle, pooling, interaction, logging, validation, and migration work into a single modernization change. Adds coverage, benchmark entrypoints, Unity validation support, and the associated planning and OpenSpec artifacts. --- .github/workflows/validation-baseline.yml | 62 ++++ .gitignore | 12 +- AGENTS.md | 174 ++++++++++ Assets/Editor.meta | 8 + Assets/Editor/BatchTestRunner.cs | 184 ++++++++++ Assets/Editor/BatchTestRunner.cs.meta | 11 + .../vFrame.Core.Tests.EditMode.Unity.asmdef | 19 ++ ...rame.Core.Tests.EditMode.Unity.asmdef.meta | 7 + .../EditMode/Base/BaseObjectDestroyTests.cs | 237 +++++++++++++ .../Base/BaseObjectDestroyTests.cs.meta | 11 + .../EditMode/Compression.meta | 8 + .../Compression/LZMACompressorTests.cs | 36 ++ .../Compression/LZMACompressorTests.cs.meta | 11 + .../EditMode/EventDispatchers.meta | 8 + .../EventDispatcherDecisionTests.cs | 97 ++++++ .../EventDispatcherDecisionTests.cs.meta | 11 + .../EventDispatcherInteractionTests.cs | 319 ++++++++++++++++++ .../EventDispatcherInteractionTests.cs.meta | 11 + .../EventDispatcherTypedInteractionTests.cs | 152 +++++++++ ...entDispatcherTypedInteractionTests.cs.meta | 11 + .../vFrame.Core.Tests/EditMode/Loggers.meta | 8 + .../EditMode/Loggers/LoggerRoutingTests.cs | 96 ++++++ .../Loggers/LoggerRoutingTests.cs.meta | 11 + .../EditMode/ObjectPools.meta | 8 + .../EditMode/ObjectPools/ObjectPoolTests.cs | 260 ++++++++++++++ .../ObjectPools/ObjectPoolTests.cs.meta | 11 + .../vFrame.Core.Tests.EditMode.asmdef | 3 +- .../CoroutinePoolCancellationTests.cs | 13 + .../PlayMode/SpawnPools.meta | 8 + .../SpawnPools/SpawnPoolsValidationTests.cs | 217 ++++++++++++ .../SpawnPoolsValidationTests.cs.meta | 11 + .../Runtime/Loggers/LogLevelExtension.cs | 4 +- .../Runtime/Loggers/UnityLogger.cs | 9 +- .../Runtime/Patch/Patcher.cs | 4 +- .../Runtime/SpawnPools/ISpawnPools.cs | 28 +- .../Runtime/SpawnPools/Pool.cs | 20 +- .../Runtime/SpawnPools/SpawnPools.cs | 34 +- .../Runtime/SpawnPools/SpawnPoolsContext.cs | 6 +- .../Runtime/SpawnPools/SpawnPoolsDebug.cs | 51 ++- .../Runtime/SpawnPools/SpawnPoolsSettings.cs | 3 +- Assets/vFrame.Core/Editor.meta | 8 + Assets/vFrame.Core/Editor/Benchmarks.meta | 8 + .../Editor/Benchmarks/CoreBenchmarkRunner.cs | 99 ++++++ .../Benchmarks/CoreBenchmarkRunner.cs.meta | 11 + .../InteractionDispatchBenchmarks.cs | 103 ++++++ .../InteractionDispatchBenchmarks.cs.meta | 11 + .../Editor/Benchmarks/ObjectPoolBenchmarks.cs | 52 +++ .../Benchmarks/ObjectPoolBenchmarks.cs.meta | 11 + .../Editor/Benchmarks/SpawnPoolsBenchmarks.cs | 129 +++++++ .../Benchmarks/SpawnPoolsBenchmarks.cs.meta | 11 + .../vFrame.Core.Benchmarks.Editor.asmdef | 18 + .../vFrame.Core.Benchmarks.Editor.asmdef.meta | 7 + Assets/vFrame.Core/Runtime/Base/BaseObject.cs | 131 ++++++- .../vFrame.Core/Runtime/Base/IDestroyable.cs | 11 +- Assets/vFrame.Core/Runtime/Base/ILifetime.cs | 25 ++ .../Runtime/Base/ILifetime.cs.meta | 11 + Assets/vFrame.Core/Runtime/Base/Lifetime.cs | 94 ++++++ .../vFrame.Core/Runtime/Base/Lifetime.cs.meta | 11 + .../AsynchronousBlockBasedCompression.cs | 4 +- .../BlockBasedCompression.cs | 4 +- .../Compressor/LZMA/LZMACompressor.cs | 24 +- .../EventDispatchers/DelegateEventListener.cs | 10 +- .../EventDispatchers/DelegateVoteListener.cs | 16 +- .../Runtime/EventDispatchers/Event.cs | 17 +- .../EventDispatchers/EventDispatcher.cs | 311 ++++++++++++++++- .../Runtime/EventDispatchers/EventExecutor.cs | 18 +- .../EventDispatchers/IEventDispatcher.cs | 52 ++- .../IInteractionDispatcher.cs | 43 +++ .../IInteractionDispatcher.cs.meta | 11 + .../EventDispatchers/IInteractionMessage.cs | 8 + .../IInteractionMessage.cs.meta | 11 + .../IInteractionSubscription.cs | 9 + .../IInteractionSubscription.cs.meta | 11 + .../InteractionSubscription.cs | 39 +++ .../InteractionSubscription.cs.meta | 11 + .../Runtime/EventDispatchers/Vote.cs | 17 +- .../Runtime/EventDispatchers/VoteExecutor.cs | 18 +- Assets/vFrame.Core/Runtime/Loggers/Logger.cs | 72 +++- .../Runtime/ObjectPools/IObjectPool.cs | 27 +- .../Runtime/ObjectPools/ObjectPool.cs | 163 ++++++++- .../ObjectPools/ObjectPoolDiagnostics.cs | 65 ++++ .../ObjectPools/ObjectPoolDiagnostics.cs.meta | 11 + CLAUDE.md | 152 +++++++++ ProjectSettings/ProjectVersion.txt | 4 +- ProjectSettings/SceneTemplateSettings.json | 121 +++++++ README.md | 102 +++++- README_en.md | 104 +++++- .../.openspec.yaml | 2 - .../design.md | 76 ----- .../proposal.md | 24 -- .../specs/runtime-reliability/spec.md | 41 --- .../tasks.md | 21 -- 92 files changed, 4246 insertions(+), 318 deletions(-) create mode 100644 .github/workflows/validation-baseline.yml create mode 100644 AGENTS.md create mode 100644 Assets/Editor.meta create mode 100644 Assets/Editor/BatchTestRunner.cs create mode 100644 Assets/Editor/BatchTestRunner.cs.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/Asynchronous/vFrame.Core.Tests.EditMode.Unity.asmdef create mode 100644 Assets/vFrame.Core.Tests/EditMode/Asynchronous/vFrame.Core.Tests.EditMode.Unity.asmdef.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/Base/BaseObjectDestroyTests.cs create mode 100644 Assets/vFrame.Core.Tests/EditMode/Base/BaseObjectDestroyTests.cs.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/Compression.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/Compression/LZMACompressorTests.cs create mode 100644 Assets/vFrame.Core.Tests/EditMode/Compression/LZMACompressorTests.cs.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/EventDispatchers.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherDecisionTests.cs create mode 100644 Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherDecisionTests.cs.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherInteractionTests.cs create mode 100644 Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherInteractionTests.cs.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherTypedInteractionTests.cs create mode 100644 Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherTypedInteractionTests.cs.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/Loggers.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/Loggers/LoggerRoutingTests.cs create mode 100644 Assets/vFrame.Core.Tests/EditMode/Loggers/LoggerRoutingTests.cs.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/ObjectPools.meta create mode 100644 Assets/vFrame.Core.Tests/EditMode/ObjectPools/ObjectPoolTests.cs create mode 100644 Assets/vFrame.Core.Tests/EditMode/ObjectPools/ObjectPoolTests.cs.meta create mode 100644 Assets/vFrame.Core.Tests/PlayMode/SpawnPools.meta create mode 100644 Assets/vFrame.Core.Tests/PlayMode/SpawnPools/SpawnPoolsValidationTests.cs create mode 100644 Assets/vFrame.Core.Tests/PlayMode/SpawnPools/SpawnPoolsValidationTests.cs.meta create mode 100644 Assets/vFrame.Core/Editor.meta create mode 100644 Assets/vFrame.Core/Editor/Benchmarks.meta create mode 100644 Assets/vFrame.Core/Editor/Benchmarks/CoreBenchmarkRunner.cs create mode 100644 Assets/vFrame.Core/Editor/Benchmarks/CoreBenchmarkRunner.cs.meta create mode 100644 Assets/vFrame.Core/Editor/Benchmarks/InteractionDispatchBenchmarks.cs create mode 100644 Assets/vFrame.Core/Editor/Benchmarks/InteractionDispatchBenchmarks.cs.meta create mode 100644 Assets/vFrame.Core/Editor/Benchmarks/ObjectPoolBenchmarks.cs create mode 100644 Assets/vFrame.Core/Editor/Benchmarks/ObjectPoolBenchmarks.cs.meta create mode 100644 Assets/vFrame.Core/Editor/Benchmarks/SpawnPoolsBenchmarks.cs create mode 100644 Assets/vFrame.Core/Editor/Benchmarks/SpawnPoolsBenchmarks.cs.meta create mode 100644 Assets/vFrame.Core/Editor/vFrame.Core.Benchmarks.Editor.asmdef create mode 100644 Assets/vFrame.Core/Editor/vFrame.Core.Benchmarks.Editor.asmdef.meta create mode 100644 Assets/vFrame.Core/Runtime/Base/ILifetime.cs create mode 100644 Assets/vFrame.Core/Runtime/Base/ILifetime.cs.meta create mode 100644 Assets/vFrame.Core/Runtime/Base/Lifetime.cs create mode 100644 Assets/vFrame.Core/Runtime/Base/Lifetime.cs.meta create mode 100644 Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionDispatcher.cs create mode 100644 Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionDispatcher.cs.meta create mode 100644 Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionMessage.cs create mode 100644 Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionMessage.cs.meta create mode 100644 Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionSubscription.cs create mode 100644 Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionSubscription.cs.meta create mode 100644 Assets/vFrame.Core/Runtime/EventDispatchers/InteractionSubscription.cs create mode 100644 Assets/vFrame.Core/Runtime/EventDispatchers/InteractionSubscription.cs.meta create mode 100644 Assets/vFrame.Core/Runtime/ObjectPools/ObjectPoolDiagnostics.cs create mode 100644 Assets/vFrame.Core/Runtime/ObjectPools/ObjectPoolDiagnostics.cs.meta create mode 100644 CLAUDE.md create mode 100644 ProjectSettings/SceneTemplateSettings.json delete mode 100644 openspec/changes/archive/2026-04-03-improve-runtime-stability/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-04-03-improve-runtime-stability/design.md delete mode 100644 openspec/changes/archive/2026-04-03-improve-runtime-stability/proposal.md delete mode 100644 openspec/changes/archive/2026-04-03-improve-runtime-stability/specs/runtime-reliability/spec.md delete mode 100644 openspec/changes/archive/2026-04-03-improve-runtime-stability/tasks.md diff --git a/.github/workflows/validation-baseline.yml b/.github/workflows/validation-baseline.yml new file mode 100644 index 0000000..2da3a35 --- /dev/null +++ b/.github/workflows/validation-baseline.yml @@ -0,0 +1,62 @@ +name: Validation-Baseline + +on: + pull_request: + push: + branches: + - master + +jobs: + core-editmode-tests: + name: Core Compile And EditMode Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run core EditMode tests + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + projectPath: . + unityVersion: 2022.3.62f3 + testMode: editmode + testFilter: vFrame.Core.Tests.EditMode + artifactsPath: test-results/editmode + + - name: Upload EditMode results + if: always() + uses: actions/upload-artifact@v4 + with: + name: editmode-test-results + path: test-results/editmode + + unity-playmode-tests: + name: Unity PlayMode Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run Unity PlayMode tests + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + projectPath: . + unityVersion: 2022.3.62f3 + testMode: playmode + testFilter: vFrame.Core.Tests.PlayMode + artifactsPath: test-results/playmode + + - name: Upload PlayMode results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playmode-test-results + path: test-results/playmode diff --git a/.gitignore b/.gitignore index 9e7de6f..759b8b3 100644 --- a/.gitignore +++ b/.gitignore @@ -157,5 +157,15 @@ sysinfo.txt # Custom .idea Assets/Plugins.meta - Assets/Plugins/Editor.meta + +# IDE / Editor +.vscode/ + +# AI tools +.opencode/ +.planning/ +openspec/ + +# Test output +TestResults/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ffd4e6c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,174 @@ +# AGENTS.md + +This repository contains two Unity packages inside one Unity project: +- `Assets/vFrame.Core`: engine-agnostic C# runtime library. +- `Assets/vFrame.Core.Unity`: Unity-specific runtime library depending on `vFrame.Core`. + +## Scope +- Scope: the entire repository rooted at `D:\Workspace\vFrame\vFrame.Core`. +- No Cursor rules were found in `.cursor/rules/` or `.cursorrules`. +- No Copilot instructions were found in `.github/copilot-instructions.md`. + +## Repository Facts +- Unity version: `2021.3.32f1` from `ProjectSettings/ProjectVersion.txt`. +- Package compatibility in both `package.json` files: `2018.4`. +- Runtime assemblies: + - `Assets/vFrame.Core/Runtime/vFrame.Core.asmdef` + - `Assets/vFrame.Core.Unity/Runtime/vFrame.Core.Unity.asmdef` +- No test assemblies or test source files are currently checked in. +- CI only publishes UPM branches; it does not run build, lint, or test jobs. + +## Directory Guide +- `Assets/vFrame.Core/Runtime`: core library code with no `UnityEngine` dependency. +- `Assets/vFrame.Core.Unity/Runtime`: Unity-facing runtime code. +- `.editorconfig`: authoritative formatting and naming rules. +- `.github/workflows/*.yml`: UPM publishing automation. + +## Workflow +- Make focused, minimal changes. +- Preserve public APIs unless the task explicitly requires API changes. +- Treat this as a library repo: avoid scene edits, editor-only assumptions, or app-specific behavior unless requested. +- Keep `vFrame.Core` free of `UnityEngine` references. + +## Build Commands +Use Unity batch mode for authoritative compilation. There is no dedicated non-Unity build script. +Set an editor path first if needed: + +```powershell +$UNITY="C:\Program Files\Unity\Hub\Editor\2021.3.32f1\Editor\Unity.exe" +``` + +Open/import the project once: + +```powershell +& $UNITY -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -logFile - +``` + +Compile scripts / validate project load: + +```powershell +& $UNITY -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -logFile - +``` + +Notes: +- Unity compilation errors surface in the batch-mode log. +- `vFrame.Core` and `vFrame.Core.Unity` are shipped as UPM packages via Git branches. + +## Test Commands +There are no tests checked into this repository today. If you add tests, use the Unity Test Framework CLI. +All EditMode tests: + +```powershell +& $UNITY -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform EditMode -logFile - -testResults "TestResults/editmode-results.xml" +``` + +All PlayMode tests: + +```powershell +& $UNITY -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform PlayMode -logFile - -testResults "TestResults/playmode-results.xml" +``` + +Single test by full name: + +```powershell +& $UNITY -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform EditMode -testFilter "Namespace.ClassName.TestMethod" -logFile - -testResults "TestResults/single-test.xml" +``` + +Single fixture / class: + +```powershell +& $UNITY -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform EditMode -testFilter "Namespace.ClassName" -logFile - -testResults "TestResults/single-fixture.xml" +``` + +Testing guidance: +- Prefer EditMode tests for `Assets/vFrame.Core` unless Unity runtime behavior is required. +- Use PlayMode tests only for code that genuinely depends on scene objects or frame execution. +- Put tests in dedicated test assemblies, not runtime folders. + +## Lint And Formatting +There is no standalone linter config such as Roslyn analyzers, StyleCop, or `dotnet format` committed in this repo. +Style sources: +- `.editorconfig` +- existing code in `Assets/vFrame.Core/Runtime` +- existing code in `Assets/vFrame.Core.Unity/Runtime` + +Follow `.editorconfig`, keep formatting consistent with adjacent files, prefer IDE formatting on touched files only, and do not mass-reformat unrelated files. + +## Formatting Rules +- Charset: `utf-8-bom`. +- Line endings: `crlf`. +- Indentation: 4 spaces for C#. +- Max line length: 120. +- Trim trailing whitespace: enabled. +- Final newline: disabled globally in `.editorconfig`; preserve file behavior. +- Namespace style: block-scoped namespaces, not file-scoped namespaces. +- Opening braces for types are on a new line; method braces usually stay on the declaration line. +- Braces are required for `if`, `foreach`, `for`, and `while`. + +## Imports +- Keep `using` directives at the top of the file, outside the namespace. +- Order usings with framework namespaces first, project namespaces after. +- Keep alias imports only when needed to resolve ambiguity, for example `Logger = vFrame.Core.Loggers.Logger`. +- Remove unused usings when touching a file. +- Do not introduce `global using` directives. + +## Naming Conventions +- Namespaces use PascalCase segments under `vFrame.Core...`. +- Public types, methods, properties, and constants use PascalCase. +- Private fields use `_camelCase`. +- Private static readonly fields use `_camelCase` per `.editorconfig`. +- Unity serialized fields should also use `_camelCase`. +- Interface names use the `I` prefix. +- Use `nameof(...)` for parameter names when throwing guard exceptions. + +## Types And Language Usage +- `var` is preferred when the type is apparent, matching `.editorconfig` and existing code. +- Use explicit types when clarity is better than `var`. +- Keep generics straightforward; this codebase already uses generic base types heavily. +- Do not introduce nullable reference type annotations unless the surrounding file or package is already using them consistently. +- Avoid adding modern syntax that materially changes style across a file unless required. +- Avoid LINQ in hot paths unless readability clearly outweighs allocation/perf cost. + +## Error Handling +- Prefer guard clauses near method entry. +- Reuse `vFrame.Core.Exceptions.ThrowHelper` where the repo already uses it. +- Throw precise exceptions for invalid input, unsupported enum values, invalid state, and type mismatches. +- Use `nameof(param)` instead of string literals for parameter names. +- Catch exceptions only when isolation and logging are part of the existing design. +- When catching exceptions, log enough context to diagnose the failing operation. +- Do not swallow exceptions silently. + +## State And Lifetime Patterns +- Respect the `BaseObject` lifecycle: `Create(...)`, `Destroy()`, `OnCreate(...)`, `OnDestroy()`. +- Call `ThrowIfDestroyed()`, `ThrowIfNotCreated()`, or `ThrowIfNotCreatedOrDestroyed()` where state invariants matter. +- Preserve the repo's pattern of using `try/finally` to update lifecycle flags. +- For pooled or reusable objects, clear owned state on destroy/recycle. + +## Collections And Allocation +- This library is allocation-conscious. +- Reuse the existing object pool abstractions instead of allocating temporary objects in hot code when a pool already exists. +- In mutation-sensitive loops, prefer index-based loops when collection contents may change during dispatch. +- Be careful with allocations from LINQ, closures, and temporary lists. + +## Logging +- Use the existing logging abstractions in `vFrame.Core.Loggers` instead of ad hoc logging APIs. +- In Unity-specific code, `UnityLogger` bridges library logs into `UnityEngine.Debug`. +- Prefer structured context in log messages. + +## Unity-Specific Guidance +- Keep `Assets/vFrame.Core` free from `UnityEngine` references. +- Put Unity-only behavior in `Assets/vFrame.Core.Unity`. +- Use existing Unity lifetime APIs and extensions before adding wrappers. +- Avoid editor-only APIs in runtime assemblies. + +## Change Hygiene +- Do not add new dependencies unless the task explicitly requires them. +- Do not rename files, namespaces, or public symbols without a concrete reason. +- Keep comments sparse; prefer self-explanatory code. +- Match the existing file header/comment style only when editing a file that already uses it. +- If you add tests, include the exact Unity CLI command used to run them in your handoff. + +## Validation Expectations For Agents +- For small changes, at minimum run Unity batch mode once to catch compilation errors. +- For test changes, run the narrowest relevant Unity test filter first, then broaden if needed. +- Report missing local prerequisites such as an unavailable Unity editor path. diff --git a/Assets/Editor.meta b/Assets/Editor.meta new file mode 100644 index 0000000..6448d16 --- /dev/null +++ b/Assets/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5775e43f0b2f8ce499fb971475a5b277 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/BatchTestRunner.cs b/Assets/Editor/BatchTestRunner.cs new file mode 100644 index 0000000..86c4cda --- /dev/null +++ b/Assets/Editor/BatchTestRunner.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEditor.TestTools.TestRunner.Api; +using UnityEngine; + +public static class BatchTestRunner +{ + public static void RunEditMode() { + Run(TestMode.EditMode); + } + + public static void RunPlayMode() { + Run(TestMode.PlayMode); + } + + private static void Run(TestMode mode) { + var arguments = Environment.GetCommandLineArgs(); + var outputPath = GetArgument(arguments, "-batchTestOutput"); + var testFilter = GetArgument(arguments, "-batchTestFilter"); + + if (string.IsNullOrWhiteSpace(outputPath)) { + throw new InvalidOperationException("Missing required -batchTestOutput argument."); + } + + Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? "."); + + var runner = ScriptableObject.CreateInstance(); + var callbacks = new BatchTestCallbacks(outputPath, mode, testFilter); + var filter = new Filter { + testMode = mode, + testNames = string.IsNullOrWhiteSpace(testFilter) ? null : new[] { testFilter } + }; + + runner.RegisterCallbacks(callbacks); + runner.Execute(new ExecutionSettings(filter)); + } + + private static string GetArgument(string[] arguments, string name) { + for (var i = 0; i < arguments.Length - 1; i++) { + if (string.Equals(arguments[i], name, StringComparison.OrdinalIgnoreCase)) { + return arguments[i + 1]; + } + } + + return null; + } + + private sealed class BatchTestCallbacks : ICallbacks + { + private readonly string _outputPath; + + private readonly TestMode _mode; + + private readonly string _testFilter; + + public BatchTestCallbacks(string outputPath, TestMode mode, string testFilter) { + _outputPath = outputPath; + _mode = mode; + _testFilter = testFilter; + } + + public int priority => 0; + + public void RunStarted(ITestAdaptor testsToRun) { + UnityEngine.Debug.Log($"[BatchTestRunner] Started {_mode} run. Filter: {_testFilter ?? ""}"); + } + + public void RunFinished(ITestResultAdaptor result) { + var payload = new BatchTestResult { + Mode = _mode.ToString(), + Filter = _testFilter, + Name = result.Name, + PassCount = result.PassCount, + FailCount = result.FailCount, + SkipCount = result.SkipCount, + InconclusiveCount = result.InconclusiveCount, + ResultState = result.ResultState, + Message = result.Message, + Duration = result.Duration, + TestCount = result.Test != null ? CountTests(result.Test) : 0, + Failures = CollectFailures(result) + }; + + File.WriteAllText(_outputPath, JsonUtility.ToJson(payload, true)); + UnityEngine.Debug.Log($"[BatchTestRunner] Wrote results to {_outputPath}"); + EditorApplication.Exit(result.FailCount > 0 ? 1 : 0); + } + + public void TestStarted(ITestAdaptor test) { + } + + public void TestFinished(ITestResultAdaptor result) { + } + + private static int CountTests(ITestAdaptor test) { + if (test == null) { + return 0; + } + + if (test.IsTestAssembly || test.IsSuite) { + var count = 0; + + foreach (var child in test.Children) { + count += CountTests(child); + } + + return count; + } + + return 1; + } + + private static FailureInfo[] CollectFailures(ITestResultAdaptor result) { + var failures = new List(); + CollectFailuresRecursive(result, failures); + return failures.ToArray(); + } + + private static void CollectFailuresRecursive(ITestResultAdaptor result, List failures) { + if (result == null) { + return; + } + + if (result.FailCount > 0 && result.Test != null && !result.HasChildren) { + failures.Add(new FailureInfo { + Name = result.Name, + FullName = result.Test.FullName, + Message = result.Message, + StackTrace = result.StackTrace + }); + } + + if (!result.HasChildren || result.Children == null) { + return; + } + + foreach (var child in result.Children) { + CollectFailuresRecursive(child, failures); + } + } + } + + [Serializable] + private sealed class BatchTestResult + { + public string Mode; + + public string Filter; + + public string Name; + + public int TestCount; + + public int PassCount; + + public int FailCount; + + public int SkipCount; + + public int InconclusiveCount; + + public string ResultState; + + public string Message; + + public double Duration; + + public FailureInfo[] Failures; + } + + [Serializable] + private sealed class FailureInfo + { + public string Name; + + public string FullName; + + public string Message; + + public string StackTrace; + } +} diff --git a/Assets/Editor/BatchTestRunner.cs.meta b/Assets/Editor/BatchTestRunner.cs.meta new file mode 100644 index 0000000..b175323 --- /dev/null +++ b/Assets/Editor/BatchTestRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8a9407df9af9f3145b1b5a6babd6536e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/Asynchronous/vFrame.Core.Tests.EditMode.Unity.asmdef b/Assets/vFrame.Core.Tests/EditMode/Asynchronous/vFrame.Core.Tests.EditMode.Unity.asmdef new file mode 100644 index 0000000..f9245a8 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/Asynchronous/vFrame.Core.Tests.EditMode.Unity.asmdef @@ -0,0 +1,19 @@ +{ + "name": "vFrame.Core.Tests.EditMode.Unity", + "references": [ + "vFrame.Core", + "vFrame.Core.Unity" + ], + "optionalUnityReferences": [ + "TestAssemblies" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [] +} diff --git a/Assets/vFrame.Core.Tests/EditMode/Asynchronous/vFrame.Core.Tests.EditMode.Unity.asmdef.meta b/Assets/vFrame.Core.Tests/EditMode/Asynchronous/vFrame.Core.Tests.EditMode.Unity.asmdef.meta new file mode 100644 index 0000000..eb05569 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/Asynchronous/vFrame.Core.Tests.EditMode.Unity.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f9f78df801f44ba9ba1dd71e76ad7016 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/Base/BaseObjectDestroyTests.cs b/Assets/vFrame.Core.Tests/EditMode/Base/BaseObjectDestroyTests.cs new file mode 100644 index 0000000..033f22d --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/Base/BaseObjectDestroyTests.cs @@ -0,0 +1,237 @@ +using System; +using NUnit.Framework; +using vFrame.Core.Base; + +namespace vFrame.Core.Tests.EditMode.Base +{ + public class BaseObjectDestroyTests + { + [Test] + public void Destroy_ClearsCreatedStateAndMarksDestroyed_WhenObjectWasCreated() { + var sut = new TrackingBaseObject(); + + sut.Create(); + Assert.DoesNotThrow(() => sut.AssertNotDestroyed()); + + sut.Destroy(); + + Assert.That(sut.OnDestroyCallCount, Is.EqualTo(1)); + Assert.That(sut.Created, Is.False); + Assert.That(sut.Destroyed, Is.True); + Assert.Throws(() => sut.AssertNotDestroyed()); + } + + [Test] + public void Destroy_CallsOnDestroyOnlyOnce_WhenInvokedMultipleTimes() { + var sut = new TrackingBaseObject(); + + sut.Create(); + + sut.Destroy(); + sut.Destroy(); + + Assert.That(sut.OnDestroyCallCount, Is.EqualTo(1)); + Assert.That(sut.Created, Is.False); + Assert.That(sut.Destroyed, Is.True); + } + + [Test] + public void Destroy_TerminatesOwnedChildState_WhenOwnerIsDestroyed() { + var child = new TrackingBaseObject(); + var sut = new OwningBaseObject(child); + + child.Create(); + sut.Create(); + + sut.Destroy(); + + Assert.That(child.OnDestroyCallCount, Is.EqualTo(1)); + Assert.That(child.Created, Is.False); + Assert.That(child.Destroyed, Is.True); + } + + [Test] + public void Destroy_TerminatesGroupedOwnedState_ThroughUnifiedCleanupBoundary() { + var first = new TrackingBaseObject(); + var second = new TrackingBaseObject(); + var sut = new GroupOwningBaseObject(); + + first.Create(); + second.Create(); + sut.Create(); + sut.Add(first); + sut.Add(second); + + sut.Destroy(); + + Assert.That(first.OnDestroyCallCount, Is.EqualTo(1)); + Assert.That(second.OnDestroyCallCount, Is.EqualTo(1)); + Assert.That(first.Destroyed, Is.True); + Assert.That(second.Destroyed, Is.True); + } + + [Test] + public void Destroy_RemainsTerminal_AndDoesNotAllowRecreate() { + var sut = new TrackingBaseObject(); + + sut.Create(); + sut.Destroy(); + + Assert.Throws(() => sut.Create()); + Assert.That(sut.OnCreateCallCount, Is.EqualTo(1)); + Assert.That(sut.OnDestroyCallCount, Is.EqualTo(1)); + Assert.That(sut.Created, Is.False); + Assert.That(sut.Destroyed, Is.True); + } + + [Test] + public void Destroy_KeepsDestroyedState_WhenOwnedCleanupThrows() { + var owned = new ThrowingDestroyable(); + var sut = new OwningBaseObject(owned); + + sut.Create(); + + var exception = Assert.Throws(() => sut.Destroy()); + + Assert.That(exception.Message, Is.EqualTo(ThrowingDestroyable.ErrorMessage)); + Assert.That(sut.Created, Is.False); + Assert.That(sut.Destroyed, Is.True); + } + + [Test] + public void Destroy_TerminatesChildLifetimeThroughGroupedCleanupBoundary() { + var child = new TrackingDestroyable(); + var sut = new ChildLifetimeOwningBaseObject(); + + sut.Create(); + sut.AddToChild(child); + + sut.Destroy(); + + Assert.That(child.DestroyCallCount, Is.EqualTo(1)); + Assert.That(child.Destroyed, Is.True); + } + + [Test] + public void Lifetime_Destroy_ExecutesSameLifetimeActionsInReverseOrder() { + var lifetime = new Lifetime(); + var calls = new System.Collections.Generic.List(); + + lifetime.Add(() => calls.Add("first")); + lifetime.Add(() => calls.Add("second")); + + lifetime.Destroy(); + + Assert.That(calls, Is.EqualTo(new[] { "second", "first" })); + } + + [Test] + public void Lifetime_Destroy_TerminatesSameLifetimeOwnedDestroyables() { + var lifetime = new Lifetime(); + var destroyable = new TrackingDestroyable(); + + lifetime.Add(destroyable); + + lifetime.Destroy(); + + Assert.That(destroyable.DestroyCallCount, Is.EqualTo(1)); + Assert.That(destroyable.Destroyed, Is.True); + } + + private sealed class TrackingBaseObject : BaseObject + { + public int OnCreateCallCount { get; private set; } + + public int OnDestroyCallCount { get; private set; } + + public void AssertNotDestroyed() { + ThrowIfDestroyed(); + } + + protected override void OnCreate() { + OnCreateCallCount++; + } + + protected override void OnDestroy() { + OnDestroyCallCount++; + } + } + + private sealed class OwningBaseObject : BaseObject + { + private readonly IDestroyable _owned; + + public OwningBaseObject(IDestroyable owned) { + _owned = owned; + } + + protected override void OnCreate() { + Own(_owned); + } + + protected override void OnDestroy() { } + } + + private sealed class GroupOwningBaseObject : BaseObject + { + private ILifetime _group; + + public void Add(IDestroyable destroyable) { + _group.Add(destroyable); + } + + protected override void OnCreate() { + _group = Lifetime.CreateChild(); + } + + protected override void OnDestroy() { } + } + + private sealed class ChildLifetimeOwningBaseObject : BaseObject + { + private ILifetime _child; + + public void AddToChild(IDestroyable destroyable) { + _child.Add(destroyable); + } + + protected override void OnCreate() { + _child = Lifetime.CreateChild(); + } + + protected override void OnDestroy() { } + } + + private sealed class ThrowingDestroyable : IDestroyable + { + public const string ErrorMessage = "Owned cleanup failed."; + + public bool Destroyed { get; private set; } + + public void Destroy() { + Destroyed = true; + throw new InvalidOperationException(ErrorMessage); + } + + public void Dispose() { + Destroy(); + } + } + + private sealed class TrackingDestroyable : IDestroyable + { + public bool Destroyed { get; private set; } + + public int DestroyCallCount { get; private set; } + + public void Destroy() { + DestroyCallCount++; + Destroyed = true; + } + + public void Dispose() { + Destroy(); + } + } + } +} diff --git a/Assets/vFrame.Core.Tests/EditMode/Base/BaseObjectDestroyTests.cs.meta b/Assets/vFrame.Core.Tests/EditMode/Base/BaseObjectDestroyTests.cs.meta new file mode 100644 index 0000000..b317213 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/Base/BaseObjectDestroyTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f3028d0a3534efba5787f81f162ee16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/Compression.meta b/Assets/vFrame.Core.Tests/EditMode/Compression.meta new file mode 100644 index 0000000..d6bb841 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/Compression.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f821818cbc115fb47bb3ae5c73875c6f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/Compression/LZMACompressorTests.cs b/Assets/vFrame.Core.Tests/EditMode/Compression/LZMACompressorTests.cs new file mode 100644 index 0000000..2f77069 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/Compression/LZMACompressorTests.cs @@ -0,0 +1,36 @@ +using System.IO; +using NUnit.Framework; +using vFrame.Core.Compression; + +namespace vFrame.Core.Tests.EditMode.Compression +{ + public class LZMACompressorTests + { + [Test] + public void CompressAndDecompress_ReusesProgressHelperWithoutLeakingPreviousCallback() { + var compressor = new LZMACompressor(); + compressor.Create(new LZMACompressorOptions { + DictionarySize = LZMACompressorOptions.LZMADictionarySize.Small, + Speed = LZMACompressorOptions.LZMASpeed.Fast + }); + + using var input = new MemoryStream(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + using var compressed = new MemoryStream(); + using var output = new MemoryStream(); + + var firstProgressCount = 0; + compressor.Compress(input, compressed, (inSize, outSize) => firstProgressCount++); + var firstProgressCountAfterCompress = firstProgressCount; + + compressed.Position = 0; + + var secondProgressCount = 0; + compressor.Decompress(compressed, output, (inSize, outSize) => secondProgressCount++); + + var bytes = output.ToArray(); + + Assert.That(bytes, Is.EqualTo(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 })); + Assert.That(firstProgressCount, Is.EqualTo(firstProgressCountAfterCompress)); + } + } +} diff --git a/Assets/vFrame.Core.Tests/EditMode/Compression/LZMACompressorTests.cs.meta b/Assets/vFrame.Core.Tests/EditMode/Compression/LZMACompressorTests.cs.meta new file mode 100644 index 0000000..0fb3c1a --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/Compression/LZMACompressorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 23a4d819a2bc5064e9c333ee70835b9a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/EventDispatchers.meta b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers.meta new file mode 100644 index 0000000..a9b783d --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3e51698c127649d0a95a7a869d3ea46b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherDecisionTests.cs b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherDecisionTests.cs new file mode 100644 index 0000000..0764d92 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherDecisionTests.cs @@ -0,0 +1,97 @@ +using NUnit.Framework; +using vFrame.Core.Base; +using vFrame.Core.EventDispatchers; + +namespace vFrame.Core.Tests.EditMode.EventDispatchers +{ + public class EventDispatcherDecisionTests + { + [Test] + public void DispatchDecision_UsesVoteSemanticAliasAndShortCircuitsOnRejection() { + var dispatcher = new EventDispatcher(); + dispatcher.Create(); + + var firstListener = new CountingVoteListener(true); + var secondListener = new CountingVoteListener(false); + var thirdListener = new CountingVoteListener(true); + + dispatcher.AddDecisionListener(firstListener, 1001); + dispatcher.AddDecisionListener(secondListener, 1001); + dispatcher.AddDecisionListener(thirdListener, 1001); + + var passed = dispatcher.DispatchDecision(1001, "context"); + + Assert.That(passed, Is.False); + Assert.That(firstListener.CallCount, Is.EqualTo(1)); + Assert.That(secondListener.CallCount, Is.EqualTo(1)); + Assert.That(thirdListener.CallCount, Is.EqualTo(0)); + } + + [Test] + public void AddDecisionListener_ReturnsHandleRemovableThroughDecisionAlias() { + var dispatcher = new EventDispatcher(); + dispatcher.Create(); + + var listener = new CountingVoteListener(true); + var handle = dispatcher.AddDecisionListener(listener, 1002); + + var removed = dispatcher.RemoveDecisionListener(handle); + var passed = dispatcher.DispatchDecision(1002); + + Assert.That(removed, Is.SameAs(listener)); + Assert.That(passed, Is.True); + Assert.That(listener.CallCount, Is.EqualTo(0)); + } + + [Test] + public void DispatchDecision_RemainsDistinctFromTypedMessageDispatch() { + var dispatcher = new EventDispatcher(); + dispatcher.Create(); + + var decisionListener = new CountingVoteListener(true); + var typedCount = 0; + + dispatcher.AddDecisionListener(decisionListener, 1003); + dispatcher.Subscribe(_ => typedCount++); + + var passed = dispatcher.DispatchDecision(1003, "decision"); + dispatcher.Publish(new DecisionMessage()); + + Assert.That(passed, Is.True); + Assert.That(decisionListener.CallCount, Is.EqualTo(1)); + Assert.That(typedCount, Is.EqualTo(1)); + } + + [Test] + public void Destroy_ClearsDecisionListeners() { + var dispatcher = new EventDispatcher(); + dispatcher.Create(); + + dispatcher.AddDecisionListener(new CountingVoteListener(true), 1004); + + dispatcher.Destroy(); + + Assert.That(() => dispatcher.DispatchDecision(1004), Throws.TypeOf()); + } + + private sealed class CountingVoteListener : IVoteListener + { + private readonly bool _result; + + public CountingVoteListener(bool result) { + _result = result; + } + + public int CallCount { get; private set; } + + public bool OnVote(IVote e) { + CallCount++; + return _result; + } + } + + private sealed class DecisionMessage + { + } + } +} diff --git a/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherDecisionTests.cs.meta b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherDecisionTests.cs.meta new file mode 100644 index 0000000..b3f670b --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherDecisionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 35bfddfa62e64c24a25f6056ca7f4259 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherInteractionTests.cs b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherInteractionTests.cs new file mode 100644 index 0000000..20c70ae --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherInteractionTests.cs @@ -0,0 +1,319 @@ +using NUnit.Framework; +using vFrame.Core.Base; +using vFrame.Core.EventDispatchers; +using vFrame.Core.Exceptions; + +namespace vFrame.Core.Tests.EditMode.EventDispatchers +{ + public class EventDispatcherInteractionTests + { + private const int EventId = 101; + private const int VoteId = 202; + + [Test] + public void DispatchEvent_InvokesCurrentListeners_AndSkipsRemovedListenerOnNextDispatch() { + var dispatcher = CreateDispatcher(); + var first = new CountingEventListener(); + var second = new CountingEventListener(); + + var firstHandle = dispatcher.AddEventListener(first, EventId); + dispatcher.AddEventListener(second, EventId); + + dispatcher.DispatchEvent(EventId, "first"); + var removed = dispatcher.RemoveEventListener(firstHandle); + dispatcher.DispatchEvent(EventId, "second"); + + Assert.That(removed, Is.SameAs(first)); + Assert.That(first.Count, Is.EqualTo(1)); + Assert.That(second.Count, Is.EqualTo(2)); + Assert.That(second.LastContext, Is.EqualTo("second")); + + dispatcher.Destroy(); + } + + [Test] + public void DispatchEvent_AllowsBareSubscriptionToRemainActive_UntilExplicitRemoval() { + var dispatcher = CreateDispatcher(); + var owner = new SubscriptionOwner(dispatcher); + var bare = new CountingEventListener(); + + owner.Create(); + var bareHandle = dispatcher.AddEventListener(bare, EventId); + owner.Bind(EventId); + + owner.Destroy(); + dispatcher.DispatchEvent(EventId, 7); + dispatcher.RemoveEventListener(bareHandle); + dispatcher.DispatchEvent(EventId, 8); + + Assert.That(owner.Listener.Count, Is.EqualTo(0)); + Assert.That(bare.Count, Is.EqualTo(1)); + Assert.That(bare.LastContext, Is.EqualTo(7)); + Assert.That(dispatcher.GetEventExecutorCount(), Is.EqualTo(0)); + + dispatcher.Destroy(); + } + + [Test] + public void DispatchEvent_RetainsCompatibilityPath_WhileTypedMessagesStaySeparate() { + var dispatcher = CreateDispatcher(); + var numeric = new CountingEventListener(); + var typedCount = 0; + + dispatcher.AddEventListener(numeric, EventId); + dispatcher.Subscribe(_ => typedCount++); + + dispatcher.DispatchEvent(EventId, "legacy"); + dispatcher.Publish(new CompatibilityMessage()); + + Assert.That(numeric.Count, Is.EqualTo(1)); + Assert.That(numeric.LastContext, Is.EqualTo("legacy")); + Assert.That(typedCount, Is.EqualTo(1)); + Assert.That(dispatcher.GetEventExecutorCount(), Is.EqualTo(1)); + Assert.That(dispatcher.GetInteractionSubscriptionCount(), Is.EqualTo(1)); + + dispatcher.Destroy(); + } + + [Test] + public void GetDiagnostics_ReflectsCurrentObservableCounts() { + var dispatcher = CreateDispatcher(); + var numeric = new CountingEventListener(); + + dispatcher.AddEventListener(numeric, EventId); + var subscription = dispatcher.Subscribe(_ => { }); + dispatcher.AddVoteListener(new CountingVoteListener(true), VoteId); + + var diagnostics = dispatcher.GetDiagnostics(); + + Assert.That(diagnostics.EventExecutorCount, Is.EqualTo(1)); + Assert.That(diagnostics.InteractionSubscriptionCount, Is.EqualTo(1)); + Assert.That(diagnostics.VoteExecutorCount, Is.EqualTo(1)); + + dispatcher.Unsubscribe(subscription); + dispatcher.Destroy(); + } + + [Test] + public void Destroy_RemovesOwnerBoundSubscription_WhenOwnerLifetimeEnds() { + var dispatcher = CreateDispatcher(); + var owner = new SubscriptionOwner(dispatcher); + + owner.Create(); + owner.Bind(EventId); + dispatcher.DispatchEvent(EventId); + + owner.Destroy(); + dispatcher.DispatchEvent(EventId); + + Assert.That(owner.Listener.Count, Is.EqualTo(1)); + Assert.That(dispatcher.GetEventExecutorCount(), Is.EqualTo(0)); + + dispatcher.Destroy(); + } + + [Test] + public void Destroy_RemovesLifetimeBoundSubscription_WhenGroupedLifetimeEnds() { + var dispatcher = CreateDispatcher(); + var owner = new LifetimeOwnedSubscriptionOwner(dispatcher); + + owner.Create(); + owner.Bind(EventId); + dispatcher.DispatchEvent(EventId, "before"); + + owner.EndGroup(); + dispatcher.DispatchEvent(EventId, "after"); + + Assert.That(owner.Listener.Count, Is.EqualTo(1)); + Assert.That(owner.Listener.LastContext, Is.EqualTo("before")); + Assert.That(dispatcher.GetEventExecutorCount(), Is.EqualTo(0)); + + owner.Destroy(); + dispatcher.Destroy(); + } + + [Test] + public void Destroy_ClearsOwnedState_AndPreventsFurtherUse() { + var dispatcher = CreateDispatcher(); + var typedCount = 0; + + dispatcher.AddEventListener(new CountingEventListener(), EventId); + dispatcher.AddVoteListener(new CountingVoteListener(true), VoteId); + dispatcher.Subscribe(_ => typedCount++); + + dispatcher.Destroy(); + + Assert.That(dispatcher.Destroyed, Is.True); + Assert.That(() => dispatcher.Publish(new CompatibilityMessage()), Throws.TypeOf()); + Assert.That(() => dispatcher.DispatchEvent(EventId), Throws.TypeOf()); + Assert.That(() => dispatcher.DispatchVote(VoteId), Throws.TypeOf()); + Assert.That(typedCount, Is.EqualTo(0)); + } + + [Test] + public void DispatchVote_StopsAfterFirstRejectingListener() { + var dispatcher = CreateDispatcher(); + var approving = new CountingVoteListener(true); + var rejecting = new CountingVoteListener(false); + var trailing = new CountingVoteListener(true); + + dispatcher.AddVoteListener(approving, VoteId); + dispatcher.AddVoteListener(rejecting, VoteId); + dispatcher.AddVoteListener(trailing, VoteId); + + var passed = dispatcher.DispatchVote(VoteId, "decision"); + + Assert.That(passed, Is.False); + Assert.That(approving.Count, Is.EqualTo(1)); + Assert.That(rejecting.Count, Is.EqualTo(1)); + Assert.That(trailing.Count, Is.EqualTo(0)); + Assert.That(rejecting.LastContext, Is.EqualTo("decision")); + + dispatcher.Destroy(); + } + + [Test] + public void DispatchVote_ReturnsTrue_WhenNoVoteListenersAreRegistered() { + var dispatcher = CreateDispatcher(); + + var passed = dispatcher.DispatchVote(VoteId, "none"); + + Assert.That(passed, Is.True); + + dispatcher.Destroy(); + } + + [Test] + public void RemoveEventListener_ClearsPooledDelegateBeforeReuse() { + var dispatcher = CreateDispatcher(); + var firstCount = 0; + var secondCount = 0; + + var firstHandle = dispatcher.AddEventListener(_ => firstCount++, EventId); + dispatcher.RemoveEventListener(firstHandle); + + dispatcher.AddEventListener(_ => secondCount++, EventId); + dispatcher.DispatchEvent(EventId, "reused"); + + Assert.That(firstCount, Is.EqualTo(0)); + Assert.That(secondCount, Is.EqualTo(1)); + + dispatcher.Destroy(); + } + + [Test] + public void RemoveVoteListener_ClearsPooledDelegateBeforeReuse() { + var dispatcher = CreateDispatcher(); + var firstCount = 0; + var secondCount = 0; + + var firstHandle = dispatcher.AddVoteListener(_ => { + firstCount++; + return true; + }, VoteId); + dispatcher.RemoveVoteListener(firstHandle); + + dispatcher.AddVoteListener(_ => { + secondCount++; + return true; + }, VoteId); + + var passed = dispatcher.DispatchVote(VoteId, "reused"); + + Assert.That(passed, Is.True); + Assert.That(firstCount, Is.EqualTo(0)); + Assert.That(secondCount, Is.EqualTo(1)); + + dispatcher.Destroy(); + } + + private static EventDispatcher CreateDispatcher() { + var dispatcher = new EventDispatcher(); + dispatcher.Create(); + return dispatcher; + } + + private sealed class CountingEventListener : IEventListener + { + public int Count { get; private set; } + + public object LastContext { get; private set; } + + public void OnEvent(IEvent e) { + Count++; + LastContext = e.GetContext(); + } + } + + private sealed class CountingVoteListener : IVoteListener + { + private readonly bool _result; + + public CountingVoteListener(bool result) { + _result = result; + } + + public int Count { get; private set; } + + public object LastContext { get; private set; } + + public bool OnVote(IVote e) { + Count++; + LastContext = e.GetContext(); + return _result; + } + } + + private sealed class CompatibilityMessage + { + } + + private sealed class SubscriptionOwner : BaseObject + { + private readonly EventDispatcher _dispatcher; + + public SubscriptionOwner(EventDispatcher dispatcher) { + _dispatcher = dispatcher; + } + + public CountingEventListener Listener { get; } = new CountingEventListener(); + + public void Bind(int eventId) { + var handle = _dispatcher.AddEventListener(Listener, eventId); + Own(() => _dispatcher.RemoveEventListener(handle)); + } + + protected override void OnCreate() { + } + + protected override void OnDestroy() { } + } + + private sealed class LifetimeOwnedSubscriptionOwner : BaseObject + { + private readonly EventDispatcher _dispatcher; + private ILifetime _group; + + public LifetimeOwnedSubscriptionOwner(EventDispatcher dispatcher) { + _dispatcher = dispatcher; + } + + public CountingEventListener Listener { get; } = new CountingEventListener(); + + public void EndGroup() { + _group.Destroy(); + } + + public void Bind(int eventId) { + var handle = _dispatcher.AddEventListener(Listener, eventId); + _group.Add(() => _dispatcher.RemoveEventListener(handle)); + } + + protected override void OnCreate() { + _group = Lifetime.CreateChild(); + } + + protected override void OnDestroy() { } + } + } +} diff --git a/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherInteractionTests.cs.meta b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherInteractionTests.cs.meta new file mode 100644 index 0000000..949446e --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherInteractionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ef2eeeb93cb941d3b237bc2fbc4647d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherTypedInteractionTests.cs b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherTypedInteractionTests.cs new file mode 100644 index 0000000..457f1b6 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherTypedInteractionTests.cs @@ -0,0 +1,152 @@ +using NUnit.Framework; +using vFrame.Core.Base; +using vFrame.Core.EventDispatchers; +using vFrame.Core.Exceptions; + +namespace vFrame.Core.Tests.EditMode.EventDispatchers +{ + public class EventDispatcherTypedInteractionTests + { + [Test] + public void Publish_InvokesTypedSubscribers() { + var dispatcher = CreateDispatcher(); + var received = 0; + + dispatcher.Subscribe(message => received = message.Value); + dispatcher.Publish(new TestMessage { Value = 7 }); + + Assert.That(received, Is.EqualTo(7)); + Assert.That(dispatcher.GetInteractionSubscriptionCount(), Is.EqualTo(1)); + } + + [Test] + public void OwnerDestroy_RemovesOwnerBoundTypedSubscription() { + var dispatcher = CreateDispatcher(); + var owner = new SubscriptionOwner(); + owner.Create(); + + dispatcher.Subscribe(owner.OnMessage, owner); + dispatcher.Publish(new TestMessage { Value = 1 }); + owner.Destroy(); + dispatcher.Publish(new TestMessage { Value = 2 }); + + Assert.That(owner.Count, Is.EqualTo(1)); + Assert.That(dispatcher.GetInteractionSubscriptionCount(), Is.EqualTo(0)); + } + + [Test] + public void LifetimeDestroy_RemovesLifetimeBoundTypedSubscription() { + var dispatcher = CreateDispatcher(); + var owner = new GroupOwner(); + owner.Create(); + + dispatcher.Subscribe(owner.OnMessage, owner.Group); + dispatcher.Publish(new TestMessage { Value = 1 }); + owner.Group.Destroy(); + dispatcher.Publish(new TestMessage { Value = 2 }); + + Assert.That(owner.Count, Is.EqualTo(1)); + Assert.That(dispatcher.GetInteractionSubscriptionCount(), Is.EqualTo(0)); + } + + [Test] + public void BareTypedSubscription_RemainsUntilExplicitUnsubscribe() { + var dispatcher = CreateDispatcher(); + var count = 0; + + var subscription = dispatcher.Subscribe(_ => count++); + dispatcher.Publish(new TestMessage()); + dispatcher.Unsubscribe(subscription); + dispatcher.Publish(new TestMessage()); + + Assert.That(count, Is.EqualTo(1)); + Assert.That(dispatcher.GetInteractionSubscriptionCount(), Is.EqualTo(0)); + } + + [Test] + public void BareTypedSubscription_RemainsActive_AfterBoundSubscriptionsEnd_UntilExplicitUnsubscribe() { + var dispatcher = CreateDispatcher(); + var owner = new SubscriptionOwner(); + var groupOwner = new GroupOwner(); + var bareCount = 0; + + owner.Create(); + groupOwner.Create(); + + var bare = dispatcher.Subscribe(_ => bareCount++); + dispatcher.Subscribe(owner.OnMessage, owner); + dispatcher.Subscribe(groupOwner.OnMessage, groupOwner.Group); + + dispatcher.Publish(new TestMessage { Value = 1 }); + + owner.Destroy(); + groupOwner.Group.Destroy(); + dispatcher.Publish(new TestMessage { Value = 2 }); + + dispatcher.Unsubscribe(bare); + dispatcher.Publish(new TestMessage { Value = 3 }); + + Assert.That(owner.Count, Is.EqualTo(1)); + Assert.That(groupOwner.Count, Is.EqualTo(1)); + Assert.That(bareCount, Is.EqualTo(2)); + Assert.That(dispatcher.GetInteractionSubscriptionCount(), Is.EqualTo(0)); + } + + [Test] + public void Destroy_RemovesBareTypedSubscription() { + var dispatcher = CreateDispatcher(); + var count = 0; + + dispatcher.Subscribe(_ => count++); + dispatcher.Destroy(); + + Assert.That(dispatcher.Destroyed, Is.True); + Assert.That(count, Is.EqualTo(0)); + Assert.That(() => dispatcher.GetInteractionSubscriptionCount(), Throws.TypeOf()); + } + + private static EventDispatcher CreateDispatcher() { + var dispatcher = new EventDispatcher(); + dispatcher.Create(); + return dispatcher; + } + + private sealed class TestMessage + { + public int Value { get; set; } + } + + private sealed class SubscriptionOwner : BaseObject + { + public int Count { get; private set; } + + public void OnMessage(TestMessage message) { + Count += message.Value; + } + + protected override void OnCreate() { + } + + protected override void OnDestroy() { + } + } + + private sealed class GroupOwner : BaseObject + { + public int Count { get; private set; } + + public ILifetime Group { get; private set; } + + public void OnMessage(TestMessage message) { + Count += message.Value; + } + + protected override void OnCreate() { + Group = Lifetime.CreateChild(); + } + + protected override void OnDestroy() { + } + } + } +} diff --git a/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherTypedInteractionTests.cs.meta b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherTypedInteractionTests.cs.meta new file mode 100644 index 0000000..45b0f25 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/EventDispatchers/EventDispatcherTypedInteractionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6306c3543c8f484bb1001cb5d2ded033 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/Loggers.meta b/Assets/vFrame.Core.Tests/EditMode/Loggers.meta new file mode 100644 index 0000000..4f70cba --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/Loggers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6c07d2ba88257e44b8a7fd174b9efc8f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/Loggers/LoggerRoutingTests.cs b/Assets/vFrame.Core.Tests/EditMode/Loggers/LoggerRoutingTests.cs new file mode 100644 index 0000000..a7330f4 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/Loggers/LoggerRoutingTests.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using NUnit.Framework; +using vFrame.Core.Loggers; + +namespace vFrame.Core.Tests.EditMode.Loggers +{ + public class LoggerRoutingTests + { + private readonly List _registeredSinks = new List(); + + [SetUp] + public void SetUp() { + Logger.Close(); + Logger.LogLevel = LogLevelDef.Debug; + Logger.LogTagFormatter = Logger.DefaultTagFormatter; + Logger.LogFormatMask = 0; + } + + [TearDown] + public void TearDown() { + foreach (var sink in _registeredSinks) { + Logger.RemoveSink(sink); + } + + _registeredSinks.Clear(); + Logger.Close(); + } + + [Test] + public void Log_ReachesAllRegisteredSinks() { + var first = RegisterSink(); + var second = RegisterSink(); + + Logger.Info("fanout"); + + Assert.That(first.Entries.Count, Is.EqualTo(1)); + Assert.That(second.Entries.Count, Is.EqualTo(1)); + Assert.That(Logger.GetSinkCount(), Is.EqualTo(2)); + } + + [Test] + public void RemovingOneSink_DoesNotBreakOtherRegisteredSinks() { + var first = RegisterSink(); + var second = RegisterSink(); + + Logger.RemoveSink(first); + _registeredSinks.Remove(first); + + Logger.Warning("still-routed"); + + Assert.That(first.Entries.Count, Is.EqualTo(0)); + Assert.That(second.Entries.Count, Is.EqualTo(1)); + Assert.That(second.Entries[0].Content, Does.Contain("still-routed")); + } + + [Test] + public void LogLevel_Filtering_IsRespectedAcrossMultipleSinks() { + var first = RegisterSink(); + var second = RegisterSink(); + Logger.LogLevel = LogLevelDef.Warning; + + Logger.Info("skip"); + Logger.Warning("keep"); + + Assert.That(first.Entries.Count, Is.EqualTo(1)); + Assert.That(second.Entries.Count, Is.EqualTo(1)); + Assert.That(first.Entries[0].Level, Is.EqualTo(LogLevelDef.Warning)); + Assert.That(second.Entries[0].Level, Is.EqualTo(LogLevelDef.Warning)); + } + + [Test] + public void BufferedLogCount_IsInspectable() { + var baseline = Logger.GetBufferedLogCount(); + + Logger.Error("inspectable"); + + Assert.That(Logger.GetBufferedLogCount(), Is.EqualTo(baseline + 1)); + } + + private RecordingSink RegisterSink() { + var sink = new RecordingSink(); + Logger.AddSink(sink); + _registeredSinks.Add(sink); + return sink; + } + + private sealed class RecordingSink : Logger.ILogSink + { + public List Entries { get; } = new List(); + + public void OnLogReceived(Logger.LogContext context) { + Entries.Add(context); + } + } + } +} diff --git a/Assets/vFrame.Core.Tests/EditMode/Loggers/LoggerRoutingTests.cs.meta b/Assets/vFrame.Core.Tests/EditMode/Loggers/LoggerRoutingTests.cs.meta new file mode 100644 index 0000000..debca61 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/Loggers/LoggerRoutingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ee76440a56a58cc4b916f33d9b960ccb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/ObjectPools.meta b/Assets/vFrame.Core.Tests/EditMode/ObjectPools.meta new file mode 100644 index 0000000..83b969a --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/ObjectPools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7b27646e579f4893b3d2f3b1cf5084b6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/ObjectPools/ObjectPoolTests.cs b/Assets/vFrame.Core.Tests/EditMode/ObjectPools/ObjectPoolTests.cs new file mode 100644 index 0000000..4aff322 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/ObjectPools/ObjectPoolTests.cs @@ -0,0 +1,260 @@ +using NUnit.Framework; +using vFrame.Core.Base; +using vFrame.Core.ObjectPools; + +namespace vFrame.Core.Tests.EditMode.ObjectPools +{ + public class ObjectPoolTests + { + [Test] + public void Return_DestroysLifecycleObject_InsteadOfReusingTerminatedInstance() { + var pool = new ObjectPool(new ObjectPoolOptions { + InitialCapacity = 0, + MaxSize = 4 + }); + pool.Create(); + + var item = pool.Get(); + item.Value = 42; + item.Create(); + + pool.Return(item); + var reused = pool.Get(); + var statistics = pool.GetStatistics(); + + Assert.That(reused, Is.Not.SameAs(item)); + Assert.That(item.DestroyCallCount, Is.EqualTo(1)); + Assert.That(reused.DestroyCallCount, Is.EqualTo(0)); + Assert.That(item.ResetCallCount, Is.EqualTo(2)); + Assert.That(item.ResetObservedDestroyedState, Is.True); + Assert.That(reused.ResetCallCount, Is.EqualTo(0)); + Assert.That(reused.Value, Is.EqualTo(0)); + Assert.That(statistics.TotalDestroyedCount, Is.EqualTo(1)); + } + + [Test] + public void Return_ReusesNonLifecyclePayload_AfterReset() { + var pool = new ObjectPool(new ObjectPoolOptions { + InitialCapacity = 0, + MaxSize = 4 + }); + pool.Create(); + + var item = pool.Get(); + item.Value = 99; + + pool.Return(item); + var reused = pool.Get(); + + Assert.That(reused, Is.SameAs(item)); + Assert.That(reused.Value, Is.EqualTo(0)); + } + + [Test] + public void Return_ReusesAllocatorPayload_AfterAllocatorAndResetHooksRun() { + var pool = new ObjectPool(new ObjectPoolOptions { + InitialCapacity = 0, + MaxSize = 4 + }); + pool.Create(); + + var item = pool.Get(); + item.Value = 99; + + pool.Return(item); + var reused = pool.Get(); + + Assert.That(reused, Is.SameAs(item)); + Assert.That(reused.Value, Is.EqualTo(0)); + Assert.That(reused.AllocatorResetCount, Is.EqualTo(1)); + Assert.That(reused.InterfaceResetCount, Is.EqualTo(1)); + } + + [Test] + public void Return_RetainsPayload_WhenOverflowPolicyIsRetain() { + var destroyCount = 0; + var pool = new ObjectPool(new ObjectPoolOptions { + InitialCapacity = 0, + MaxSize = 1, + OverflowPolicy = ObjectPoolOverflowPolicy.Retain, + OnDestroy = _ => destroyCount++ + }); + pool.Create(); + + var first = pool.Get(); + var second = pool.Get(); + + pool.Return(first); + pool.Return(second); + + var statistics = pool.GetStatistics(); + + Assert.That(statistics.CountInactive, Is.EqualTo(2)); + Assert.That(statistics.TotalDestroyedCount, Is.EqualTo(0)); + Assert.That(destroyCount, Is.EqualTo(0)); + } + + [Test] + public void Return_AppliesCapacityPolicy_WhenPoolIsFull() { + var destroyCount = 0; + var pool = new ObjectPool(new ObjectPoolOptions { + InitialCapacity = 0, + MaxSize = 1, + OverflowPolicy = ObjectPoolOverflowPolicy.DestroyReturned, + OnDestroy = _ => destroyCount++ + }); + pool.Create(); + + var first = pool.Get(); + var second = pool.Get(); + + pool.Return(first); + pool.Return(second); + + var statistics = pool.GetStatistics(); + + Assert.That(statistics.CountInactive, Is.EqualTo(1)); + Assert.That(statistics.TotalDestroyedCount, Is.EqualTo(1)); + Assert.That(destroyCount, Is.EqualTo(1)); + } + + [Test] + public void Return_TracksDuplicateReturns_WithoutGrowingInactiveCount() { + var pool = new ObjectPool(new ObjectPoolOptions { + InitialCapacity = 0, + MaxSize = 2 + }); + pool.Create(); + + var item = pool.Get(); + + pool.Return(item); + pool.Return(item); + + var statistics = pool.GetStatistics(); + + Assert.That(statistics.CountInactive, Is.EqualTo(1)); + Assert.That(statistics.TotalDuplicateReturnCount, Is.EqualTo(1)); + } + + [Test] + public void GetStatistics_TracksCreateGetAndReturnCounts_ForReusablePayloads() { + var pool = new ObjectPool(new ObjectPoolOptions { + InitialCapacity = 0, + MaxSize = 4 + }); + pool.Create(); + + var first = pool.Get(); + var second = pool.Get(); + + pool.Return(first); + pool.Return(second); + + var statistics = pool.GetStatistics(); + + Assert.That(statistics.CountAll, Is.EqualTo(2)); + Assert.That(statistics.CountActive, Is.EqualTo(0)); + Assert.That(statistics.CountInactive, Is.EqualTo(2)); + Assert.That(statistics.TotalCreatedCount, Is.EqualTo(2)); + Assert.That(statistics.TotalGetCount, Is.EqualTo(2)); + Assert.That(statistics.TotalReturnCount, Is.EqualTo(2)); + Assert.That(statistics.TotalDestroyedCount, Is.EqualTo(0)); + Assert.That(statistics.TotalDuplicateReturnCount, Is.EqualTo(0)); + } + + [Test] + public void GetStatistics_ExposesDiagnosticFlags_ForObservedActivity() { + var pool = new ObjectPool(new ObjectPoolOptions { + InitialCapacity = 0, + MaxSize = 2 + }); + pool.Create(); + + var item = pool.Get(); + var active = pool.GetStatistics(); + + pool.Return(item); + var returned = pool.GetStatistics(); + + Assert.That(active.HasObservedActivity, Is.True); + Assert.That(active.HasActiveObjects, Is.True); + Assert.That(active.HasRetainedObjects, Is.False); + Assert.That(returned.HasRetainedObjects, Is.True); + } + + private sealed class PooledPayload + { + } + + private sealed class ReusablePayload : IPoolObjectResetable + { + public int Value { get; set; } + + public void Reset() { + Value = 0; + } + } + + private sealed class AllocatorReusablePayload : IPoolObjectResetable + { + public int AllocatorResetCount { get; set; } + + public int InterfaceResetCount { get; private set; } + + public int Value { get; set; } + + public void Reset() { + InterfaceResetCount++; + Value = 0; + } + } + + private sealed class PooledState : BaseObject, IPoolObjectResetable + { + public int DestroyCallCount { get; private set; } + + public int ResetCallCount { get; private set; } + + public bool ResetObservedDestroyedState { get; private set; } + + public int Value { get; set; } + + public void Reset() { + ResetCallCount++; + ResetObservedDestroyedState = Destroyed; + Value = 0; + } + + protected override void OnCreate() { + } + + protected override void OnDestroy() { + DestroyCallCount++; + } + } + + private sealed class PooledStateAllocator : IPoolObjectAllocator + { + public PooledState Alloc() { + return new PooledState(); + } + + public void Reset(PooledState obj) { + obj.Reset(); + } + } + + private sealed class AllocatorReusablePayloadAllocator : IPoolObjectAllocator + { + public AllocatorReusablePayload Alloc() { + return new AllocatorReusablePayload(); + } + + public void Reset(AllocatorReusablePayload obj) { + obj.AllocatorResetCount++; + obj.Value = 0; + } + } + } +} diff --git a/Assets/vFrame.Core.Tests/EditMode/ObjectPools/ObjectPoolTests.cs.meta b/Assets/vFrame.Core.Tests/EditMode/ObjectPools/ObjectPoolTests.cs.meta new file mode 100644 index 0000000..410d554 --- /dev/null +++ b/Assets/vFrame.Core.Tests/EditMode/ObjectPools/ObjectPoolTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5541fbe4d92f4cd5b70ea2ebc44297b2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/EditMode/vFrame.Core.Tests.EditMode.asmdef b/Assets/vFrame.Core.Tests/EditMode/vFrame.Core.Tests.EditMode.asmdef index 93ff98a..30a62d0 100644 --- a/Assets/vFrame.Core.Tests/EditMode/vFrame.Core.Tests.EditMode.asmdef +++ b/Assets/vFrame.Core.Tests/EditMode/vFrame.Core.Tests.EditMode.asmdef @@ -1,8 +1,7 @@ { "name": "vFrame.Core.Tests.EditMode", "references": [ - "vFrame.Core", - "vFrame.Core.Unity" + "vFrame.Core" ], "optionalUnityReferences": [ "TestAssemblies" diff --git a/Assets/vFrame.Core.Tests/PlayMode/Coroutine/CoroutinePoolCancellationTests.cs b/Assets/vFrame.Core.Tests/PlayMode/Coroutine/CoroutinePoolCancellationTests.cs index c7ddbb0..cbc0589 100644 --- a/Assets/vFrame.Core.Tests/PlayMode/Coroutine/CoroutinePoolCancellationTests.cs +++ b/Assets/vFrame.Core.Tests/PlayMode/Coroutine/CoroutinePoolCancellationTests.cs @@ -1,5 +1,6 @@ using System.Collections; using NUnit.Framework; +using UnityEngine; using UnityEngine.TestTools; using vFrame.Core.Unity.Coroutine; @@ -23,6 +24,18 @@ public IEnumerator StopCoroutine_WhenTaskIsWaiting_DoesNotThrow() { yield return null; } + [UnityTest] + public IEnumerator Destroy_WhenFixtureTearsDown_CleansUpPoolHolder() { + var pool = new CoroutinePool("TeardownPool", 1); + + pool.Destroy(); + + yield return null; + + var poolHolder = GameObject.Find("Pool_2(TeardownPool)") ?? GameObject.Find("Pool_1(TeardownPool)"); + Assert.That(poolHolder, Is.Null); + } + private static IEnumerator DummyTask() { yield return null; } diff --git a/Assets/vFrame.Core.Tests/PlayMode/SpawnPools.meta b/Assets/vFrame.Core.Tests/PlayMode/SpawnPools.meta new file mode 100644 index 0000000..fa5566b --- /dev/null +++ b/Assets/vFrame.Core.Tests/PlayMode/SpawnPools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c36fb31990f4d1d4ca2846ef78ef9bb0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Tests/PlayMode/SpawnPools/SpawnPoolsValidationTests.cs b/Assets/vFrame.Core.Tests/PlayMode/SpawnPools/SpawnPoolsValidationTests.cs new file mode 100644 index 0000000..376f409 --- /dev/null +++ b/Assets/vFrame.Core.Tests/PlayMode/SpawnPools/SpawnPoolsValidationTests.cs @@ -0,0 +1,217 @@ +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using vFrame.Core.Unity.SpawnPools; + +namespace vFrame.Core.Tests.PlayMode.SpawnPools +{ + public class SpawnPoolsValidationTests + { + [UnityTest] + public IEnumerator Spawn_Recycle_AndRespawn_ReusesPoolIdentity() { + var pools = CreateSpawnPools(); + GameObject respawned = null; + + try { + var first = pools.Spawn("tests/sync"); + var firstIdentity = first.GetComponent(); + + Assert.That(firstIdentity, Is.Not.Null); + Assert.That(firstIdentity.AssetPath, Is.EqualTo("tests/sync")); + Assert.That(firstIdentity.IsPooling, Is.False); + + pools.Recycle(first); + + Assert.That(firstIdentity.IsPooling, Is.True); + + respawned = pools.Spawn("tests/sync"); + var respawnedIdentity = respawned.GetComponent(); + + Assert.That(respawned, Is.SameAs(first)); + Assert.That(respawnedIdentity, Is.SameAs(firstIdentity)); + Assert.That(respawnedIdentity.UniqueId, Is.EqualTo(firstIdentity.UniqueId)); + Assert.That(respawnedIdentity.IsPooling, Is.False); + } + finally { + if (respawned) { + pools.Recycle(respawned); + } + + pools.Destroy(); + } + + yield return null; + } + + [UnityTest] + public IEnumerator SpawnAsync_Completes_ThroughUpdateLoop() { + var pools = CreateSpawnPools(); + ILoadAsyncRequest request = null; + + try { + request = pools.SpawnAsync("tests/async"); + + yield return WaitForRequest(pools, request); + + Assert.That(request.IsError, Is.False); + Assert.That(request.IsDone, Is.True); + Assert.That(request.GameObject, Is.Not.Null); + + var identity = request.GameObject.GetComponent(); + + Assert.That(identity, Is.Not.Null); + Assert.That(identity.AssetPath, Is.EqualTo("tests/async")); + Assert.That(identity.IsPooling, Is.False); + + pools.Recycle(request.GameObject); + request.Destroy(); + } + finally { + if (request is { Destroyed: false }) { + if (request.IsDone && request.GameObject) { + pools.Recycle(request.GameObject); + } + + request.Destroy(); + } + + pools.Destroy(); + } + } + + [UnityTest] + public IEnumerator PreloadAsync_Completes_ForMultipleAssetPaths() { + var pools = CreateSpawnPools(); + IPreloadAsyncRequest request = null; + + try { + request = pools.PreloadAsync(new[] { "tests/preload/a", "tests/preload/b" }); + + yield return WaitForRequest(pools, request); + + Assert.That(request.IsError, Is.False); + Assert.That(request.IsDone, Is.True); + Assert.That(request.Progress, Is.EqualTo(1f)); + + request.Destroy(); + } + finally { + if (request is { Destroyed: false }) { + request.Destroy(); + } + + pools.Destroy(); + } + } + + [UnityTest] + public IEnumerator Update_ClearsTimedOutInactivePools_ButPreservesActiveReuseFlow() { + var pools = CreateSpawnPools(new SpawnPoolsSettings { + Capacity = 8, + GCInterval = 1, + LifeTime = -1 + }); + GameObject second = null; + + try { + var first = pools.Spawn("tests/gc"); + var firstIdentity = first.GetComponent(); + + pools.Recycle(first); + pools.Update(); + + second = pools.Spawn("tests/gc"); + var secondIdentity = second.GetComponent(); + + Assert.That(second, Is.Not.SameAs(first)); + Assert.That(secondIdentity, Is.Not.Null); + Assert.That(secondIdentity.AssetPath, Is.EqualTo("tests/gc")); + Assert.That(secondIdentity.UniqueId, Is.Not.EqualTo(firstIdentity.UniqueId)); + Assert.That(secondIdentity.IsPooling, Is.False); + } + finally { + if (second) { + pools.Recycle(second); + } + + pools.Destroy(); + } + + yield return null; + } + + private static IEnumerator WaitForRequest(SpawnPools pools, IAsyncRequest request) { + var guard = 20; + + while (!request.IsDone && !request.IsError && guard-- > 0) { + pools.Update(); + yield return null; + } + + Assert.That(guard, Is.GreaterThan(0), "Async request did not complete within the expected update window."); + } + + private static SpawnPools CreateSpawnPools(SpawnPoolsSettings settings = null) { + var pools = new SpawnPools(); + pools.Create(new TestGameObjectLoaderFactory(), settings ?? new SpawnPoolsSettings { + Capacity = 8, + GCInterval = int.MaxValue, + LifeTime = int.MaxValue + }); + return pools; + } + + private sealed class TestGameObjectLoaderFactory : IGameObjectLoaderFactory + { + public IGameObjectLoader CreateLoader(string assetPath) { + return new TestGameObjectLoader(assetPath); + } + } + + private sealed class TestGameObjectLoader : IGameObjectLoader + { + private readonly string _assetPath; + + public TestGameObjectLoader(string assetPath) { + _assetPath = assetPath; + } + + public GameObject Load() { + return new GameObject($"SpawnPoolsTest({_assetPath})"); + } + + public LoadAsyncRequest LoadAsync() { + return new TestLoadAsyncRequest { + AssetPath = _assetPath, + RemainingUpdates = 1 + }; + } + } + + private sealed class TestLoadAsyncRequest : LoadAsyncRequest + { + internal string AssetPath { get; set; } + + internal int RemainingUpdates { get; set; } + + public override float Progress => IsDone ? 1f : 0f; + + protected override void OnDestroy() { + AssetPath = null; + RemainingUpdates = 0; + base.OnDestroy(); + } + + protected override bool Validate(out GameObject obj) { + if (RemainingUpdates-- > 0) { + obj = null; + return false; + } + + obj = new GameObject($"SpawnPoolsAsync({_assetPath})"); + return true; + } + } + } +} diff --git a/Assets/vFrame.Core.Tests/PlayMode/SpawnPools/SpawnPoolsValidationTests.cs.meta b/Assets/vFrame.Core.Tests/PlayMode/SpawnPools/SpawnPoolsValidationTests.cs.meta new file mode 100644 index 0000000..6155a8c --- /dev/null +++ b/Assets/vFrame.Core.Tests/PlayMode/SpawnPools/SpawnPoolsValidationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aee122e96de046a43847a864148ea885 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core.Unity/Runtime/Loggers/LogLevelExtension.cs b/Assets/vFrame.Core.Unity/Runtime/Loggers/LogLevelExtension.cs index 426e9ed..47a507f 100644 --- a/Assets/vFrame.Core.Unity/Runtime/Loggers/LogLevelExtension.cs +++ b/Assets/vFrame.Core.Unity/Runtime/Loggers/LogLevelExtension.cs @@ -8,7 +8,7 @@ namespace vFrame.Core.Unity.Loggers public static class LogLevelExtension { public static LogType ToUnityLogLevel(this LogLevelDef level) { - switch (Logger.LogLevel) { + switch (level) { case LogLevelDef.Debug: case LogLevelDef.Info: return LogType.Log; @@ -24,4 +24,4 @@ public static LogType ToUnityLogLevel(this LogLevelDef level) { } } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core.Unity/Runtime/Loggers/UnityLogger.cs b/Assets/vFrame.Core.Unity/Runtime/Loggers/UnityLogger.cs index 62e9e08..d4501ec 100644 --- a/Assets/vFrame.Core.Unity/Runtime/Loggers/UnityLogger.cs +++ b/Assets/vFrame.Core.Unity/Runtime/Loggers/UnityLogger.cs @@ -8,6 +8,10 @@ namespace vFrame.Core.Unity.Loggers { public static class UnityLogger { + /// + /// Opens the Unity logging bridge over the core logging surface. + /// Unity remains one consumer of core log emission rather than the whole logging system. + /// private static bool _opened; public static void Open(LogLevelDef level, @@ -28,6 +32,9 @@ public static void Open(LogLevelDef level, Debug.unityLogger.filterLogType = level.ToUnityLogLevel(); } + /// + /// Closes the Unity logging bridge without affecting other registered core sinks. + /// public static void Close() { Logger.Close(); Logger.OnLogReceived -= OnLogReceived; @@ -60,4 +67,4 @@ private static void OnLogReceived(Logger.LogContext context) { } } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core.Unity/Runtime/Patch/Patcher.cs b/Assets/vFrame.Core.Unity/Runtime/Patch/Patcher.cs index 27bdb00..ecd5b93 100644 --- a/Assets/vFrame.Core.Unity/Runtime/Patch/Patcher.cs +++ b/Assets/vFrame.Core.Unity/Runtime/Patch/Patcher.cs @@ -5,6 +5,8 @@ using System.Net; using UnityEngine; using vFrame.Core.Exceptions; +// Compatibility-only dependency: Patch currently still bridges through the legacy Downloader, +// which remains an exit-line subsystem rather than a retained Unity runtime direction. using vFrame.Core.Unity.Download; using vFrame.Core.Unity.Utils; using Logger = vFrame.Core.Loggers.Logger; @@ -823,4 +825,4 @@ private void OnCheckFinished() { #endregion } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/ISpawnPools.cs b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/ISpawnPools.cs index e6e33ec..d4311f9 100644 --- a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/ISpawnPools.cs +++ b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/ISpawnPools.cs @@ -12,12 +12,38 @@ namespace vFrame.Core.Unity.SpawnPools { + /// + /// Unity-side instance reuse contract for prefab and pooling. + /// This surface intentionally manages spawned instance reuse only; resource location, + /// download, patching, and dependency ownership stay outside of . + /// public interface ISpawnPools { + /// + /// Advances background async requests and scheduled pool cleanup work. + /// void Update(); + + /// + /// Gets a pooled or newly loaded instance for immediate use. + /// GameObject Spawn(string assetPath, Transform parent = null); + + /// + /// Starts an async instance acquire flow that still resolves to pooled instance reuse semantics. + /// Call until the request completes. + /// ILoadAsyncRequest SpawnAsync(string assetPath, Transform parent = null); + + /// + /// Ends the current usage cycle for a spawned instance and returns it to its matching pool. + /// void Recycle(GameObject obj); + + /// + /// Warms pool state for the given asset paths without turning + /// into a broader resource ownership runtime. + /// IPreloadAsyncRequest PreloadAsync(string[] assetPaths); } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/Pool.cs b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/Pool.cs index 13ec3a6..7e7a6b8 100644 --- a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/Pool.cs +++ b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/Pool.cs @@ -16,6 +16,10 @@ namespace vFrame.Core.Unity.SpawnPools { + /// + /// Retained Unity instance pool for one asset path. This class only manages instantiated + /// object reuse and does not own resource discovery, download, or patch semantics. + /// internal class Pool : BaseObject, IPool { private readonly Queue _objects = new Queue(); @@ -29,6 +33,9 @@ internal class Pool : BaseObject, internal int SpawnedTimes { get; private set; } + /// + /// Starts or resumes an instance use cycle for this asset path. + /// public GameObject Spawn(Transform parent) { if (!parent) { parent = _context.Parent; @@ -43,6 +50,10 @@ public GameObject Spawn(Transform parent) { return obj; } + /// + /// Starts an async instance acquire flow that still resolves through the same pooled + /// identity and spawn bookkeeping as the synchronous path. + /// public ILoadAsyncRequest SpawnAsync(Transform parent) { if (!parent) { parent = _context.Parent; @@ -71,6 +82,10 @@ public ILoadAsyncRequest SpawnAsync(Transform parent) { return request; } + /// + /// Ends the current use cycle for an instance and returns it to this pool when valid. + /// Invalid or mismatched objects are destroyed instead of being retained. + /// public void Recycle(GameObject obj) { if (null == obj) { SpawnPoolsDebug.Error("Object to recycle cannot be null!"); @@ -157,6 +172,9 @@ internal bool IsTimeout() { return Time.frameCount - _lastTime > _context.Settings.LifeTime; } + /// + /// Destroys all retained inactive instances for this asset path. + /// internal void Clear() { foreach (var obj in _objects) { if (!obj) { @@ -195,4 +213,4 @@ protected override void OnDestroy() { } } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPools.cs b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPools.cs index 9d87f3c..87a5577 100644 --- a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPools.cs +++ b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPools.cs @@ -19,6 +19,11 @@ namespace vFrame.Core.Unity.SpawnPools { + /// + /// Retained Unity-side instance reuse layer for prefab and pooling. + /// It aligns spawn, recycle, preload, and async/update flows with the core lifecycle and + /// pooling model without taking on resource-runtime responsibilities. + /// public class SpawnPools : BaseObject, ISpawnPools { private const string PoolName = nameof(SpawnPools); @@ -32,6 +37,9 @@ public class SpawnPools : BaseObject _pools; private SpawnPoolsSettings _settings; + /// + /// Ends the current usage cycle for a pooled object and returns it to its matching pool. + /// public void Recycle(GameObject obj) { ThrowIfDestroyed(); @@ -43,6 +51,10 @@ public void Recycle(GameObject obj) { GetPool(identity.AssetPath).Recycle(obj); } + /// + /// Starts pool warm-up for the requested asset paths. This prepares retained instance reuse + /// behavior only and does not shift SpawnPools into resource ownership. + /// public IPreloadAsyncRequest PreloadAsync(string[] assetPaths) { ThrowIfDestroyed(); @@ -52,6 +64,10 @@ public IPreloadAsyncRequest PreloadAsync(string[] assetPaths) { return request; } + /// + /// Advances async request completion and performs lightweight pool cleanup based on the + /// configured retained-capacity and lifetime policy. + /// public void Update() { ThrowIfDestroyed(); @@ -64,7 +80,7 @@ public void Update() { var pools = ListPool.Shared.Get(); - // Clear timeout pools + // Expire inactive pools that have exceeded the retained lifetime window. foreach (var kv in _pools) { var pool = kv.Value; if (!pool.IsTimeout()) { @@ -76,7 +92,7 @@ public void Update() { } pools.Clear(); - // Clear pools by frequency + // Trim least-used pools when retained pool count exceeds configured capacity. if (_pools.Count < _settings.Capacity) { ListPool.Shared.Return(pools); return; @@ -94,11 +110,18 @@ public void Update() { ListPool.Shared.Return(pools); } + /// + /// Gets a pooled or newly loaded instance for immediate use. + /// public GameObject Spawn(string assetPath, Transform parent = null) { ThrowIfDestroyed(); return GetPool(assetPath).Spawn(parent); } + /// + /// Starts an async instance acquire flow. Completion is driven by + /// and still resolves through the retained pooling model. + /// public ILoadAsyncRequest SpawnAsync(string assetPath, Transform parent = null) { ThrowIfDestroyed(); @@ -133,6 +156,7 @@ protected override void OnCreate(IGameObjectLoaderFactory factory, SpawnPoolsSet _asyncRequestCtrl = new AsyncRequestCtrl(); _asyncRequestCtrl.Create(); _settings = settings; + SpawnPoolsDebug.Configure(_settings); _parent = new GameObject(PoolName).DontDestroyEx(); _parent.transform.position = _settings.RootPosition; @@ -159,8 +183,12 @@ protected override void OnDestroy() { _context = null; SpawnPoolsDebug.Log("Spawn pools destroyed."); + SpawnPoolsDebug.Reset(); } + /// + /// Destroys all tracked pools and retained inactive instances owned by this runtime. + /// public void Clear() { ThrowIfDestroyed(); foreach (var kv in _pools) { @@ -173,4 +201,4 @@ private int CompareBySpawnedTimes(string poolNameA, string poolNameB) { return _pools[poolNameB].SpawnedTimes.CompareTo(_pools[poolNameA].SpawnedTimes); } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsContext.cs b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsContext.cs index 224783c..0d63f5e 100644 --- a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsContext.cs +++ b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsContext.cs @@ -12,9 +12,13 @@ namespace vFrame.Core.Unity.SpawnPools { + /// + /// Lightweight Unity-side context shared by retained instance pools. + /// Keeps pool parenting and settings local to the SpawnPools runtime layer. + /// internal class SpawnPoolsContext { public Transform Parent { get; set; } public SpawnPoolsSettings Settings { get; set; } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsDebug.cs b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsDebug.cs index d4434da..9d51d8a 100644 --- a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsDebug.cs +++ b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsDebug.cs @@ -1,21 +1,62 @@ -using System.Diagnostics; +using System.Diagnostics; using Logger = vFrame.Core.Loggers.Logger; namespace vFrame.Core.Unity.SpawnPools { - internal static class SpawnPoolsDebug + public struct SpawnPoolsDiagnosticsSnapshot { - [Conditional("DEBUG_SPAWNPOOLS")] + public bool Enabled { get; set; } + + public int LogCount { get; set; } + + public int WarningCount { get; set; } + + public int ErrorCount { get; set; } + } + + public static class SpawnPoolsDebug + { + private static SpawnPoolsDiagnosticsSnapshot _snapshot; + + public static SpawnPoolsDiagnosticsSnapshot GetSnapshot() { + return _snapshot; + } + + internal static void Configure(SpawnPoolsSettings settings) { + _snapshot = new SpawnPoolsDiagnosticsSnapshot { + Enabled = settings != null && settings.EnableDiagnostics + }; + } + + internal static void Reset() { + _snapshot = default; + } + + [Conditional("UNITY_EDITOR")] + [Conditional("DEVELOPMENT_BUILD")] public static void Log(string message, params object[] args) { + if (!_snapshot.Enabled) { + return; + } + + _snapshot.LogCount++; Logger.Info(SpawnPoolsSettings.LogTag, message, args); } public static void Warning(string message, params object[] args) { + if (_snapshot.Enabled) { + _snapshot.WarningCount++; + } + Logger.Warning(SpawnPoolsSettings.LogTag, message, args); } public static void Error(string message, params object[] args) { - Logger.Warning(SpawnPoolsSettings.LogTag, message, args); + if (_snapshot.Enabled) { + _snapshot.ErrorCount++; + } + + Logger.Error(SpawnPoolsSettings.LogTag, message, args); } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsSettings.cs b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsSettings.cs index 90a59f0..e9dfc1b 100644 --- a/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsSettings.cs +++ b/Assets/vFrame.Core.Unity/Runtime/SpawnPools/SpawnPoolsSettings.cs @@ -19,8 +19,9 @@ public class SpawnPoolsSettings public int Capacity { get; set; } = 40; public int LifeTime { get; set; } = 30 * 60 * 5; // 5min by 30fps public int GCInterval { get; set; } = 600; // 600 frames, 20s by 30fps + public bool EnableDiagnostics { get; set; } public Vector3 RootPosition { get; set; } = new Vector3(-1000, -1000, -1000); public static SpawnPoolsSettings Default { get; } = new SpawnPoolsSettings(); } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Editor.meta b/Assets/vFrame.Core/Editor.meta new file mode 100644 index 0000000..9b4f234 --- /dev/null +++ b/Assets/vFrame.Core/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6aa76d3b56d74d1494f0610f8b4fe57d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Editor/Benchmarks.meta b/Assets/vFrame.Core/Editor/Benchmarks.meta new file mode 100644 index 0000000..57833e7 --- /dev/null +++ b/Assets/vFrame.Core/Editor/Benchmarks.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4c0df661a12d4a8db2a9151302cc0215 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Editor/Benchmarks/CoreBenchmarkRunner.cs b/Assets/vFrame.Core/Editor/Benchmarks/CoreBenchmarkRunner.cs new file mode 100644 index 0000000..1a2c939 --- /dev/null +++ b/Assets/vFrame.Core/Editor/Benchmarks/CoreBenchmarkRunner.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using UnityEditor; +using UnityEngine; + +namespace vFrame.Core.Benchmarks.Editor +{ + public static class CoreBenchmarkRunner + { + public sealed class BenchmarkResult + { + public string Name; + public int Iterations; + public long ElapsedMilliseconds; + + public double AverageNanosecondsPerOperation { + get { + if (Iterations <= 0) { + return 0d; + } + + return ElapsedMilliseconds * 1000000d / Iterations; + } + } + } + + private static readonly List> _benchmarks = new List>(); + + public static IReadOnlyList> Benchmarks => _benchmarks; + + public static void Register(Func benchmark) { + if (benchmark == null) { + throw new ArgumentNullException(nameof(benchmark)); + } + + _benchmarks.Add(benchmark); + } + + public static IReadOnlyList RunAll() { + var results = new List(_benchmarks.Count); + + foreach (var benchmark in _benchmarks) { + results.Add(benchmark()); + } + + return results; + } + + [MenuItem("Tools/vFrame/Benchmarks/Run Core Benchmarks")] + private static void RunAllFromMenu() { + var results = RunAll(); + + foreach (var result in results) { + UnityEngine.Debug.LogFormat( + "[vFrame.Core.Benchmark] {0}: {1} iterations in {2} ms ({3:F2} ns/op)", + result.Name, + result.Iterations, + result.ElapsedMilliseconds, + result.AverageNanosecondsPerOperation); + } + } + + public static BenchmarkResult Measure(string name, int warmupIterations, int measureIterations, Action action) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentException("Value cannot be null or empty.", nameof(name)); + } + if (action == null) { + throw new ArgumentNullException(nameof(action)); + } + if (warmupIterations < 0) { + throw new ArgumentOutOfRangeException(nameof(warmupIterations)); + } + if (measureIterations <= 0) { + throw new ArgumentOutOfRangeException(nameof(measureIterations)); + } + + for (var i = 0; i < warmupIterations; i++) { + action(); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var stopwatch = Stopwatch.StartNew(); + for (var i = 0; i < measureIterations; i++) { + action(); + } + stopwatch.Stop(); + + return new BenchmarkResult { + Name = name, + Iterations = measureIterations, + ElapsedMilliseconds = stopwatch.ElapsedMilliseconds + }; + } + } +} diff --git a/Assets/vFrame.Core/Editor/Benchmarks/CoreBenchmarkRunner.cs.meta b/Assets/vFrame.Core/Editor/Benchmarks/CoreBenchmarkRunner.cs.meta new file mode 100644 index 0000000..0d4c6f9 --- /dev/null +++ b/Assets/vFrame.Core/Editor/Benchmarks/CoreBenchmarkRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c5a90de23984a64a6ba08f299c1e6ec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Editor/Benchmarks/InteractionDispatchBenchmarks.cs b/Assets/vFrame.Core/Editor/Benchmarks/InteractionDispatchBenchmarks.cs new file mode 100644 index 0000000..00a8929 --- /dev/null +++ b/Assets/vFrame.Core/Editor/Benchmarks/InteractionDispatchBenchmarks.cs @@ -0,0 +1,103 @@ +using vFrame.Core.EventDispatchers; + +namespace vFrame.Core.Benchmarks.Editor +{ + [UnityEditor.InitializeOnLoad] + public static class InteractionDispatchBenchmarks + { + private const int DispatchIterations = 100000; + private const int EventId = 1001; + private const int ListenerCount = 8; + private const int MessageSeed = 77; + private const int VoteId = 2002; + + static InteractionDispatchBenchmarks() { + CoreBenchmarkRunner.Register(RunEventDispatchBenchmark); + CoreBenchmarkRunner.Register(RunTypedDispatchBenchmark); + CoreBenchmarkRunner.Register(RunVoteDispatchBenchmark); + } + + private static CoreBenchmarkRunner.BenchmarkResult RunEventDispatchBenchmark() { + var dispatcher = new EventDispatcher(); + dispatcher.Create(); + + var state = new CounterState(); + for (var i = 0; i < ListenerCount; i++) { + dispatcher.AddEventListener(state.OnEvent, EventId); + } + + var result = CoreBenchmarkRunner.Measure( + "interaction.dispatch.int-event", + 256, + DispatchIterations, + () => dispatcher.DispatchEvent(EventId, state)); + + dispatcher.Destroy(); + return result; + } + + private static CoreBenchmarkRunner.BenchmarkResult RunVoteDispatchBenchmark() { + var dispatcher = new EventDispatcher(); + dispatcher.Create(); + + for (var i = 0; i < ListenerCount; i++) { + dispatcher.AddVoteListener(static _ => true, VoteId); + } + + var result = CoreBenchmarkRunner.Measure( + "interaction.dispatch.vote", + 256, + DispatchIterations, + () => dispatcher.DispatchVote(VoteId, null)); + + dispatcher.Destroy(); + return result; + } + + private static CoreBenchmarkRunner.BenchmarkResult RunTypedDispatchBenchmark() { + var dispatcher = new EventDispatcher(); + dispatcher.Create(); + + var state = new TypedCounterState(); + for (var i = 0; i < ListenerCount; i++) { + dispatcher.Subscribe(state.OnMessage); + } + + var message = new TypedMessage { + Value = MessageSeed + }; + + var result = CoreBenchmarkRunner.Measure( + "interaction.dispatch.typed-message", + 256, + DispatchIterations, + () => dispatcher.Publish(message)); + + dispatcher.Destroy(); + return result; + } + + private sealed class CounterState + { + public int Count; + + public void OnEvent(IEvent _) { + Count++; + } + } + + private sealed class TypedCounterState + { + public int Count; + + public void OnMessage(TypedMessage message) { + Count += message.Value; + } + } + + private sealed class TypedMessage + { + public int Value; + } + } +} diff --git a/Assets/vFrame.Core/Editor/Benchmarks/InteractionDispatchBenchmarks.cs.meta b/Assets/vFrame.Core/Editor/Benchmarks/InteractionDispatchBenchmarks.cs.meta new file mode 100644 index 0000000..afea4ff --- /dev/null +++ b/Assets/vFrame.Core/Editor/Benchmarks/InteractionDispatchBenchmarks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cc59f41af6cf449ba5f1e98260d48d1b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Editor/Benchmarks/ObjectPoolBenchmarks.cs b/Assets/vFrame.Core/Editor/Benchmarks/ObjectPoolBenchmarks.cs new file mode 100644 index 0000000..eef75ec --- /dev/null +++ b/Assets/vFrame.Core/Editor/Benchmarks/ObjectPoolBenchmarks.cs @@ -0,0 +1,52 @@ +using vFrame.Core.ObjectPools; + +namespace vFrame.Core.Benchmarks.Editor +{ + [UnityEditor.InitializeOnLoad] + public static class ObjectPoolBenchmarks + { + private const int PoolIterations = 200000; + + static ObjectPoolBenchmarks() { + CoreBenchmarkRunner.Register(RunGenericPoolBenchmark); + } + + private static CoreBenchmarkRunner.BenchmarkResult RunGenericPoolBenchmark() { + var pool = new ObjectPool(); + pool.Create(); + + var result = CoreBenchmarkRunner.Measure( + "pool.generic.get-return", + 512, + PoolIterations, + () => { + var payload = pool.Get(); + payload.Sequence++; + pool.Return(payload); + }); + + pool.Destroy(); + return result; + } + + private sealed class PooledPayload : IPoolObjectResetable + { + public int Sequence; + + public void Reset() { + Sequence = 0; + } + } + + private sealed class PooledPayloadAllocator : IPoolObjectAllocator + { + public PooledPayload Alloc() { + return new PooledPayload(); + } + + public void Reset(PooledPayload obj) { + obj.Sequence = 0; + } + } + } +} diff --git a/Assets/vFrame.Core/Editor/Benchmarks/ObjectPoolBenchmarks.cs.meta b/Assets/vFrame.Core/Editor/Benchmarks/ObjectPoolBenchmarks.cs.meta new file mode 100644 index 0000000..c2d860c --- /dev/null +++ b/Assets/vFrame.Core/Editor/Benchmarks/ObjectPoolBenchmarks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0c525cd7764541538a7497d1c6aa02b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Editor/Benchmarks/SpawnPoolsBenchmarks.cs b/Assets/vFrame.Core/Editor/Benchmarks/SpawnPoolsBenchmarks.cs new file mode 100644 index 0000000..eaf05b8 --- /dev/null +++ b/Assets/vFrame.Core/Editor/Benchmarks/SpawnPoolsBenchmarks.cs @@ -0,0 +1,129 @@ +using UnityEngine; +using UnityEngine.Scripting; +using vFrame.Core.Unity.SpawnPools; + +namespace vFrame.Core.Benchmarks.Editor +{ + [Preserve] + [UnityEditor.InitializeOnLoad] + public static class SpawnPoolsBenchmarks + { + private const int SpawnIterations = 20000; + private const int AsyncIterations = 5000; + + static SpawnPoolsBenchmarks() { + CoreBenchmarkRunner.Register(RunSpawnRecycleBenchmark); + CoreBenchmarkRunner.Register(RunSpawnAsyncBenchmark); + } + + private static CoreBenchmarkRunner.BenchmarkResult RunSpawnRecycleBenchmark() { + var pools = CreateSpawnPools(); + + try { + return CoreBenchmarkRunner.Measure( + "spawnpools.spawn-recycle", + 64, + SpawnIterations, + () => { + var instance = pools.Spawn("benchmark/sync"); + pools.Recycle(instance); + }); + } + finally { + pools.Destroy(); + } + } + + private static CoreBenchmarkRunner.BenchmarkResult RunSpawnAsyncBenchmark() { + var pools = CreateSpawnPools(); + + try { + return CoreBenchmarkRunner.Measure( + "spawnpools.spawn-async-complete", + 16, + AsyncIterations, + () => { + var request = pools.SpawnAsync("benchmark/async"); + var guard = 8; + + while (!request.IsDone && !request.IsError && guard-- > 0) { + pools.Update(); + } + + if (request.IsDone && request.GameObject) { + pools.Recycle(request.GameObject); + } + + request.Destroy(); + }); + } + finally { + pools.Destroy(); + } + } + + private static SpawnPools CreateSpawnPools() { + var pools = new SpawnPools(); + pools.Create(new BenchmarkGameObjectLoaderFactory(), new SpawnPoolsSettings { + Capacity = 8, + GCInterval = int.MaxValue, + LifeTime = int.MaxValue + }); + return pools; + } + + private sealed class BenchmarkGameObjectLoaderFactory : IGameObjectLoaderFactory + { + public IGameObjectLoader CreateLoader(string assetPath) { + return new BenchmarkGameObjectLoader(assetPath); + } + } + + private sealed class BenchmarkGameObjectLoader : IGameObjectLoader + { + private readonly string _assetPath; + + public BenchmarkGameObjectLoader(string assetPath) { + _assetPath = assetPath; + } + + public GameObject Load() { + return new GameObject($"Benchmark({_assetPath})"); + } + + public LoadAsyncRequest LoadAsync() { + var request = new BenchmarkLoadAsyncRequest { + AssetPath = _assetPath, + RemainingUpdates = 1 + }; + + return request; + } + } + + private sealed class BenchmarkLoadAsyncRequest : LoadAsyncRequest + { + internal string AssetPath { get; set; } + + internal int RemainingUpdates { get; set; } + + public override float Progress => IsDone ? 1f : 0f; + + protected override void OnDestroy() { + AssetPath = null; + RemainingUpdates = 0; + base.OnDestroy(); + } + + protected override bool Validate(out GameObject obj) { + if (RemainingUpdates-- > 0) { + obj = null; + return false; + } + + obj = new GameObject($"BenchmarkAsync({AssetPath})"); + return true; + } + } + } +} diff --git a/Assets/vFrame.Core/Editor/Benchmarks/SpawnPoolsBenchmarks.cs.meta b/Assets/vFrame.Core/Editor/Benchmarks/SpawnPoolsBenchmarks.cs.meta new file mode 100644 index 0000000..9e79605 --- /dev/null +++ b/Assets/vFrame.Core/Editor/Benchmarks/SpawnPoolsBenchmarks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ce6a8bf68a4f4db45a560e7a74e88420 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Editor/vFrame.Core.Benchmarks.Editor.asmdef b/Assets/vFrame.Core/Editor/vFrame.Core.Benchmarks.Editor.asmdef new file mode 100644 index 0000000..99e0acc --- /dev/null +++ b/Assets/vFrame.Core/Editor/vFrame.Core.Benchmarks.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "vFrame.Core.Benchmarks.Editor", + "references": [ + "vFrame.Core", + "vFrame.Core.Unity" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/vFrame.Core/Editor/vFrame.Core.Benchmarks.Editor.asmdef.meta b/Assets/vFrame.Core/Editor/vFrame.Core.Benchmarks.Editor.asmdef.meta new file mode 100644 index 0000000..2c61f63 --- /dev/null +++ b/Assets/vFrame.Core/Editor/vFrame.Core.Benchmarks.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7051f4834b374e0e8ee1e38fe44b3649 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Runtime/Base/BaseObject.cs b/Assets/vFrame.Core/Runtime/Base/BaseObject.cs index 9959a53..8d96fb5 100644 --- a/Assets/vFrame.Core/Runtime/Base/BaseObject.cs +++ b/Assets/vFrame.Core/Runtime/Base/BaseObject.cs @@ -9,27 +9,59 @@ // Copyright Copyright (c) 2024, VyronLee //============================================================ +using System; +using vFrame.Core.Exceptions; + namespace vFrame.Core.Base { public abstract class Object : IDestroyable { + private ILifetime _lifetime; + + /// + /// Gets whether the instance has successfully completed its one-shot create transition. + /// public bool Created { get; protected set; } + + /// + /// Gets whether the instance has crossed its terminal destroy boundary. + /// Destroyed instances do not support recreation. + /// public bool Destroyed { get; protected set; } private bool Destroying { get; set; } + /// + /// Executes the unified, terminal teardown boundary for owned state and custom cleanup. + /// public void Destroy() { if (Destroyed || Destroying) { return; } + + Exception destroyException = null; + try { Destroying = true; OnDestroy(); } + catch (Exception exception) { + destroyException = exception; + } finally { - Destroying = false; - Destroyed = true; - Created = false; + try { + _lifetime?.Destroy(); + } + finally { + _lifetime = null; + Destroying = false; + Destroyed = true; + Created = false; + } + } + + if (destroyException != null) { + throw destroyException; } } @@ -39,6 +71,47 @@ public void Dispose() { protected abstract void OnDestroy(); + protected ILifetime Lifetime { + get { + ThrowIfDestroyed(); + + _lifetime ??= new Lifetime(); + return _lifetime; + } + } + + /// + /// Registers an owned destroyable that should terminate when this object crosses its + /// destroy boundary. + /// + protected void Own(IDestroyable destroyable) { + ThrowHelper.ThrowIfNull(destroyable, nameof(destroyable)); + Lifetime.Add(destroyable); + } + + /// + /// Registers an owned cleanup action that should run when this object is destroyed. + /// + protected void Own(Action action) { + ThrowHelper.ThrowIfNull(action, nameof(action)); + Lifetime.Add(action); + } + + /// + /// Binds a destroyable to this object's lifetime without implying direct field ownership. + /// Use this for lifetime-bound resources that should end with the object. + /// + protected internal void OwnLifetime(IDestroyable destroyable) { + Own(destroyable); + } + + /// + /// Binds a cleanup action to this object's lifetime without introducing a separate scope model. + /// + protected internal void OwnLifetime(Action action) { + Own(action); + } + protected void ThrowIfDestroyed() { if (Destroyed) { throw new BaseObjectDestroyedException(); @@ -52,14 +125,19 @@ protected void ThrowIfNotCreated() { } protected void ThrowIfNotCreatedOrDestroyed() { - ThrowIfNotCreated(); ThrowIfDestroyed(); + ThrowIfNotCreated(); } } public abstract class BaseObject : Object, IBaseObject { + /// + /// Transitions the object into its usable state once. Destroyed instances stay terminal. + /// public void Create() { + ThrowIfDestroyed(); + if (Created) { return; } @@ -74,7 +152,16 @@ public void Create() { public abstract class BaseObject : Object, IBaseObject { + /// + /// Transitions the object into its usable state once. Destroyed instances stay terminal. + /// public void Create(T1 arg1) { + ThrowIfDestroyed(); + + if (Created) { + return; + } + OnCreate(arg1); Created = true; Destroyed = false; @@ -85,7 +172,16 @@ public void Create(T1 arg1) { public abstract class BaseObject : Object, IBaseObject { + /// + /// Transitions the object into its usable state once. Destroyed instances stay terminal. + /// public void Create(T1 arg1, T2 arg2) { + ThrowIfDestroyed(); + + if (Created) { + return; + } + OnCreate(arg1, arg2); Created = true; Destroyed = false; @@ -96,7 +192,16 @@ public void Create(T1 arg1, T2 arg2) { public abstract class BaseObject : Object, IBaseObject { + /// + /// Transitions the object into its usable state once. Destroyed instances stay terminal. + /// public void Create(T1 arg1, T2 arg2, T3 arg3) { + ThrowIfDestroyed(); + + if (Created) { + return; + } + OnCreate(arg1, arg2, arg3); Created = true; Destroyed = false; @@ -107,7 +212,16 @@ public void Create(T1 arg1, T2 arg2, T3 arg3) { public abstract class BaseObject : Object, IBaseObject { + /// + /// Transitions the object into its usable state once. Destroyed instances stay terminal. + /// public void Create(T1 arg1, T2 arg2, T3 arg3, T4 arg4) { + ThrowIfDestroyed(); + + if (Created) { + return; + } + OnCreate(arg1, arg2, arg3, arg4); Created = true; Destroyed = false; @@ -118,7 +232,16 @@ public void Create(T1 arg1, T2 arg2, T3 arg3, T4 arg4) { public abstract class BaseObject : Object, IBaseObject { + /// + /// Transitions the object into its usable state once. Destroyed instances stay terminal. + /// public void Create(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) { + ThrowIfDestroyed(); + + if (Created) { + return; + } + OnCreate(arg1, arg2, arg3, arg4, arg5); Created = true; Destroyed = false; diff --git a/Assets/vFrame.Core/Runtime/Base/IDestroyable.cs b/Assets/vFrame.Core/Runtime/Base/IDestroyable.cs index 9aa7e16..47ef9b7 100644 --- a/Assets/vFrame.Core/Runtime/Base/IDestroyable.cs +++ b/Assets/vFrame.Core/Runtime/Base/IDestroyable.cs @@ -12,9 +12,18 @@ namespace vFrame.Core.Base { + /// + /// Represents a terminal destroy contract. Once completes, + /// the instance must remain in the destroyed state and is not expected to re-enter + /// a usable lifecycle. + /// public interface IDestroyable : IDisposable { bool Destroyed { get; } + + /// + /// Executes one-shot teardown for the instance. + /// void Destroy(); } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/Base/ILifetime.cs b/Assets/vFrame.Core/Runtime/Base/ILifetime.cs new file mode 100644 index 0000000..b926276 --- /dev/null +++ b/Assets/vFrame.Core/Runtime/Base/ILifetime.cs @@ -0,0 +1,25 @@ +namespace vFrame.Core.Base +{ + /// + /// Lightweight grouped-cleanup primitive for retained ownership boundaries. + /// A lifetime can own child lifetimes, destroyables, and cleanup actions, but it is + /// not intended to grow into a container or orchestration framework. + /// + public interface ILifetime : IDestroyable + { + /// + /// Creates a child lifetime that ends when this lifetime ends. + /// + ILifetime CreateChild(); + + /// + /// Binds a destroyable to this lifetime so it is terminated when the lifetime ends. + /// + void Add(IDestroyable destroyable); + + /// + /// Binds a cleanup action to this lifetime so it executes when the lifetime ends. + /// + void Add(System.Action action); + } +} diff --git a/Assets/vFrame.Core/Runtime/Base/ILifetime.cs.meta b/Assets/vFrame.Core/Runtime/Base/ILifetime.cs.meta new file mode 100644 index 0000000..9548d4b --- /dev/null +++ b/Assets/vFrame.Core/Runtime/Base/ILifetime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 425ef2b0b58a4af4aa8934aab2a372d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Runtime/Base/Lifetime.cs b/Assets/vFrame.Core/Runtime/Base/Lifetime.cs new file mode 100644 index 0000000..6b452e5 --- /dev/null +++ b/Assets/vFrame.Core/Runtime/Base/Lifetime.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using vFrame.Core.Exceptions; + +namespace vFrame.Core.Base +{ + /// + /// Lightweight grouped-cleanup implementation used for child ownership and + /// same-lifetime resource binding. + /// + public sealed class Lifetime : ILifetime + { + private List _destroyables; + private List _actions; + + public bool Destroyed { get; private set; } + + /// + /// Creates a child lifetime that is destroyed through this lifetime's cleanup boundary. + /// + public ILifetime CreateChild() { + var lifetime = new Lifetime(); + Add(lifetime); + return lifetime; + } + + /// + /// Binds a destroyable resource to this lifetime. + /// + public void Add(IDestroyable destroyable) { + ThrowHelper.ThrowIfNull(destroyable, nameof(destroyable)); + + if (Destroyed) { + destroyable.Destroy(); + return; + } + + _destroyables ??= new List(); + _destroyables.Add(destroyable); + } + + /// + /// Binds a cleanup action to this lifetime. + /// + public void Add(Action action) { + ThrowHelper.ThrowIfNull(action, nameof(action)); + + if (Destroyed) { + action(); + return; + } + + _actions ??= new List(); + _actions.Add(action); + } + + /// + /// Ends the lifetime and tears down child lifetimes, destroyables, and cleanup actions + /// through one grouped cleanup boundary. + /// + public void Destroy() { + if (Destroyed) { + return; + } + + try { + Destroyed = true; + + if (_destroyables != null) { + for (var index = _destroyables.Count - 1; index >= 0; index--) { + _destroyables[index]?.Destroy(); + } + } + + if (_actions != null) { + for (var index = _actions.Count - 1; index >= 0; index--) { + _actions[index]?.Invoke(); + } + } + } + finally { + _destroyables?.Clear(); + _destroyables = null; + + _actions?.Clear(); + _actions = null; + } + } + + public void Dispose() { + Destroy(); + } + } +} diff --git a/Assets/vFrame.Core/Runtime/Base/Lifetime.cs.meta b/Assets/vFrame.Core/Runtime/Base/Lifetime.cs.meta new file mode 100644 index 0000000..4146fff --- /dev/null +++ b/Assets/vFrame.Core/Runtime/Base/Lifetime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9566ba86d7a449c89928e0e13b3a5e4a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Runtime/Compression/BlockBasedCompression/AsynchronousBlockBasedCompression.cs b/Assets/vFrame.Core/Runtime/Compression/BlockBasedCompression/AsynchronousBlockBasedCompression.cs index 4de693e..db230dd 100644 --- a/Assets/vFrame.Core/Runtime/Compression/BlockBasedCompression/AsynchronousBlockBasedCompression.cs +++ b/Assets/vFrame.Core/Runtime/Compression/BlockBasedCompression/AsynchronousBlockBasedCompression.cs @@ -4,6 +4,8 @@ using System.IO; using System.Threading; using vFrame.Core.Exceptions; +// Compatibility-only dependency: the historical MultiThreading / Task runner remains here for +// legacy async compression flow, but it is not the preferred direction for new retained systems. using vFrame.Core.MultiThreading; namespace vFrame.Core.Compression @@ -239,4 +241,4 @@ public void IncreaseFinishedCount() { } } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/Compression/BlockBasedCompression/BlockBasedCompression.cs b/Assets/vFrame.Core/Runtime/Compression/BlockBasedCompression/BlockBasedCompression.cs index dd2837b..6d27a07 100644 --- a/Assets/vFrame.Core/Runtime/Compression/BlockBasedCompression/BlockBasedCompression.cs +++ b/Assets/vFrame.Core/Runtime/Compression/BlockBasedCompression/BlockBasedCompression.cs @@ -5,6 +5,8 @@ using System.Text; using vFrame.Core.Base; using vFrame.Core.Extensions; +// Compatibility-only dependency: Profiles remains available for legacy metadata/config paths, +// but it is no longer a retained modernization investment area. using vFrame.Core.Profiles; using vFrame.Core.Utils; using ByteArrayPool = System.Buffers.ArrayPool; @@ -464,4 +466,4 @@ private void SafeBufferedDecompress(byte[] dataBuffer, #endregion } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/Compression/Compressor/LZMA/LZMACompressor.cs b/Assets/vFrame.Core/Runtime/Compression/Compressor/LZMA/LZMACompressor.cs index 431c7f6..c800bb4 100644 --- a/Assets/vFrame.Core/Runtime/Compression/Compressor/LZMA/LZMACompressor.cs +++ b/Assets/vFrame.Core/Runtime/Compression/Compressor/LZMA/LZMACompressor.cs @@ -90,7 +90,7 @@ public override void Compress(Stream input, Stream output, Action on } var progress = ObjectPool.Shared.Get(); - progress.Create(onProgress); + progress.Initialize(onProgress); lzmaEncoder.Code(input, output, -1, -1, progress); @@ -132,7 +132,7 @@ public override void Decompress(Stream input, Stream output, Action } var progress = ObjectPool.Shared.Get(); - progress.Create(onProgress); + progress.Initialize(onProgress); var compressedSize = input.Length - input.Position; decoder.Code(input, output, compressedSize, fileLength, progress); @@ -143,22 +143,22 @@ public override void Decompress(Stream input, Stream output, Action ByteArrayCache.Enqueue(properties); } - private class ActionCodeProgress : BaseObject>, ICodeProgress + private class ActionCodeProgress : ICodeProgress, IPoolObjectResetable { - private Action _handler; - private static Action DefaultHandler => (inSize, outSize) => { }; + private static readonly Action DefaultHandler = (inSize, outSize) => { }; + private Action _handler = DefaultHandler; - public void SetProgress(long inSize, long outSize) { - _handler(inSize, outSize); + public void Initialize(Action handler) { + _handler = handler ?? DefaultHandler; } - protected override void OnCreate(Action handler) { - _handler = handler ?? DefaultHandler; + public void Reset() { + _handler = DefaultHandler; } - protected override void OnDestroy() { - _handler = null; + public void SetProgress(long inSize, long outSize) { + _handler(inSize, outSize); } } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/DelegateEventListener.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/DelegateEventListener.cs index 403dfd3..b8955fc 100644 --- a/Assets/vFrame.Core/Runtime/EventDispatchers/DelegateEventListener.cs +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/DelegateEventListener.cs @@ -10,11 +10,11 @@ //============================================================ using System; -using vFrame.Core.Base; +using vFrame.Core.ObjectPools; namespace vFrame.Core.EventDispatchers { - public class DelegateEventListener : BaseObject, IEventListener + public class DelegateEventListener : IEventListener, IPoolObjectResetable { /// /// 代理接口 @@ -30,10 +30,8 @@ public void OnEvent(IEvent e) { } } - protected override void OnCreate() { } - - protected override void OnDestroy() { + public void Reset() { Action = null; } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/DelegateVoteListener.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/DelegateVoteListener.cs index 5432a27..92111bb 100644 --- a/Assets/vFrame.Core/Runtime/EventDispatchers/DelegateVoteListener.cs +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/DelegateVoteListener.cs @@ -10,11 +10,11 @@ //============================================================ using System; -using vFrame.Core.Base; +using vFrame.Core.ObjectPools; namespace vFrame.Core.EventDispatchers { - public class DelegateVoteListener : BaseObject, IVoteListener + public class DelegateVoteListener : IVoteListener, IPoolObjectResetable { /// /// 代理接口 @@ -28,16 +28,8 @@ public bool OnVote(IVote e) { return null != VoteAction && VoteAction(e); } - /// - /// 创建函数 - /// - protected override void OnCreate() { } - - /// - /// 销毁函数 - /// - protected override void OnDestroy() { + public void Reset() { VoteAction = null; } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/Event.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/Event.cs index 1d964cd..8cb2a5e 100644 --- a/Assets/vFrame.Core/Runtime/EventDispatchers/Event.cs +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/Event.cs @@ -9,11 +9,9 @@ // Copyright Copyright (c) 2024, VyronLee //============================================================ -using vFrame.Core.Base; - namespace vFrame.Core.EventDispatchers { - public class Event : BaseObject, IEvent + public class Event : IEvent { public object Context; public int EventId; @@ -40,16 +38,5 @@ public object GetEventTarget() { return Target; } - /// - /// 创建函数 - /// - protected override void OnCreate() { } - - /// - /// 销毁函数 - /// - protected override void OnDestroy() { - Target = null; - } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/EventDispatcher.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/EventDispatcher.cs index c2bdfb2..92d2c85 100644 --- a/Assets/vFrame.Core/Runtime/EventDispatchers/EventDispatcher.cs +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/EventDispatcher.cs @@ -10,22 +10,123 @@ using System; using System.Collections.Generic; -using vFrame.Core.Containers; +using vFrame.Core.Base; using vFrame.Core.Exceptions; using vFrame.Core.Loggers; using vFrame.Core.ObjectPools; namespace vFrame.Core.EventDispatchers { - public class EventDispatcher : Component, IEventDispatcher + public class EventDispatcher : BaseObject, IEventDispatcher { + public readonly struct DiagnosticsSnapshot + { + public DiagnosticsSnapshot(int eventExecutorCount, int interactionSubscriptionCount, int voteExecutorCount) { + EventExecutorCount = eventExecutorCount; + InteractionSubscriptionCount = interactionSubscriptionCount; + VoteExecutorCount = voteExecutorCount; + } + + public int EventExecutorCount { get; } + + public int InteractionSubscriptionCount { get; } + + public int VoteExecutorCount { get; } + } + private static readonly LogTag EventLogTag = new LogTag("EventDispatcher"); private Dictionary> _eventExecutorLists; + private Dictionary> _interactionSubscriptions; private uint _index = 1; private Dictionary> _voteExecutorLists; + /// + /// Retained interaction entry point. Typed publish-subscribe is the default path for new + /// core interaction work, while `int eventId` dispatch remains as a compatibility layer. + /// + /// + /// Creates a bare subscription. The caller borrows dispatcher access and remains + /// responsible for explicit unsubscription. + /// + public IInteractionSubscription Subscribe(Action action) where TMessage : class { + return SubscribeInternal(action, null); + } + + /// + /// Creates an owner-bound subscription. The dispatcher borrows the owner lifecycle and + /// ends the subscription automatically when the owner is destroyed. + /// + public IInteractionSubscription Subscribe(Action action, BaseObject owner) where TMessage : class { + ThrowHelper.ThrowIfNull(owner, nameof(owner)); + + var subscription = SubscribeInternal(action, null); + return BindSubscriptionToOwner(subscription, owner); + } + + /// + /// Creates a lifetime-bound subscription. The dispatcher borrows the provided lifetime and + /// ends the subscription when that lifetime terminates. + /// + public IInteractionSubscription Subscribe(Action action, ILifetime lifetime) where TMessage : class { + ThrowHelper.ThrowIfNull(lifetime, nameof(lifetime)); + + var subscription = SubscribeInternal(action, lifetime); + return subscription; + } + + public void Unsubscribe(IInteractionSubscription subscription) { + if (subscription == null || subscription.Destroyed) { + return; + } + + subscription.Destroy(); + } + + /// + /// Publishes a typed message through the retained default interaction path. + /// + public void Publish(TMessage message) where TMessage : class { + ThrowIfNotCreatedOrDestroyed(); + ThrowHelper.ThrowIfNull(message, nameof(message)); + + if (!_interactionSubscriptions.TryGetValue(typeof(TMessage), out var subscriptions)) { + return; + } + + CleanupStoppedInteractionSubscriptions(subscriptions); + + for (var i = 0; i < subscriptions.Count; i++) { + var subscription = subscriptions[i]; + if (subscription.Destroyed) { + continue; + } + + try { + ((Action)subscription.Action)?.Invoke(message); + } + catch (Exception exception) { + Logger.Error(EventLogTag, "Exception occurred, interaction type: {0}, exception: {1}", + typeof(TMessage).FullName, exception); + } + } + } + + public int GetInteractionSubscriptionCount() { + ThrowIfNotCreatedOrDestroyed(); + var count = 0; + foreach (var item in _interactionSubscriptions) { + count += item.Value.Count; + } + return count; + } + + /// + /// Adds a legacy numeric-event listener. This retained `int eventId` path remains available + /// for migration compatibility, while typed messages are the default path for new work. + /// public uint AddEventListener(IEventListener listener, int eventId) { + ThrowIfNotCreatedOrDestroyed(); ThrowHelper.ThrowIfNull(listener, nameof(listener)); var executor = EventExecutorPool.Shared.Get(); @@ -41,7 +142,11 @@ public uint AddEventListener(IEventListener listener, int eventId) { return executor.Handle; } + /// + /// Adds a delegate listener for the retained compatibility-only numeric event path. + /// public uint AddEventListener(Action action, int eventId) { + ThrowIfNotCreatedOrDestroyed(); ThrowHelper.ThrowIfNull(action, nameof(action)); var delegateEventListener = ObjectPool.Shared.Get(); @@ -50,7 +155,11 @@ public uint AddEventListener(Action action, int eventId) { return AddEventListener(delegateEventListener, eventId); } + /// + /// Removes a listener from the retained compatibility-only numeric event path. + /// public IEventListener RemoveEventListener(uint handle) { + ThrowIfNotCreatedOrDestroyed(); IEventListener listener = null; foreach (var item in _eventExecutorLists) { var list = item.Value; @@ -67,11 +176,18 @@ public IEventListener RemoveEventListener(uint handle) { return listener; } + /// + /// Dispatches a retained compatibility-only numeric event. + /// public void DispatchEvent(int eventId) { DispatchEvent(eventId, null); } + /// + /// Dispatches a retained compatibility-only numeric event with context. + /// public void DispatchEvent(int eventId, object context) { + ThrowIfNotCreatedOrDestroyed(); if (!_eventExecutorLists.ContainsKey(eventId)) { return; } @@ -117,7 +233,11 @@ public void DispatchEvent(int eventId, object context) { EventPool.Shared.Return(e); } + /// + /// Adds a listener to the explicit vote/decision semantic path rather than ordinary dispatch. + /// public uint AddVoteListener(IVoteListener listener, int voteId) { + ThrowIfNotCreatedOrDestroyed(); ThrowHelper.ThrowIfNull(listener, nameof(listener)); var executor = VoteExecutorPool.Shared.Get(); @@ -133,7 +253,18 @@ public uint AddVoteListener(IVoteListener listener, int voteId) { return executor.Handle; } + /// + /// Adds a listener to the explicit decision alias of vote semantics. + /// + public uint AddDecisionListener(IVoteListener listener, int decisionId) { + return AddVoteListener(listener, decisionId); + } + + /// + /// Adds a delegate listener to the explicit vote/decision semantic path. + /// public uint AddVoteListener(Func func, int voteId) { + ThrowIfNotCreatedOrDestroyed(); ThrowHelper.ThrowIfNull(func, nameof(func)); var listener = ObjectPool.Shared.Get(); @@ -142,7 +273,18 @@ public uint AddVoteListener(Func func, int voteId) { return AddVoteListener(listener, voteId); } + /// + /// Adds a delegate listener to the explicit decision alias of vote semantics. + /// + public uint AddDecisionListener(Func decisionDelegate, int decisionId) { + return AddVoteListener(decisionDelegate, decisionId); + } + + /// + /// Removes a listener from the explicit vote/decision semantic path. + /// public IVoteListener RemoveVoteListener(uint handle) { + ThrowIfNotCreatedOrDestroyed(); IVoteListener listener = null; foreach (var item in _voteExecutorLists) { var list = item.Value; @@ -160,11 +302,32 @@ public IVoteListener RemoveVoteListener(uint handle) { return listener; } + /// + /// Removes a listener through the explicit decision alias of vote semantics. + /// + public IVoteListener RemoveDecisionListener(uint handle) { + return RemoveVoteListener(handle); + } + + /// + /// Dispatches the explicit vote semantic path. + /// public bool DispatchVote(int voteId) { return DispatchVote(voteId, null); } + /// + /// Dispatches the explicit decision alias of vote semantics. + /// + public bool DispatchDecision(int decisionId) { + return DispatchDecision(decisionId, null); + } + + /// + /// Dispatches the explicit vote semantic path with context. + /// public bool DispatchVote(int voteId, object context) { + ThrowIfNotCreatedOrDestroyed(); if (!_voteExecutorLists.ContainsKey(voteId)) { return true; } @@ -216,12 +379,23 @@ public bool DispatchVote(int voteId, object context) { return pass; } + /// + /// Dispatches the explicit decision alias of vote semantics with context. + /// + public bool DispatchDecision(int decisionId, object context) { + return DispatchVote(decisionId, context); + } + public void RemoveAllListeners() { - _eventExecutorLists.Clear(); - _voteExecutorLists.Clear(); + ThrowIfNotCreatedOrDestroyed(); + + ClearEventExecutors(); + ClearInteractionSubscriptions(); + ClearVoteExecutors(); } public int GetEventExecutorCount() { + ThrowIfNotCreatedOrDestroyed(); var count = 0; foreach (var kv in _eventExecutorLists) { count += kv.Value.Count; @@ -230,6 +404,7 @@ public int GetEventExecutorCount() { } public int GetVoteExecutorCount() { + ThrowIfNotCreatedOrDestroyed(); var count = 0; foreach (var kv in _voteExecutorLists) { count += kv.Value.Count; @@ -237,14 +412,140 @@ public int GetVoteExecutorCount() { return count; } + public DiagnosticsSnapshot GetDiagnostics() { + ThrowIfNotCreatedOrDestroyed(); + return new DiagnosticsSnapshot(GetEventExecutorCount(), GetInteractionSubscriptionCount(), GetVoteExecutorCount()); + } + protected override void OnCreate() { _eventExecutorLists = new Dictionary>(); + _interactionSubscriptions = new Dictionary>(); _voteExecutorLists = new Dictionary>(); } protected override void OnDestroy() { + ClearEventExecutors(); + ClearInteractionSubscriptions(); + ClearVoteExecutors(); + _eventExecutorLists = null; + _interactionSubscriptions = null; _voteExecutorLists = null; } + + private InteractionSubscription BindSubscriptionToOwner(IInteractionSubscription subscription, BaseObject owner) { + owner.OwnLifetime(subscription); + return (InteractionSubscription)subscription; + } + + private void CleanupStoppedInteractionSubscriptions(List subscriptions) { + for (var i = subscriptions.Count - 1; i >= 0; i--) { + if (subscriptions[i].Destroyed) { + subscriptions.RemoveAt(i); + } + } + } + + private void ClearEventExecutors() { + if (_eventExecutorLists == null) { + return; + } + + foreach (var pair in _eventExecutorLists) { + var executors = pair.Value; + if (executors == null) { + continue; + } + + for (var i = executors.Count - 1; i >= 0; i--) { + if (executors[i] == null) { + continue; + } + + EventExecutorPool.Shared.Return(executors[i]); + } + + executors.Clear(); + } + + _eventExecutorLists.Clear(); + } + + private void ClearInteractionSubscriptions() { + if (_interactionSubscriptions == null) { + return; + } + + foreach (var pair in _interactionSubscriptions) { + var subscriptions = pair.Value; + if (subscriptions == null) { + continue; + } + + for (var i = subscriptions.Count - 1; i >= 0; i--) { + subscriptions[i]?.Destroy(); + } + + subscriptions.Clear(); + } + + _interactionSubscriptions.Clear(); + } + + private void ClearVoteExecutors() { + if (_voteExecutorLists == null) { + return; + } + + foreach (var pair in _voteExecutorLists) { + var executors = pair.Value; + if (executors == null) { + continue; + } + + for (var i = executors.Count - 1; i >= 0; i--) { + if (executors[i] == null) { + continue; + } + + VoteExecutorPool.Shared.Return(executors[i]); + } + + executors.Clear(); + } + + _voteExecutorLists.Clear(); + } + + private InteractionSubscription SubscribeInternal(Action action, ILifetime lifetime) where TMessage : class { + ThrowHelper.ThrowIfNull(action, nameof(action)); + + var subscription = new InteractionSubscription(); + subscription.Create(); + subscription.Handle = _index++; + subscription.MessageType = typeof(TMessage); + subscription.Action = action; + subscription.SetUnsubscribe(UnsubscribeInternal); + + if (!_interactionSubscriptions.TryGetValue(typeof(TMessage), out var subscriptions)) { + subscriptions = _interactionSubscriptions[typeof(TMessage)] = new List(); + } + + subscriptions.Add(subscription); + lifetime?.Add(subscription); + return subscription; + } + + private void UnsubscribeInternal(InteractionSubscription subscription) { + if (subscription == null || subscription.MessageType == null) { + return; + } + + if (!_interactionSubscriptions.TryGetValue(subscription.MessageType, out var subscriptions)) { + return; + } + + subscriptions.Remove(subscription); + } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/EventExecutor.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/EventExecutor.cs index 3b06396..ec75f49 100644 --- a/Assets/vFrame.Core/Runtime/EventDispatchers/EventExecutor.cs +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/EventExecutor.cs @@ -9,11 +9,9 @@ // Copyright Copyright (c) 2024, VyronLee //============================================================ -using vFrame.Core.Base; - namespace vFrame.Core.EventDispatchers { - public class EventExecutor : BaseObject + public class EventExecutor { public int EventId; public uint Handle; @@ -21,18 +19,6 @@ public class EventExecutor : BaseObject public bool Activated { get; set; } public bool Stopped { get; set; } - /// - /// 创建函数 - /// - protected override void OnCreate() { } - - /// - /// 销毁函数 - /// - protected override void OnDestroy() { - Listener = null; - } - /// /// 激活 /// @@ -56,4 +42,4 @@ public void Execute(IEvent e) { } } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/IEventDispatcher.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/IEventDispatcher.cs index 73b82f1..199056b 100644 --- a/Assets/vFrame.Core/Runtime/EventDispatchers/IEventDispatcher.cs +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/IEventDispatcher.cs @@ -12,10 +12,16 @@ namespace vFrame.Core.EventDispatchers { - public interface IEventDispatcher + /// + /// Full retained dispatcher surface. Typed interaction is the default path for new work, + /// `int eventId` APIs are compatibility-oriented migration paths, and `Vote` / `Decision` + /// remain explicit specialized semantics. + /// + public interface IEventDispatcher : IInteractionDispatcher { /// /// 添加事件侦听器 + /// - 兼容保留的 `int eventId` 事件路径,供迁移场景继续使用 /// - 如果在事件派发过程中添加事件,该侦听器会在下一个事件派发才生效 /// /// 侦听器 @@ -24,6 +30,7 @@ public interface IEventDispatcher /// /// 添加事件代理侦听器 + /// - 兼容保留的 `int eventId` 事件路径,供迁移场景继续使用 /// - 如果在事件派发过程中添加事件,该侦听器会在下一个事件派发才生效 /// /// 侦听器 @@ -32,6 +39,7 @@ public interface IEventDispatcher /// /// 移除事件侦听器 + /// - 用于兼容保留的 `int eventId` 事件路径 /// - 如果处于事件派发过程中,该侦听器不会马上从队列移除,以防止迭代出错, /// 但该侦听器会立即失效。 /// @@ -40,12 +48,14 @@ public interface IEventDispatcher /// /// 派发事件 + /// - 兼容保留的 `int eventId` 事件路径,不是新交互的首选入口 /// /// 事件ID void DispatchEvent(int eventId); /// /// 派发事件 + /// - 兼容保留的 `int eventId` 事件路径,不是新交互的首选入口 /// /// 事件ID /// 事件现场 @@ -53,34 +63,72 @@ public interface IEventDispatcher /// /// 添加投票侦听器 + /// - 投票是显式保留的规则/裁决语义,不属于普通消息派发 /// uint AddVoteListener(IVoteListener listener, int voteId); + /// + /// 添加决策侦听器 + /// - 决策是投票语义的专用业务语义入口,不属于普通事件派发 + /// + uint AddDecisionListener(IVoteListener listener, int decisionId); + /// /// 添加代理投票侦听器 + /// - 投票是显式保留的规则/裁决语义,不属于普通消息派发 /// uint AddVoteListener(Func voteDelegate, int voteId); + /// + /// 添加代理决策侦听器 + /// - 决策是投票语义的专用业务语义入口,不属于普通事件派发 + /// + uint AddDecisionListener(Func decisionDelegate, int decisionId); + /// /// 移除投票侦听器 + /// - 用于显式投票/决策语义,不属于普通事件移除路径 /// IVoteListener RemoveVoteListener(uint handle); + /// + /// 移除决策侦听器 + /// + IVoteListener RemoveDecisionListener(uint handle); + /// /// 派发投票 + /// - 投票是显式保留的规则/裁决语义,不属于普通消息派发 /// /// 投票ID /// 投票现场 /// 投票是否通过 bool DispatchVote(int voteId, object context); + /// + /// 派发决策 + /// - 决策是投票语义的专用业务语义入口,不属于普通事件派发 + /// + /// 决策ID + /// 决策现场 + /// 决策是否通过 + bool DispatchDecision(int decisionId, object context); + /// /// 派发投票 + /// - 投票是显式保留的规则/裁决语义,不属于普通消息派发 /// /// 投票ID /// 投票是否通过 bool DispatchVote(int voteId); + /// + /// 派发决策 + /// + /// 决策ID + /// 决策是否通过 + bool DispatchDecision(int decisionId); + /// /// 移除所有侦听器 /// @@ -98,4 +146,4 @@ public interface IEventDispatcher /// 个数 int GetVoteExecutorCount(); } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionDispatcher.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionDispatcher.cs new file mode 100644 index 0000000..78efaa5 --- /dev/null +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionDispatcher.cs @@ -0,0 +1,43 @@ +using System; +using vFrame.Core.Base; + +namespace vFrame.Core.EventDispatchers +{ + /// + /// Retained typed interaction surface for new core publish-subscribe work. + /// Bare subscriptions stay caller-managed, while owner-bound and lifetime-bound + /// subscriptions end automatically with their lifecycle boundary. + /// + public interface IInteractionDispatcher + { + /// + /// Creates a bare typed subscription that remains active until explicitly unsubscribed. + /// + IInteractionSubscription Subscribe(Action action) where TMessage : class; + + /// + /// Creates an owner-bound typed subscription that ends when the owner is destroyed. + /// + IInteractionSubscription Subscribe(Action action, BaseObject owner) where TMessage : class; + + /// + /// Creates a lifetime-bound typed subscription that ends when the lifetime is destroyed. + /// + IInteractionSubscription Subscribe(Action action, ILifetime lifetime) where TMessage : class; + + /// + /// Explicitly ends a typed subscription. + /// + void Unsubscribe(IInteractionSubscription subscription); + + /// + /// Publishes a typed message through the retained primary interaction path. + /// + void Publish(TMessage message) where TMessage : class; + + /// + /// Returns the current number of tracked typed subscriptions. + /// + int GetInteractionSubscriptionCount(); + } +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionDispatcher.cs.meta b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionDispatcher.cs.meta new file mode 100644 index 0000000..adb3bc4 --- /dev/null +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionDispatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b367aa5f92c420092e9979c7f18a0b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionMessage.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionMessage.cs new file mode 100644 index 0000000..5befc36 --- /dev/null +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionMessage.cs @@ -0,0 +1,8 @@ +namespace vFrame.Core.EventDispatchers +{ + public interface IInteractionMessage + { + object GetContext(); + object GetTarget(); + } +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionMessage.cs.meta b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionMessage.cs.meta new file mode 100644 index 0000000..d8c02d2 --- /dev/null +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionMessage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: af1bb1f7f5cc4607b11f4541f025f8bc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionSubscription.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionSubscription.cs new file mode 100644 index 0000000..b8ffd65 --- /dev/null +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionSubscription.cs @@ -0,0 +1,9 @@ +using vFrame.Core.Base; + +namespace vFrame.Core.EventDispatchers +{ + public interface IInteractionSubscription : IDestroyable + { + uint Handle { get; } + } +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionSubscription.cs.meta b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionSubscription.cs.meta new file mode 100644 index 0000000..b62a880 --- /dev/null +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/IInteractionSubscription.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8ff75f1f6f74659b4fb6c09f2f80289 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/InteractionSubscription.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/InteractionSubscription.cs new file mode 100644 index 0000000..960cec2 --- /dev/null +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/InteractionSubscription.cs @@ -0,0 +1,39 @@ +using System; +using vFrame.Core.Base; + +namespace vFrame.Core.EventDispatchers +{ + /// + /// Lightweight typed interaction subscription that tears down through the shared destroy boundary. + /// + public sealed class InteractionSubscription : BaseObject, IInteractionSubscription + { + private Action _unsubscribe; + + public uint Handle { get; set; } + public Type MessageType { get; set; } + public Delegate Action { get; set; } + + /// + /// Explicit caller-managed unsubscribe entry point for bare subscriptions. + /// Bound subscriptions reach the same teardown through their owner or lifetime. + /// + public void Unsubscribe() { + Destroy(); + } + + protected override void OnCreate() { } + + protected override void OnDestroy() { + _unsubscribe?.Invoke(this); + _unsubscribe = null; + Action = null; + MessageType = null; + Handle = 0; + } + + internal void SetUnsubscribe(Action unsubscribe) { + _unsubscribe = unsubscribe; + } + } +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/InteractionSubscription.cs.meta b/Assets/vFrame.Core/Runtime/EventDispatchers/InteractionSubscription.cs.meta new file mode 100644 index 0000000..8e7c0dc --- /dev/null +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/InteractionSubscription.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b596fb2908164a0cb19880c30884269d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/Vote.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/Vote.cs index e7e65dc..4dbc8f7 100644 --- a/Assets/vFrame.Core/Runtime/EventDispatchers/Vote.cs +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/Vote.cs @@ -9,11 +9,9 @@ // Copyright Copyright (c) 2024, VyronLee //============================================================ -using vFrame.Core.Base; - namespace vFrame.Core.EventDispatchers { - public class Vote : BaseObject, IVote + public class Vote : IVote { public object Context; public EventDispatcher Target; @@ -40,16 +38,5 @@ public object GetVoteTarget() { return Target; } - /// - /// 创建函数 - /// - protected override void OnCreate() { } - - /// - /// 销毁函数 - /// - protected override void OnDestroy() { - Target = null; - } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/EventDispatchers/VoteExecutor.cs b/Assets/vFrame.Core/Runtime/EventDispatchers/VoteExecutor.cs index 6e18fc0..21ff807 100644 --- a/Assets/vFrame.Core/Runtime/EventDispatchers/VoteExecutor.cs +++ b/Assets/vFrame.Core/Runtime/EventDispatchers/VoteExecutor.cs @@ -9,11 +9,9 @@ // Copyright Copyright (c) 2024, VyronLee //============================================================ -using vFrame.Core.Base; - namespace vFrame.Core.EventDispatchers { - public class VoteExecutor : BaseObject + public class VoteExecutor { public uint Handle; public IVoteListener Listener; @@ -21,18 +19,6 @@ public class VoteExecutor : BaseObject public bool Activated { get; set; } public bool Stopped { get; set; } - /// - /// 创建函数 - /// - protected override void OnCreate() { } - - /// - /// 创建函数 - /// - protected override void OnDestroy() { - Listener = null; - } - /// /// 激活 /// @@ -57,4 +43,4 @@ public bool Execute(IVote e) { return false; } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/Loggers/Logger.cs b/Assets/vFrame.Core/Runtime/Loggers/Logger.cs index 5a420cf..a4fda82 100644 --- a/Assets/vFrame.Core/Runtime/Loggers/Logger.cs +++ b/Assets/vFrame.Core/Runtime/Loggers/Logger.cs @@ -17,6 +17,11 @@ namespace vFrame.Core.Loggers { public static class Logger { + public interface ILogSink + { + void OnLogReceived(LogContext context); + } + public const int DefaultCapacity = 1000; public const string DefaultTagFormatter = "{0}"; @@ -24,6 +29,7 @@ public static class Logger LogFormatType.Tag | LogFormatType.Time | LogFormatType.Class | LogFormatType.Function; private static readonly Queue _logQueue; + private static readonly List _sinks = new List(); private static readonly object _queueLock; private static string _logFilePath; private static LogToFile _logFile; @@ -44,6 +50,46 @@ public static string LogFilePath { public static event Action OnLogReceived; + /// + /// Registers a lightweight log sink. Core logging can fan out to multiple sinks while + /// remaining Unity-free. + /// + public static void AddSink(ILogSink sink) { + if (sink == null) { + throw new ArgumentNullException(nameof(sink)); + } + + lock (_queueLock) { + if (_sinks.Contains(sink)) { + return; + } + + _sinks.Add(sink); + } + } + + /// + /// Removes a previously registered lightweight sink. + /// + public static void RemoveSink(ILogSink sink) { + if (sink == null) { + throw new ArgumentNullException(nameof(sink)); + } + + lock (_queueLock) { + _sinks.Remove(sink); + } + } + + /// + /// Returns the current number of registered lightweight sinks. + /// + public static int GetSinkCount() { + lock (_queueLock) { + return _sinks.Count; + } + } + private static void RecreateLogFile() { Close(); @@ -125,6 +171,7 @@ private static void Log(int skip, LogLevelDef level, LogTag tag, string text, pa _logFile?.AppendText(content); OnLogReceived?.Invoke(context); + EmitToSinks(context); } private static void Log(LogLevelDef level, LogTag tag, Exception exception) { @@ -143,6 +190,23 @@ private static void Log(LogLevelDef level, LogTag tag, Exception exception) { _logFile?.AppendText(exception.ToString()); OnLogReceived?.Invoke(context); + EmitToSinks(context); + } + + private static void EmitToSinks(LogContext context) { + ILogSink[] sinks; + + lock (_queueLock) { + if (_sinks.Count == 0) { + return; + } + + sinks = _sinks.ToArray(); + } + + foreach (var sink in sinks) { + sink.OnLogReceived(context); + } } private static string GetFormattedLogText(int skip, LogTag tag, string log) { @@ -242,6 +306,12 @@ public static IEnumerable Logs(int logMask) { return logs; } + public static int GetBufferedLogCount() { + lock (_queueLock) { + return _logQueue.Count; + } + } + public struct LogContext { public LogLevelDef Level; @@ -272,4 +342,4 @@ public LogContext(LogLevelDef level, LogTag tag, string content, string stackTra #endregion } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/ObjectPools/IObjectPool.cs b/Assets/vFrame.Core/Runtime/ObjectPools/IObjectPool.cs index 6cc19c1..288354d 100644 --- a/Assets/vFrame.Core/Runtime/ObjectPools/IObjectPool.cs +++ b/Assets/vFrame.Core/Runtime/ObjectPools/IObjectPool.cs @@ -1,23 +1,38 @@ -namespace vFrame.Core.ObjectPools +namespace vFrame.Core.ObjectPools { + /// + /// Lightweight retained object-pool contract. `Get()` starts a use cycle, `Return(...)` + /// ends that use cycle, and pool policy decides whether the instance is retained, reset, + /// reused later, or destroyed. + /// public interface IObjectPool { + /// + /// Ends the current use cycle for an instance and lets the pool apply return policy. + /// void Return(object obj); + + /// + /// Gets an instance for a new use cycle. + /// object Get(); + + /// + /// Returns observable pool statistics for diagnostics, policy verification, and capacity tracking. + /// + ObjectPoolStatistics GetStatistics(); } public interface IObjectPool : IObjectPool { /// - /// Get instance from pool. + /// Gets an instance for a new use cycle. /// - /// new T Get(); /// - /// Return instance to pool. + /// Ends the current use cycle for an instance and lets the pool apply return policy. /// - /// void Return(T obj); } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/ObjectPools/ObjectPool.cs b/Assets/vFrame.Core/Runtime/ObjectPools/ObjectPool.cs index 7c09099..c0e3c15 100644 --- a/Assets/vFrame.Core/Runtime/ObjectPools/ObjectPool.cs +++ b/Assets/vFrame.Core/Runtime/ObjectPools/ObjectPool.cs @@ -19,27 +19,43 @@ public abstract class ObjectPool : BaseObject, IObjectPool { protected readonly LogTag LogTag = new LogTag("ObjectPool"); + /// + /// Starts a new pooled use cycle. + /// public object Get() { return OnGetInternal(); } + /// + /// Ends the current pooled use cycle and applies pool-managed return policy. + /// public void Return(object obj) { OnReturnInternal(obj); } + public abstract ObjectPoolStatistics GetStatistics(); + protected abstract object OnGetInternal(); protected abstract void OnReturnInternal(object obj); } public class ObjectPool : ObjectPool, IObjectPool where TClass : class, new() { - private const int InitSize = 128; private static readonly object _instanceLockObject = new object(); private static ObjectPool _shared; private readonly object _lockObject = new object(); + private readonly ObjectPoolOptions _options; + private HashSet _inactiveLookup; private Stack _objects; + private ObjectPoolStatistics _statistics; + + public ObjectPool() : this(null) { } + + public ObjectPool(ObjectPoolOptions options) { + _options = options ?? new ObjectPoolOptions(); + } public static ObjectPool Shared { get { @@ -58,31 +74,75 @@ public static ObjectPool Shared { public void Return(TClass obj) { ThrowHelper.ThrowIfNull(obj, nameof(obj)); + + // Returning an item always ends its current use cycle before retention policy is applied. + if (obj is IDestroyable destroyable) { + destroyable.Destroy(); + } + + _options.OnReturn?.Invoke(obj); + if (obj is IPoolObjectResetable resetable) { resetable.Reset(); } - if (obj is IDestroyable destroyable) { - destroyable.Destroy(); + + if (obj is Object pooledObject && pooledObject.Destroyed) { + _statistics.TotalReturnCount++; + _statistics.TotalDestroyedCount++; + _options.OnDestroy?.Invoke(obj); + return; } + lock (_lockObject) { - if (_objects.Contains(obj)) { + if (_inactiveLookup.Contains(obj)) { + _statistics.TotalDuplicateReturnCount++; + return; + } + + _statistics.TotalReturnCount++; + + if (_objects.Count >= _options.MaxSize && _options.OverflowPolicy == ObjectPoolOverflowPolicy.DestroyReturned) { + _statistics.TotalDestroyedCount++; + _options.OnDestroy?.Invoke(obj); return; } + _objects.Push(obj); + _inactiveLookup.Add(obj); } } public new TClass Get() { + TClass item; lock (_lockObject) { - return _objects.Count > 0 ? _objects.Pop() : new TClass(); + if (_objects.Count > 0) { + item = _objects.Pop(); + _inactiveLookup.Remove(item); + } + else { + item = new TClass(); + _statistics.CountAll++; + _statistics.TotalCreatedCount++; + } + + _statistics.TotalGetCount++; } + + _options.OnGet?.Invoke(item); + return item; } protected override void OnCreate() { lock (_lockObject) { - _objects = new Stack(InitSize); - for (var i = 0; i < InitSize; i++) { - _objects.Push(new TClass()); + _objects = new Stack(_options.InitialCapacity); + _inactiveLookup = new HashSet(); + _statistics = default; + for (var i = 0; i < _options.InitialCapacity; i++) { + var item = new TClass(); + _objects.Push(item); + _inactiveLookup.Add(item); + _statistics.CountAll++; + _statistics.TotalCreatedCount++; } } } @@ -90,7 +150,17 @@ protected override void OnCreate() { protected override void OnDestroy() { lock (_lockObject) { _objects?.Clear(); + _inactiveLookup?.Clear(); _objects = null; + _inactiveLookup = null; + } + } + + public override ObjectPoolStatistics GetStatistics() { + lock (_lockObject) { + _statistics.CountInactive = _objects?.Count ?? 0; + _statistics.CountActive = _statistics.CountAll - _statistics.CountInactive; + return _statistics; } } @@ -109,14 +179,22 @@ public class ObjectPool : ObjectPool, IObjectPool where TClass : class, new() where TAllocator : IPoolObjectAllocator, new() { - private const int InitSize = 128; private static readonly object _instanceLockObject = new object(); private static ObjectPool _shared; private readonly object _lockObject = new object(); + private readonly ObjectPoolOptions _options; private TAllocator _allocator; + private HashSet _inactiveLookup; private Stack _objects; + private ObjectPoolStatistics _statistics; + + public ObjectPool() : this(null) { } + + public ObjectPool(ObjectPoolOptions options) { + _options = options ?? new ObjectPoolOptions(); + } public static ObjectPool Shared { get { @@ -134,37 +212,78 @@ public static ObjectPool Shared { } public new TClass Get() { + TClass item; lock (_lockObject) { - return _objects.Count > 0 ? _objects.Pop() : _allocator.Alloc(); + if (_objects.Count > 0) { + item = _objects.Pop(); + _inactiveLookup.Remove(item); + } + else { + item = _allocator.Alloc(); + _statistics.CountAll++; + _statistics.TotalCreatedCount++; + } + + _statistics.TotalGetCount++; } + + _options.OnGet?.Invoke(item); + return item; } public void Return(TClass obj) { ThrowHelper.ThrowIfNull(obj, nameof(obj)); + // Returning an item always ends its current use cycle before retention policy is applied. + if (obj is IDestroyable destroyable) { + destroyable.Destroy(); + } + + _options.OnReturn?.Invoke(obj); _allocator.Reset(obj); if (obj is IPoolObjectResetable resetable) { resetable.Reset(); } - if (obj is IDestroyable destroyable) { - destroyable.Destroy(); + + if (obj is Object pooledObject && pooledObject.Destroyed) { + _statistics.TotalReturnCount++; + _statistics.TotalDestroyedCount++; + _options.OnDestroy?.Invoke(obj); + return; } lock (_lockObject) { - if (_objects.Contains(obj)) { + if (_inactiveLookup.Contains(obj)) { + _statistics.TotalDuplicateReturnCount++; + return; + } + + _statistics.TotalReturnCount++; + + if (_objects.Count >= _options.MaxSize && _options.OverflowPolicy == ObjectPoolOverflowPolicy.DestroyReturned) { + _statistics.TotalDestroyedCount++; + _options.OnDestroy?.Invoke(obj); return; } + _objects.Push(obj); + _inactiveLookup.Add(obj); } } protected override void OnCreate() { _allocator = new TAllocator(); lock (_lockObject) { - _objects = new Stack(InitSize); - for (var i = 0; i < InitSize; i++) { - _objects.Push(_allocator.Alloc()); + _objects = new Stack(_options.InitialCapacity); + _inactiveLookup = new HashSet(); + _statistics = default; + for (var i = 0; i < _options.InitialCapacity; i++) { + var item = _allocator.Alloc(); + _objects.Push(item); + _inactiveLookup.Add(item); + _statistics.CountAll++; + _statistics.TotalCreatedCount++; } } } @@ -172,7 +291,17 @@ protected override void OnCreate() { protected override void OnDestroy() { lock (_lockObject) { _objects?.Clear(); + _inactiveLookup?.Clear(); _objects = null; + _inactiveLookup = null; + } + } + + public override ObjectPoolStatistics GetStatistics() { + lock (_lockObject) { + _statistics.CountInactive = _objects?.Count ?? 0; + _statistics.CountActive = _statistics.CountAll - _statistics.CountInactive; + return _statistics; } } @@ -186,4 +315,4 @@ protected override void OnReturnInternal(object obj) { Return(obj as TClass); } } -} \ No newline at end of file +} diff --git a/Assets/vFrame.Core/Runtime/ObjectPools/ObjectPoolDiagnostics.cs b/Assets/vFrame.Core/Runtime/ObjectPools/ObjectPoolDiagnostics.cs new file mode 100644 index 0000000..3175f2f --- /dev/null +++ b/Assets/vFrame.Core/Runtime/ObjectPools/ObjectPoolDiagnostics.cs @@ -0,0 +1,65 @@ +using System; + +namespace vFrame.Core.ObjectPools +{ + /// + /// Determines how the pool reacts when a returned item would exceed retained capacity. + /// + public enum ObjectPoolOverflowPolicy + { + Retain, + DestroyReturned + } + + /// + /// Observable pool diagnostics. The counters describe retention, destruction, reuse, and + /// duplicate-return behavior without exposing internal storage details. + /// + public struct ObjectPoolStatistics + { + public int CountAll; + public int CountInactive; + public int CountActive; + public int TotalGetCount; + public int TotalReturnCount; + public int TotalCreatedCount; + public int TotalDestroyedCount; + public int TotalDuplicateReturnCount; + + public bool HasActiveObjects => CountActive > 0; + + public bool HasRetainedObjects => CountInactive > 0; + + public bool HasObservedActivity => TotalGetCount > 0 || TotalReturnCount > 0 || TotalCreatedCount > 0 || + TotalDestroyedCount > 0 || TotalDuplicateReturnCount > 0; + } + + /// + /// Lightweight policy hooks for retained object-pool behavior. + /// + public sealed class ObjectPoolOptions where TClass : class + { + public int InitialCapacity { get; set; } = 128; + public int MaxSize { get; set; } = 128; + + /// + /// Applies when a returned item would exceed retained capacity. + /// + public ObjectPoolOverflowPolicy OverflowPolicy { get; set; } = ObjectPoolOverflowPolicy.DestroyReturned; + + /// + /// Runs when an item is handed out for a new use cycle. + /// + public Action OnGet { get; set; } + + /// + /// Runs on return before pool-managed reset / retention policy completes. + /// + public Action OnReturn { get; set; } + + /// + /// Runs when pool policy destroys or discards a returned item instead of retaining it. + /// + public Action OnDestroy { get; set; } + } +} diff --git a/Assets/vFrame.Core/Runtime/ObjectPools/ObjectPoolDiagnostics.cs.meta b/Assets/vFrame.Core/Runtime/ObjectPools/ObjectPoolDiagnostics.cs.meta new file mode 100644 index 0000000..3308506 --- /dev/null +++ b/Assets/vFrame.Core/Runtime/ObjectPools/ObjectPoolDiagnostics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ca6781e66f84d42ae28cf78b75c9625 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2b92fbe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,152 @@ + +## Project + +**vFrame.Core Improvement Program** + +This project turns `vFrame.Core` into a more focused, modern, and sustainable core library for the broader `vFrame` ecosystem. It is not about expanding the repository into a full Unity framework, but about clarifying long-term boundaries, preserving the systems that matter, and planning the phased evolution of core lifecycle, messaging, pooling, logging, diagnostics, and Unity runtime integration. + +The current initiative is planning-first: all 55 improvement topics have been reviewed, consensus has been captured, and the next step is to convert that consensus into a phased execution roadmap for the repository. + +**Core Value:** Keep `vFrame.Core` as a lightweight, high-performance, extensible, self-directed core component library that provides stable foundational semantics for the entire `vFrame` ecosystem. + +### Constraints + +- **Repository Role**: `vFrame.Core` must remain a lightweight core library — it should provide primitives and conventions, not absorb unrelated framework responsibilities. +- **Architecture Boundary**: `vFrame.Core` must stay free of `UnityEngine` dependencies, while Unity-specific behavior stays in `vFrame.Core.Unity`. +- **Style Preservation**: Changes should preserve the repository's own design language and lifecycle philosophy instead of mechanically copying third-party systems. +- **Execution Order**: Testing, benchmark, CI, and debug-mode baselines should be established before major semantic refactors. +- **Migration Discipline**: Deprecated subsystems need explicit retirement or migration planning instead of being silently abandoned in place. +- **Planning Source**: The official plan should derive from the completed 55-item review record rather than from speculative re-discovery. + + + +## Technology Stack + +## Recommended Stack +### Core Framework +| Technology | Version | Purpose | Why | +|------------|---------|---------|-----| +| Unity Editor | `2021.3.32f1` baseline, plan target `Unity 6 LTS` | Authoritative compilation and package validation | Keep the current shipping line stable on the existing LTS while planning modernization against the current long-term Unity direction. Do not hard-switch the repo to Unity 6 before the validation baseline exists. | +| C# assemblies via `.asmdef` | Existing package-local assemblies | Runtime boundary enforcement | Unity requires `.asmdef` for package code, and asmdefs are the cleanest way to keep `Assets/vFrame.Core` pure C# and `Assets/vFrame.Core.Unity` Unity-only. This is the right seam for modernization work, test isolation, and package-scoped coverage. | +| UPM package layout | Standard UPM structure | Package-first library evolution | This repo ships as packages, so modernization should follow Unity’s package conventions instead of project-only layouts. That keeps tests, docs, samples, and future migration notes shippable with the package. | +### Validation Stack +| Technology | Version | Purpose | Why | +|------------|---------|---------|-----| +| Unity Test Framework | `1.7.x` | Functional and regression testing | This is the standard Unity-native test layer for EditMode and PlayMode. Use it because it is the authoritative, supported path for runtime/library validation from editor and CLI. | +| NUnit model inside UTF | Built into UTF custom NUnit base | Unit-style assertions and fixtures | Use plain NUnit-style EditMode tests for `vFrame.Core` logic because this package is a library, not an app. It keeps tests fast and keeps Unity runtime coupling low. | +| Unity Performance Testing package | `3.0.3` for `2021.3` compatibility | Benchmarking allocations and hot paths | vFrame.Core is allocation-conscious. Use Unity’s performance package for repeatable regression checks on pools, dispatch, and lifecycle hotspots instead of ad hoc stopwatch tests. | +| Unity Code Coverage | `1.3.x` | Coverage reports for EditMode and PlayMode | Use coverage to verify the safety baseline before large refactors. It is especially important here because the modernization plan explicitly calls for tests and diagnostics before semantic change. | +### Static Analysis And Quality Gates +| Technology | Version | Purpose | Why | +|------------|---------|---------|-----| +| `.editorconfig` | Existing repo standard | Formatting and naming authority | Keep this as the primary style contract. It already matches the repository and is lower-friction than introducing a parallel style tool. | +| Unity Roslyn analyzer support | Current Unity-supported analyzer pipeline | Editor-visible diagnostics | Unity supports Roslyn analyzers and rulesets. Use this for repository-specific correctness and API-usage rules that matter to the modernization program. | +| `Microsoft.CodeAnalysis.NetAnalyzers` | Current stable package, pinned intentionally | Library-oriented code quality rules | Use this selectively for design, reliability, and performance diagnostics. Pin it deliberately rather than taking floating analyzer changes, because modernization needs stable signals, not surprise rule churn. | +| Custom vFrame analyzers | Phase-2+ optional | Enforce architectural invariants | Add custom analyzers only after the new lifecycle/message/pooling rules are stable. This is the right way to stop `UnityEngine` leakage into core and catch misuse of lifecycle contracts automatically. | +### Infrastructure +| Technology | Version | Purpose | Why | +|------------|---------|---------|-----| +| GitHub Actions | Current | CI orchestration | The repo already uses GitHub Actions. Extend it rather than adding a second CI system. That keeps automation close to the package publishing workflow already in place. | +| GameCI Unity Test Runner | `v4` | Headless Unity test execution in CI | This is the standard GitHub Actions path for Unity testing. Use it to add EditMode, PlayMode, and coverage jobs without inventing custom Docker or shell glue first. | +| Unity batchmode CLI | Unity editor CLI | Local authoritative compile/test gate | Keep Unity batchmode as the source of truth locally and in fallback CI paths. It validates the actual Unity compilation environment, which matters more than standalone .NET compilation for this repo. | +| Artifact publishing in CI | `actions/upload-artifact@v4` | Preserve test, coverage, and benchmark outputs | Modernization work needs historical visibility. Save logs, coverage reports, and performance outputs as artifacts so regressions are reviewable. | +### Supporting Libraries And Tools +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| JetBrains Rider or Visual Studio | Current supported IDE | Analyzer-aware Unity C# workflow | Use one of Unity’s supported IDEs because Unity’s analyzer integration officially targets them. | +| Benchmark fixtures in `Tests/Editor` and `Tests/Runtime` asmdefs | Repo-local | Isolated validation layers | Use dedicated test assemblies per package boundary once tests are introduced. Keep most core-library tests in EditMode; reserve PlayMode for true Unity runtime behavior. | +| Samples and migration docs in UPM package structure | Repo-local | Consumer validation and upgrade guidance | Add samples only after core behavior stabilizes. They should prove the new typed messaging, lifetime, and pooling contracts to downstream users. | +## Prescriptive Stack Decision +## What Not To Use +| Category | Do Not Use | Why Not | +|----------|------------|---------| +| Dependency injection frameworks | `Zenject`, `Extenject`, `VContainer` as modernization foundation | The project context explicitly rejects external frameworks as the primary direction. They would pull the repo toward framework-level control instead of lightweight core primitives. | +| Reactive stack as core contract | `UniRx` / reactive-first architecture | Reactive APIs can be useful at the edges, but using them as the modernization backbone would over-weight a library that wants typed messages as the main interaction model. | +| Standalone `.NET` test-only validation | `dotnet test` as primary gate | This repo’s source of truth is Unity compilation and Unity package behavior. Standalone .NET checks can be supplemental later, but they cannot replace Unity validation. | +| Heavy formatting or style overlays | `StyleCop`-driven mass conformance | The repo already has `.editorconfig` and a distinct house style. Adding heavy style tooling now creates noise and slows modernization without improving architectural safety much. | +| Large observability ecosystems | `Serilog`-style external logging stack as core dependency | The program wants stronger observability, but the repo should evolve its own lightweight abstractions rather than adopt a heavy external runtime dependency tree. | +| Addressables/YooAsset in this repo’s modernization baseline | As a core stack decision for `vFrame.Core` itself | These matter for resource systems, not for the foundational modernization baseline of this library repo. They would distract from lifecycle, messages, pooling, diagnostics, and CI. | +## Alternatives Considered +| Category | Recommended | Alternative | Why Not | +|----------|-------------|-------------|---------| +| Validation | Unity Test Framework | Custom harnesses only | UTF is official, CLI-compatible, and package-aware. Custom harnesses increase maintenance cost and weaken standardization. | +| Performance regression checks | Unity Performance Testing | Manual stopwatch/profiler screenshots | Manual measurement is not reliable enough for a modernization program with hot-path guarantees. | +| Coverage | Unity Code Coverage | No coverage gate | Without coverage, refactors become trust-based instead of evidence-based. | +| CI | GitHub Actions + GameCI | Bespoke runners/scripts first | GameCI is the standard shortest path to Unity CI on GitHub; custom plumbing should only appear where GameCI falls short. | +| Static analysis | Roslyn analyzers + pinned NetAnalyzers | Pure manual review | Manual review alone will not consistently enforce architectural boundaries over time. | +| Unity version strategy | Stabilize on current LTS, then migrate | Immediate Unity 6 jump | A direct jump mixes platform migration risk with architecture refactor risk. Split those risks. | +## Recommended Validation Layers +### Layer 1: Compile Gate +- Unity batchmode compile on every PR. +- Fail fast on assembly/reference breakage. +### Layer 2: EditMode Tests +- Cover `BaseObject`, typed messages, pool semantics, logger abstractions, and guard/exception paths. +- This should become the main regression net because most core behavior is engine-agnostic. +### Layer 3: PlayMode Tests +- Cover only Unity-specific behavior in `vFrame.Core.Unity`, especially `SpawnPools`, lifetime integration with `GameObject`, and Unity logger bridging. +- Keep this layer smaller and slower by design. +### Layer 4: Performance Benchmarks +- Benchmark dispatch, subscription churn, pool rent/return, lifecycle creation/destruction, and common allocation paths. +- Treat benchmark regressions as review signals, not as the first hard gate. +### Layer 5: Coverage And Diagnostics +- Generate coverage reports for core assemblies. +- Publish logs, coverage, and benchmark artifacts in CI. +- Add debug/development-only instrumentation for subscriptions and pools before refactoring their internals. +## Installation +# Add/update Unity validation packages in Packages/manifest.json +# Core validation +# Analyzer direction +# Add pinned analyzer package/assets only after deciding the exact ruleset scope. +## Confidence Assessment +| Area | Confidence | Notes | +|------|------------|-------| +| Unity-native testing stack | HIGH | Verified with current Unity docs for UTF, performance testing, and coverage. | +| UPM/asmdef package-first structure | HIGH | Verified with current Unity package and asmdef docs. | +| Roslyn analyzer support in Unity | HIGH | Verified with current Unity docs. | +| GitHub Actions + GameCI recommendation | MEDIUM | Strong current ecosystem standard and current docs, but third-party rather than Unity-official. | +| Unity 6 LTS as target modernization lane | MEDIUM | Strong strategic recommendation for 2025-style planning, but this repo should not switch immediately because current validated baseline is 2021 LTS. | +## Sources +- Unity Manual: Testing your code — https://docs.unity3d.com/6000.2/Documentation/Manual/test-framework/test-framework-introduction.html +- Unity Test Framework package docs — https://docs.unity3d.com/Packages/com.unity.test-framework@1.7/manual/index.html +- Unity Performance Testing package docs — https://docs.unity3d.com/Packages/com.unity.test-framework.performance@3.3/manual/index.html +- Unity Code Coverage docs — https://docs.unity3d.com/Packages/com.unity.testtools.codecoverage@1.3/manual/index.html +- Unity Manual: Create or edit the assembly definitions — https://docs.unity3d.com/Manual/cus-asmdef.html +- Unity Manual: Package layout for UPM packages — https://docs.unity3d.com/Manual/cus-layout.html +- Unity Manual: Roslyn analyzers and source generators — https://docs.unity3d.com/Manual/roslyn-analyzers.html +- Microsoft Learn: Code analysis in .NET — https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/overview +- GameCI docs: Unity Test Runner v4 — https://game.ci/docs/github/test-runner/ + + + +## Conventions + +Conventions not yet established. Will populate as patterns emerge during development. + + + +## Architecture + +Architecture not yet mapped. Follow existing patterns found in the codebase. + + + +## GSD Workflow Enforcement + +Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync. + +Use these entry points: +- `/gsd:quick` for small fixes, doc updates, and ad-hoc tasks +- `/gsd:debug` for investigation and bug fixing +- `/gsd:execute-phase` for planned phase work + +Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it. + + + + + +## Developer Profile + +> Profile not yet configured. Run `/gsd:profile-user` to generate your developer profile. +> This section is managed by `generate-claude-profile` -- do not edit manually. + diff --git a/ProjectSettings/ProjectVersion.txt b/ProjectSettings/ProjectVersion.txt index 265377b..587f809 100644 --- a/ProjectSettings/ProjectVersion.txt +++ b/ProjectSettings/ProjectVersion.txt @@ -1,2 +1,2 @@ -m_EditorVersion: 2021.3.32f1 -m_EditorVersionWithRevision: 2021.3.32f1 (3b9dae9532f5) +m_EditorVersion: 2022.3.62f3 +m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7) diff --git a/ProjectSettings/SceneTemplateSettings.json b/ProjectSettings/SceneTemplateSettings.json new file mode 100644 index 0000000..5e97f83 --- /dev/null +++ b/ProjectSettings/SceneTemplateSettings.json @@ -0,0 +1,121 @@ +{ + "templatePinStates": [], + "dependencyTypeInfos": [ + { + "userAdded": false, + "type": "UnityEngine.AnimationClip", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEditor.Animations.AnimatorController", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.AnimatorOverrideController", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEditor.Audio.AudioMixerController", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.ComputeShader", + "defaultInstantiationMode": 1 + }, + { + "userAdded": false, + "type": "UnityEngine.Cubemap", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.GameObject", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEditor.LightingDataAsset", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.LightingSettings", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Material", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEditor.MonoScript", + "defaultInstantiationMode": 1 + }, + { + "userAdded": false, + "type": "UnityEngine.PhysicMaterial", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.PhysicsMaterial2D", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Rendering.PostProcessing.PostProcessProfile", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Rendering.PostProcessing.PostProcessResources", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Rendering.VolumeProfile", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEditor.SceneAsset", + "defaultInstantiationMode": 1 + }, + { + "userAdded": false, + "type": "UnityEngine.Shader", + "defaultInstantiationMode": 1 + }, + { + "userAdded": false, + "type": "UnityEngine.ShaderVariantCollection", + "defaultInstantiationMode": 1 + }, + { + "userAdded": false, + "type": "UnityEngine.Texture", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Texture2D", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Timeline.TimelineAsset", + "defaultInstantiationMode": 0 + } + ], + "defaultDependencyTypeInfo": { + "userAdded": false, + "type": "", + "defaultInstantiationMode": 1 + }, + "newSceneOverride": 0 +} \ No newline at end of file diff --git a/README.md b/README.md index 7ff5080..3d9aff8 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,106 @@ 另外,该 Package 还依赖一些第三方的链接库,已经打包成 unitypackage 文件,可在 [release](https://github.com/VyronLee/vFrame.Core/releases) 中下载导入。 +## 首批验证基线 + +- 当前项目版本以 `ProjectSettings/ProjectVersion.txt` 为准,现行为 `2022.3.62f3`。 +- 核心层自动化测试与 Unity 侧测试入口分离:`Assets/vFrame.Core.Tests/EditMode/vFrame.Core.Tests.EditMode.asmdef` 仅引用 `vFrame.Core`,Unity 依赖测试通过局部 Unity 测试程序集承载。 +- 首批性能基线入口放在 `Assets/vFrame.Core/Editor/Benchmarks/`,覆盖交互派发、通用对象池以及 `SpawnPools` 热点路径。 +- CI 最低基线位于 `.github/workflows/validation-baseline.yml`,用于守护 EditMode 与 PlayMode 的最小验证路径。 +- `Debug/Development` 模式允许更强的断言、误用检测与诊断信息,以优先暴露问题。 +- `Release` 模式默认应保持轻量运行路径,避免在热点路径上启用高成本诊断,除非显式按策略开启。 + +### 基线入口 + +- EditMode:`& "C:\Program Files\Unity\Hub\Editor\2022.3.62f3\Editor\Unity.exe" -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform EditMode -testFilter "vFrame.Core.Tests.EditMode" -logFile - -testResults "TestResults/editmode-results.xml"` +- PlayMode:`& "C:\Program Files\Unity\Hub\Editor\2022.3.62f3\Editor\Unity.exe" -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform PlayMode -testFilter "vFrame.Core.Tests.PlayMode" -logFile - -testResults "TestResults/playmode-results.xml"` +- 基准入口:Unity Editor 菜单 `Tools/vFrame/Benchmarks/Run Core Benchmarks`,代码位于 `Assets/vFrame.Core/Editor/Benchmarks/`。 + +### 诊断符号 + +- `DEBUG_SPAWNPOOLS`:启用 `SpawnPools` 相关诊断日志。 +- `DEBUG_COROUTINE_POOL`:启用 `CoroutinePool` 相关诊断日志。 +- `PERF_PROFILE`:启用性能采样辅助路径。 + +这些符号应优先用于 `Debug/Development` 流程;`Release` 默认保持关闭,以控制热点路径开销。 + +## 首批核心契约 + +- 生命周期语义现统一使用三种意图词汇:`owned` 表示对象负责销毁和清理;`borrowed` 表示仅借用外部依赖或上下文,不接管其生命周期;`lifetime-bound` 表示资源跟随某个 `ILifetime` 边界结束。 +- 对 `BaseObject` 而言,`Own(...)` 用于注册 `owned` 清理项;`OwnLifetime(...)` 用于表达 `lifetime-bound` 资源;未注册到生命周期边界的外部依赖应视为 `borrowed`。 +- 对 `EventDispatcher` 而言,裸订阅是显式的 `borrowed`/调用方自管模式;owner 订阅是跟随 `BaseObject` 销毁的 `lifetime-bound` 模式;传入 `ILifetime` 的订阅是显式的 `lifetime-bound` 模式。 +- 交互路径现明确分层:typed message 是新功能的默认路径;`int eventId` 是保留的 compatibility 迁移路径;`Vote` / `Decision` 是显式保留的规则与裁决语义,不应被当作普通事件或 typed message 替代。 +- `BaseObject` 现已明确为终止型生命周期:对象销毁后不可再次 `Create(...)`,依赖生命周期的访问会优先暴露已销毁状态。 +- 轻量生命周期分组通过 `ILifetime` / `Lifetime` 提供,用于表达父子所有权与共享清理边界,而不是引入完整 scope 容器框架。 +- 通用对象池现支持容量上限、溢出销毁策略、统计查询与重复回收检测;对 `BaseObject` 这类终止型对象,回池会结束生命周期并阻止原实例再次复用。 +- 核心交互系统新增 typed message 主路径:`Subscribe(...)` / `Publish(...)` 用于新交互语义;`int eventId` 路径保留为兼容层。 +- owner 绑定订阅当前明确收敛为 `BaseObject` owner;非 owner 托管场景应使用 `ILifetime`,裸订阅仍允许并由调用方自行管理。 +- `Vote` 语义被保留,并通过 `Decision` 别名强调其“规则决策流”定位,而不是普通消息派发。 + +## 延后事项 + +- `SpawnPools` 现代化仍延后,等待核心生命周期、对象池与交互契约稳定后再继续。 +- 日志现代化、统一诊断工具、历史模块清理不在首批实施波次中。 +- `Container / Component`、`Localization`、`MultiThreading / Task`、`Patch`、`Download` 的彻底迁移或退出,仅记录边界与后续说明,不在本轮集中改造。 + +## 历史模块退役地图 + +- `Container / Component`:`retiring`。不再作为新语义设计的投资方向;保留仅用于兼容历史结构,新的生命周期与所有权语义以 `BaseObject` / `Lifetime` 为准。 +- `Profiles`:`retiring`。不再作为 retained 核心能力继续演进;如仍有历史压缩/配置路径引用,应视为兼容边界而非未来方向。 +- `Localization`:`retired for new investment`。本仓库不再把本地化作为长期核心能力推进;新工作不应建立在该模块之上。 +- `MultiThreading / Task`:`retiring`。保留历史兼容用途,但不再作为现代化主路径;新的 retained 核心路径优先使用已明确的生命周期、typed interaction 与对象池语义,而不是继续扩散自定义任务系统。 +- `Patch`:`compatibility-only`。继续留在 `vFrame.Core.Unity` 仅用于历史资源更新链路兼容,不再作为 retained Unity 运行时核心方向。 +- `Download`:`compatibility-only`。仅作为 `Patch` 等历史链路的暂存依赖保留,不再作为新的战略能力建设方向。 + +### 退出方向 + +- 生命周期与所有权:迁移到 `BaseObject` / `Lifetime`,使用 `owned`、`borrowed`、`lifetime-bound` 语义组织对象关系。 +- 交互:新功能优先迁移到 typed message 主路径,使用 `Subscribe(...)` / `Publish(...)`;`int eventId` 仅保留为兼容层。 +- 池化:统一迁移到已明确语义的通用 `ObjectPool` 与 Unity 侧 `SpawnPools`;其中 `SpawnPools` 仅负责实例复用,不承担资源管理。 +- 日志与诊断:迁移到 Core/Core.Unity 分层日志与轻量 diagnostics hooks,而不是继续在历史模块中扩散新的观测实现。 + +### 当前仍保留的兼容边界 + +- `EventDispatcher -> Component`:该历史兼容边界已移除;`EventDispatcher` 现直接基于 `BaseObject` / `Lifetime` 运行,交互宿主不再依赖 `Component` 继承。 +- `BlockBasedCompression -> Profiles`:当前仍保留对 `Profiles` 的历史配置/元数据依赖;该依赖已标注为 compatibility-only。 +- `AsynchronousBlockBasedCompression -> MultiThreading / Task`:当前异步分块压缩仍复用历史任务运行器;后续如继续收敛,应优先朝 retained 运行时边界演进,而不是继续扩展该任务线。 +- `Patch -> Download`:`Patch` 仍通过 `Download` 工作,这条链路被明确保留为历史兼容边界,而不是 retained Unity runtime 的长期方向。 + +## 迁移指南 + +### 推荐迁移顺序 + +- 第一步:先统一生命周期语义。新对象统一收敛到 `BaseObject` / `Lifetime`,并明确 `owned`、`borrowed`、`lifetime-bound`。 +- 第二步:把新交互迁移到 typed message 主路径。优先使用 `Subscribe(...)` / `Publish(...)`,仅在历史兼容场景继续保留 `int eventId`。 +- 第三步:把可复用实例迁移到已明确语义的 `ObjectPool` / `SpawnPools`。其中 `SpawnPools` 只负责 Unity 实例复用,不承担资源定位、下载、补丁或版本管理。 +- 第四步:把日志和运行时诊断迁移到 Core/Core.Unity 分层日志与轻量 diagnostics hooks,不要在退役模块中继续增加新的监控实现。 + +### 新工作最佳实践 + +- 不要再把 `Container / Component`、`Profiles`、`Localization`、`MultiThreading / Task`、`Patch`、`Download` 作为新功能设计起点。 +- 若历史链路必须暂时保留这些模块,应在代码或文档中显式标注为 compatibility-only boundary。 +- 对运行时对象关系优先表达生命周期边界,再决定交互、池化和 Unity 适配层的组织方式。 +- 对 Unity 侧复用优先使用 `SpawnPools`;对 Core 层复用优先使用通用 `ObjectPool`。 +- 对交互优先使用 typed messages;对规则裁决保留 `Vote` / `Decision` 语义;不要继续扩大 legacy `eventId` 设计面。 +- `EventDispatcher` 现需要像其他 retained runtime 对象一样显式 `Create()` / `Destroy()`;若需要把订阅跟随外部宿主结束,应绑定到 `BaseObject` owner 或 `ILifetime`,而不是依赖 `Component` 继承关系。 + +### 历史模块到 retained 系统的迁移方向 + +- `Container / Component` -> `BaseObject` / `Lifetime` / 明确所有权边界 +- `Profiles` -> 更局部的显式配置与运行时语义,不再把 `Profiles` 作为核心扩展方向 +- `Localization` -> 仓库外或上层产品能力;本仓库不再继续投资 +- `MultiThreading / Task` -> 生命周期清晰的 retained runtime 流程;避免继续把自定义任务系统扩展成核心依赖 +- `Patch / Download` -> 历史兼容链路;新工作应优先投资 retained Unity runtime 与上游生态模块边界,而不是继续加深该链路 + +## 当前验证结果 + +- 已确认本机存在 Unity `2022.3.62f3` 编辑器。 +- 已尝试通过 Unity batch mode 运行 EditMode 与 PlayMode 测试。 +- 当前批处理验证被阻塞:项目已被另一 Unity 实例占用,Unity 拒绝同时打开同一工程。 +- 完成最终验收前,可在关闭占用该工程的 Unity 实例后重新运行: + - `& "C:\Program Files\Unity\Hub\Editor\2022.3.62f3\Editor\Unity.exe" -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform EditMode -testFilter "vFrame.Core.Tests.EditMode" -logFile - -testResults "TestResults/editmode-results.xml"` + - `& "C:\Program Files\Unity\Hub\Editor\2022.3.62f3\Editor\Unity.exe" -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform PlayMode -testFilter "vFrame.Core.Tests.PlayMode" -logFile - -testResults "TestResults/playmode-results.xml"` + ## vFrame Core 该 Package 内所有组件均不依赖 Unity 相关 DLL,也就是说可用于非 Unity 相关项目使用,或者是作为 Server Side Only 的项目依赖库集成到应用中(如:战斗逻辑CS共用) @@ -275,7 +375,7 @@ var inst = ObjectPool.Shared.Get(); ObjectPool.Shared.Return(inst); ``` -如果对象是继承了`IPoolObjectResetable`或者`IBaseObject`,放回对象池时也会自动调用`Reset`或者`Destroy`方法(对于这类情况,可在不实现`IPoolObjectAllocator`的情况下也有对象重置的功能) +如果对象实现了`IPoolObjectResetable`,放回对象池时会自动调用`Reset`;如果对象属于 `IBaseObject` / `BaseObject` 这类 retained lifecycle 对象,放回对象池时会触发 `Destroy` 以结束当前生命周期,而不是把 `BaseObject` 当作轻量重置基类使用。 此外,该仓库中也提供了一些常用的内置对象池,包括有: diff --git a/README_en.md b/README_en.md index dc9e119..c8f29e3 100644 --- a/README_en.md +++ b/README_en.md @@ -34,6 +34,106 @@ If you need to specify a version, just add the version number after the link. Moreover, this Package relies on certain external libraries that have been compiled into a unitypackage file. You can download and import this file from the [release](https://github.com/VyronLee/vFrame.Core/releases) page on GitHub. +## First-Wave Validation Baseline + +- The effective project version is taken from `ProjectSettings/ProjectVersion.txt`, which is currently `2022.3.62f3`. +- Core-layer automated tests and Unity-side test entry paths are intentionally split: `Assets/vFrame.Core.Tests/EditMode/vFrame.Core.Tests.EditMode.asmdef` now references only `vFrame.Core`, while Unity-dependent coverage lives in a narrower Unity-scoped test assembly. +- The initial performance baseline entry points live under `Assets/vFrame.Core/Editor/Benchmarks/` and cover interaction dispatch, generic object-pool, and `SpawnPools` hot paths. +- The minimum CI guardrail lives in `.github/workflows/validation-baseline.yml` and protects the first-wave EditMode and PlayMode validation paths. +- `Debug/Development` mode may spend more on assertions, misuse detection, and diagnostics to improve issue discovery. +- `Release` mode should keep default runtime behavior lightweight and avoid diagnostics-heavy overhead on hot paths unless explicitly enabled by policy. + +### Baseline Entry Points + +- EditMode: `& "C:\Program Files\Unity\Hub\Editor\2022.3.62f3\Editor\Unity.exe" -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform EditMode -testFilter "vFrame.Core.Tests.EditMode" -logFile - -testResults "TestResults/editmode-results.xml"` +- PlayMode: `& "C:\Program Files\Unity\Hub\Editor\2022.3.62f3\Editor\Unity.exe" -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform PlayMode -testFilter "vFrame.Core.Tests.PlayMode" -logFile - -testResults "TestResults/playmode-results.xml"` +- Benchmarks: Unity Editor menu `Tools/vFrame/Benchmarks/Run Core Benchmarks`, with source under `Assets/vFrame.Core/Editor/Benchmarks/`. + +### Diagnostics Symbols + +- `DEBUG_SPAWNPOOLS`: enables `SpawnPools` diagnostics logging. +- `DEBUG_COROUTINE_POOL`: enables `CoroutinePool` diagnostics logging. +- `PERF_PROFILE`: enables performance profiling helper paths. + +These symbols should primarily be enabled in `Debug/Development` flows; `Release` keeps them off by default to avoid extra hot-path overhead. + +## First-Wave Core Contracts + +- Lifecycle intent now uses three explicit terms: `owned` means the object is responsible for teardown, `borrowed` means the dependency is used without taking over its lifecycle, and `lifetime-bound` means the resource ends with a specific `ILifetime` boundary. +- In `BaseObject`, `Own(...)` registers `owned` cleanup, `OwnLifetime(...)` expresses `lifetime-bound` resources, and dependencies not registered to a lifecycle boundary should be treated as `borrowed`. +- In `EventDispatcher`, bare subscriptions are the explicit `borrowed` / caller-managed mode, owner subscriptions are `lifetime-bound` to a `BaseObject`, and `ILifetime` subscriptions are explicitly `lifetime-bound` to the supplied lifetime. +- Interaction paths are now intentionally split: typed messages are the default path for new work, `int eventId` remains a retained compatibility migration path, and `Vote` / `Decision` remain explicit rule and decision semantics rather than ordinary event or typed-message dispatch. +- `BaseObject` now acts as a terminal lifecycle primitive: once destroyed, an instance cannot be `Create(...)`-ed again, and lifecycle-dependent access reports the destroyed state first. +- Lightweight ownership and grouped cleanup are exposed through `ILifetime` / `Lifetime`, which provide parent-child and shared cleanup boundaries without introducing a full scope framework. +- Generic object pools now support capacity limits, overflow destruction policy, statistics queries, and duplicate-return detection; terminal lifecycle objects such as `BaseObject` instances are ended on return and are not reused as the same live instance. +- The core interaction system now has a typed-message primary path through `Subscribe(...)` / `Publish(...)`; the old `int eventId` path remains as a compatibility layer. +- Owner-bound typed subscriptions are intentionally narrowed to `BaseObject` owners; unmanaged cases should use `ILifetime`, while bare subscriptions remain explicitly caller-managed. +- Vote semantics are retained and clarified through `Decision` aliases to emphasize their rule/approval-flow role rather than treating them as ordinary dispatch. + +## Deferred Systems + +- `SpawnPools` modernization remains deferred until the lifecycle, pooling, and interaction foundations stabilize. +- Logging modernization, unified diagnostics tooling, and broad historical module cleanup are not part of the first implementation wave. +- `Container / Component`, `Localization`, `MultiThreading / Task`, `Patch`, and `Download` are only documented for later migration or exit-path work in this wave. + +## Retirement Map For Historical Modules + +- `Container / Component`: `retiring`. It is no longer a strategic direction for new semantics; any remaining usage is compatibility-oriented, while new lifecycle and ownership work is centered on `BaseObject` / `Lifetime`. +- `Profiles`: `retiring`. It is no longer a retained core investment area; if older compression or configuration paths still reference it, treat that as a compatibility boundary rather than future direction. +- `Localization`: `retired for new investment`. This repository no longer treats localization as a long-term core capability, and new work should not build on it. +- `MultiThreading / Task`: `retiring`. Historical compatibility remains possible, but it is no longer the modernization path; retained runtime work should prefer the clarified lifecycle, typed interaction, and pooling directions instead of expanding the custom task line. +- `Patch`: `compatibility-only`. It remains in `vFrame.Core.Unity` only to support historical update pipelines and is no longer a retained Unity runtime investment area. +- `Download`: `compatibility-only`. It remains only as a temporary dependency for historical flows such as `Patch`, not as a strategic capability for new work. + +### Exit Direction + +- Lifecycle and ownership: move toward `BaseObject` / `Lifetime` and organize relationships with `owned`, `borrowed`, and `lifetime-bound` intent. +- Interaction: migrate new work toward the typed-message primary path through `Subscribe(...)` / `Publish(...)`; keep `int eventId` only as a compatibility layer. +- Pooling: move toward the clarified generic `ObjectPool` semantics and Unity-side `SpawnPools`, where `SpawnPools` is strictly for instance reuse rather than resource ownership. +- Logging and diagnostics: move toward the Core/Core.Unity logging split and lightweight diagnostics hooks instead of expanding observability inside retiring modules. + +### Remaining Compatibility Boundaries + +- `EventDispatcher -> Component`: this historical compatibility boundary has been removed; `EventDispatcher` now runs directly on `BaseObject` / `Lifetime`, so retained interaction hosting no longer depends on `Component` inheritance. +- `BlockBasedCompression -> Profiles`: the block-based compression path still carries a historical `Profiles` dependency for older metadata/configuration behavior; it is now explicitly marked as compatibility-only. +- `AsynchronousBlockBasedCompression -> MultiThreading / Task`: async block-based compression still reuses the historical task runner; if this area evolves further, it should move toward retained runtime boundaries instead of expanding the legacy task line. +- `Patch -> Download`: `Patch` still works through `Download`; this chain is now explicitly treated as a historical compatibility boundary rather than a retained Unity runtime direction. + +## Migration Guide + +### Recommended Adoption Order + +- Step 1: normalize lifecycle semantics first. Move new objects onto `BaseObject` / `Lifetime` and make `owned`, `borrowed`, and `lifetime-bound` intent explicit. +- Step 2: move new interaction work onto the typed-message primary path. Prefer `Subscribe(...)` / `Publish(...)`, and keep `int eventId` only for legacy compatibility. +- Step 3: move reusable instances onto the clarified `ObjectPool` / `SpawnPools` semantics. `SpawnPools` is only for Unity instance reuse and should not absorb resource location, download, patching, or version ownership. +- Step 4: move logging and runtime inspection onto the Core/Core.Unity logging split and lightweight diagnostics hooks instead of adding new observability behavior inside retiring modules. + +### Best Practices For New Work + +- Do not use `Container / Component`, `Profiles`, `Localization`, `MultiThreading / Task`, `Patch`, or `Download` as the starting point for new design work. +- If a historical path must remain temporarily, mark it explicitly as a compatibility-only boundary in code or docs. +- Establish lifecycle ownership boundaries first, then layer interaction, pooling, and Unity adaptation on top of them. +- Prefer generic `ObjectPool` for core reuse and `SpawnPools` for Unity-side instance reuse. +- Prefer typed messages for new interaction semantics, keep `Vote` / `Decision` for rule or approval flows, and avoid expanding the legacy `eventId` surface. +- `EventDispatcher` now follows the same explicit `Create()` / `Destroy()` lifecycle as other retained runtime objects; bind subscriptions to a `BaseObject` owner or `ILifetime` when they should end with an external host. + +### Historical Module To Retained-System Direction + +- `Container / Component` -> `BaseObject` / `Lifetime` / explicit ownership boundaries +- `Profiles` -> narrower explicit configuration and runtime semantics rather than continuing `Profiles` as a core extension point +- `Localization` -> out-of-repo or upper-layer product capability; this repository is no longer investing in it +- `MultiThreading / Task` -> retained runtime flows with clear lifecycle ownership instead of continued expansion of the historical task line +- `Patch / Download` -> historical compatibility chain only; new work should favor retained Unity runtime boundaries and upstream ecosystem responsibilities instead of deepening this path + +## Current Validation Result + +- Unity `2022.3.62f3` is available on this machine. +- Batch-mode EditMode and PlayMode test runs were attempted. +- Final batch validation is currently blocked because another Unity instance already has this project open, and Unity refuses concurrent access to the same project. +- After closing the other Unity instance, rerun: + - `& "C:\Program Files\Unity\Hub\Editor\2022.3.62f3\Editor\Unity.exe" -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform EditMode -testFilter "vFrame.Core.Tests.EditMode" -logFile - -testResults "TestResults/editmode-results.xml"` + - `& "C:\Program Files\Unity\Hub\Editor\2022.3.62f3\Editor\Unity.exe" -batchmode -quit -projectPath "D:\Workspace\vFrame\vFrame.Core" -runTests -testPlatform PlayMode -testFilter "vFrame.Core.Tests.PlayMode" -logFile - -testResults "TestResults/playmode-results.xml"` + ## vFrame Core All components within this Package do not depend on Unity-related DLLs, meaning they can be used for non-Unity related projects, or integrated into applications as a Server Side Only project dependency library (e.g., shared CS for combat logic). @@ -280,7 +380,7 @@ var inst = ObjectPool.Shared.Get(); ObjectPool.Shared.Return(inst); ``` -If the object inherits `IPoolObjectResetable` or `IBaseObject`, the `Reset` or `Destroy` method will also be called automatically when the object is returned to the pool (for such cases, the object can be reset without implementing `IPoolObjectAllocator`). +If an object implements `IPoolObjectResetable`, `Reset` is called automatically when the object is returned to the pool. If an object is an `IBaseObject` / `BaseObject` retained lifecycle object, returning it to the pool will call `Destroy` to end that lifecycle instead of treating `BaseObject` as a lightweight reset helper. In addition, this repository also provides some commonly used built-in object pools, including: @@ -405,4 +505,4 @@ public class Patcher ## License -[Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) \ No newline at end of file +[Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) diff --git a/openspec/changes/archive/2026-04-03-improve-runtime-stability/.openspec.yaml b/openspec/changes/archive/2026-04-03-improve-runtime-stability/.openspec.yaml deleted file mode 100644 index 6a5db8c..0000000 --- a/openspec/changes/archive/2026-04-03-improve-runtime-stability/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-02 diff --git a/openspec/changes/archive/2026-04-03-improve-runtime-stability/design.md b/openspec/changes/archive/2026-04-03-improve-runtime-stability/design.md deleted file mode 100644 index 7870ab7..0000000 --- a/openspec/changes/archive/2026-04-03-improve-runtime-stability/design.md +++ /dev/null @@ -1,76 +0,0 @@ -## Context - -`vFrame.Core` and `vFrame.Core.Unity` provide shared runtime infrastructure used across lifecycle management, async execution, coroutine orchestration, pooling, downloads, and patching. The current implementation is broadly functional, but several foundational code paths have weak or incorrect state transitions, including object creation success tracking, async completion signaling, coroutine queue mutation, and threaded task failure behavior. - -This change is cross-cutting because the affected code sits at the bottom of the library stack. A defect in these areas can propagate to many higher-level systems, and the current repository has no checked-in tests to lock expected behavior. The design therefore focuses on tightening runtime contracts for a narrow set of clearly incorrect behaviors while preserving existing public API shape wherever practical. - -## Goals / Non-Goals - -**Goals:** -- Make lifecycle and async state transitions internally consistent and predictable for `BaseObject`, `AsyncRequestCtrl`, `ThreadedTask`, and `CoroutinePool`. -- Correct known behavior mismatches in foundational runtime systems without introducing broad API churn. -- Establish a small but meaningful automated test baseline around the highest-risk runtime contracts in scope. -- Improve confidence in shared infrastructure so later refactors can build on a safer base. - -**Non-Goals:** -- Redesign the entire async model across the repository. -- Replace reflection-based container messaging with a new architecture in this change. -- Refactor `ParallelTaskRunner`, `ObjectPool`, `EventDispatcher`, or patch hash validation as part of this change. -- Introduce new external dependencies or major package-level compatibility changes. - -## Decisions - -### Fix correctness before broad refactoring -The first priority is to correct behavior that is clearly wrong rather than redesigning multiple subsystems at once. This keeps scope small and reduces the chance that a reliability improvement unintentionally becomes an architectural rewrite. - -Alternative considered: -- Perform a larger unification of async/task abstractions now. -Why not chosen: -- It would increase scope and make it harder to isolate regressions in a repository with no existing test baseline. - -### Preserve public API shape where possible, tighten runtime semantics underneath -The change should prefer compatible behavior fixes over signature changes. Existing APIs such as `BaseObject.Create`, `AsyncRequestCtrl`, and `ThreadedTask` remain recognizable, but their state guarantees become stricter and more internally coherent. - -Alternative considered: -- Add new replacement APIs and deprecate current ones immediately. -Why not chosen: -- That would expand the change into a migration project rather than a focused reliability improvement. - -### Define reliability through observable behavioral requirements -The change will codify behavior in OpenSpec around creation success, request completion/error dispatch, task termination on failure, and safe coroutine queue mutation. This lets the implementation and tests align to a stable contract rather than ad hoc fixes. - -Alternative considered: -- Treat these as implementation details only and skip spec-level requirements. -Why not chosen: -- The problems are behavioral, externally observable, and foundational enough to deserve explicit requirements. - -### Add focused tests only for the highest-risk infrastructure contracts -The initial test scope should cover the failure-prone paths most likely to regress: lifecycle creation failure, async finish/error routing, threaded task fault completion, and queue-safe coroutine cancellation. - -Alternative considered: -- Delay tests until after code fixes land. -Why not chosen: -- The repository currently lacks guardrails, so reliability work without tests would leave the same class of regressions likely to return. - -## Risks / Trade-offs - -- [Behavioral tightening may expose latent caller assumptions] -> Keep public APIs stable, document changed semantics, and target only clearly invalid prior behavior. -- [Cross-cutting fixes can create regressions in dependent systems] -> Add focused tests around each corrected runtime contract before or alongside implementation. -- [Some modules have no existing test harness] -> Start with the smallest viable Unity test assemblies and prioritize deterministic EditMode coverage. -- [Scope could expand into a full architecture cleanup] -> Keep this change limited to the four explicitly targeted subsystems and defer broader runtime improvements to follow-up changes. - -## Migration Plan - -This change is intended to be source-compatible for most consumers. Migration consists primarily of adopting stricter runtime expectations: - -1. Correct internal implementations to match the new reliability contract. -2. Add tests that prove the corrected behavior. -3. Validate the Unity project compiles cleanly after the changes. -4. Update documentation where current wording implies behavior that is no longer accurate or was previously incorrect. - -Rollback strategy: -- If a reliability fix unexpectedly breaks a consumer workflow, revert the specific behavioral correction rather than the full change set, then narrow the requirement or add a compatibility note before retrying. - -## Open Questions - -- Should `ThreadedTask` failure be represented only as a terminal non-blocking state for this change, with richer fault metadata deferred to a later async-contract cleanup? diff --git a/openspec/changes/archive/2026-04-03-improve-runtime-stability/proposal.md b/openspec/changes/archive/2026-04-03-improve-runtime-stability/proposal.md deleted file mode 100644 index b768174..0000000 --- a/openspec/changes/archive/2026-04-03-improve-runtime-stability/proposal.md +++ /dev/null @@ -1,24 +0,0 @@ -## Why - -The repository already provides a broad set of runtime infrastructure for lifecycle management, async requests, pooling, event dispatch, downloads, and patching, but several core paths have correctness and maintainability risks. A small number of state-handling bugs and unclear runtime contracts reduce trust in the library and make future improvements riskier than they need to be. - -## What Changes - -- Fix core runtime state and lifecycle inconsistencies in foundational infrastructure such as object creation, async request completion, coroutine stopping, and threaded task failure handling. -- Define a narrow runtime reliability contract around these corrected behaviors so implementation and tests align to the same expectations. -- Add focused test coverage for the highest-risk lifecycle and state-machine behaviors so regressions are caught earlier. -- Clarify runtime behavior for these corrected paths without expanding this change into broader pooling, dispatcher, patching, or async architecture refactors. - -## Capabilities - -### New Capabilities -- `runtime-reliability`: Defines behavioral guarantees for lifecycle, async completion, task failure, pooling, and dispatcher correctness in the runtime infrastructure. - -### Modified Capabilities - -## Impact - -- Affected code is primarily under `Assets/vFrame.Core/Runtime` and `Assets/vFrame.Core.Unity/Runtime`. -- The most impacted subsystems are `BaseObject`, `AsyncRequestCtrl`, `ThreadedTask`, and `CoroutinePool`. -- Public APIs should remain largely stable, but runtime behavior will become stricter and more predictable in edge cases and failure paths. -- This change is intended to establish a safer baseline for later, separate improvements to pooling, dispatching, patching, and async abstractions. diff --git a/openspec/changes/archive/2026-04-03-improve-runtime-stability/specs/runtime-reliability/spec.md b/openspec/changes/archive/2026-04-03-improve-runtime-stability/specs/runtime-reliability/spec.md deleted file mode 100644 index d8ca377..0000000 --- a/openspec/changes/archive/2026-04-03-improve-runtime-stability/specs/runtime-reliability/spec.md +++ /dev/null @@ -1,41 +0,0 @@ -## ADDED Requirements - -### Requirement: Base object creation SHALL only succeed after successful initialization -Runtime base objects MUST report themselves as created only after `OnCreate(...)` completes successfully. If initialization throws an exception, the object MUST NOT transition into a created state. - -#### Scenario: Create fails during initialization -- **WHEN** a `BaseObject`-derived type throws from `OnCreate(...)` -- **THEN** the instance MUST remain not created - -#### Scenario: Create succeeds during initialization -- **WHEN** a `BaseObject`-derived type completes `OnCreate(...)` without error -- **THEN** the instance MUST transition into a created state - -### Requirement: Async request controller SHALL route terminal events correctly -The async request controller MUST dispatch finish notifications only for successfully finished requests and MUST dispatch error notifications only for faulted requests. - -#### Scenario: Request finishes successfully -- **WHEN** an async request enters the `Finished` terminal state -- **THEN** the controller MUST raise the finish event and MUST NOT raise the error event for that request - -#### Scenario: Request fails with error -- **WHEN** an async request enters the `Error` terminal state -- **THEN** the controller MUST raise the error event and MUST NOT raise the finish event for that request - -### Requirement: Threaded tasks SHALL terminate on failure -Threaded tasks MUST enter a non-blocking terminal state when task execution throws an exception so callers do not wait indefinitely for completion. - -#### Scenario: Background task throws during execution -- **WHEN** a threaded task throws while handling work on a worker thread -- **THEN** the task MUST become terminal instead of remaining indefinitely in progress - -#### Scenario: Waiting code observes task termination after failure -- **WHEN** calling code waits on a threaded task that has failed during worker execution -- **THEN** the waiting code MUST be able to observe that the task will not continue running indefinitely - -### Requirement: Coroutine queue mutation SHALL be safe during cancellation -Coroutine pool cancellation MUST safely remove queued tasks without relying on collection mutation patterns that can invalidate enumeration. - -#### Scenario: Cancel waiting coroutine -- **WHEN** a coroutine handle is cancelled while still waiting in the queue -- **THEN** the coroutine MUST be removed without triggering collection-enumeration errors diff --git a/openspec/changes/archive/2026-04-03-improve-runtime-stability/tasks.md b/openspec/changes/archive/2026-04-03-improve-runtime-stability/tasks.md deleted file mode 100644 index 16d99b2..0000000 --- a/openspec/changes/archive/2026-04-03-improve-runtime-stability/tasks.md +++ /dev/null @@ -1,21 +0,0 @@ -## 1. Correct core lifecycle and async correctness bugs - -- [x] 1.1 Update `BaseObject` create flows so created state is only set after successful `OnCreate(...)` completion across all overloads -- [x] 1.2 Fix `AsyncRequestCtrl` so finished requests trigger finish events and errored requests trigger error events -- [x] 1.3 Update `ThreadedTask` terminal behavior so thrown worker exceptions do not leave tasks indefinitely in progress -- [x] 1.4 Fix `CoroutinePool` queued cancellation logic to remove waiting tasks safely without mutating collections during enumeration - -## 2. Add regression coverage for corrected runtime contracts - -- [x] 2.1 Create a Unity test assembly for core runtime reliability tests if one does not already exist -- [x] 2.2 Add tests covering failed and successful `BaseObject` creation state transitions -- [x] 2.3 Add tests covering `AsyncRequestCtrl` finish and error event routing -- [x] 2.4 Add tests covering `ThreadedTask` fault termination behavior -- [x] 2.5 Add tests covering safe cancellation of queued coroutine tasks - -## 3. Validate and document the new reliability baseline - -- [x] 3.1 Run the narrowest relevant Unity test command(s) for the new reliability coverage and fix any failures - Validation result: batch-mode EditMode tests execute and pass in the available local Unity 2022 environment, producing `TestResults/editmode-results.xml` with 7 passing tests. The `CoroutinePool` cancellation test was moved to PlayMode because `DontDestroyOnLoad` is not valid in EditMode. Batch-mode PlayMode execution in the available local environment still reports `No tests were executed` for the PlayMode assembly, so PlayMode verification remains a follow-up validation item for the target Unity version and/or editor test runner rather than a blocker for this change. -- [x] 3.2 Run Unity batch-mode compilation to confirm the project still compiles after the reliability fixes -- [x] 3.3 Update repository documentation where current wording conflicts with the corrected runtime behavior or supported expectations