diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..55934bd3a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,35 @@ +## Tracking issue + + + +## Description + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor +- [ ] Docs +- [ ] Chore + +## Checklist + +- [ ] PR targets `dev` branch (not `main`) +- [ ] Branch follows naming convention (`feat/`, `fix/`, `docs/`, `refactor/`, `chore/`, etc.) +- [ ] Diff is minimal and scoped — no unrelated changes, no drive-by refactors +- [ ] No version bumps or release metadata changes (unless explicitly requested) +- [ ] No new helpers/utilities introduced without first searching for existing ones +- [ ] Compile Check CI workflow passes (or verification has been run locally) +- [ ] Runtime code has no unguarded `UnityEditor` dependencies +- [ ] Editor-only code is placed under `Runtime/Editor/` and/or guarded with `#if UNITY_EDITOR` +- [ ] Tracking issue in `lootlocker/index` is linked and status set to "In Review" + +## Testing notes + + + +## Additional notes + + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..8ea52e22b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,45 @@ +# Copilot / Coding Agent instructions (LootLocker Unity SDK) + +Follow these rules for any work in this repo: + +## Non-negotiables +- Never commit directly to `dev` or `main`. +- PRs must target `dev`. +- Do not tag/publish/create releases. +- Do not bump versions or edit release metadata (for example `package.json` version) unless explicitly asked. +- Keep diffs minimal; do not move/rename files unless explicitly requested. +- Search first to avoid duplicating helpers/utilities. + +## Architecture references +- Repo structure + “where do I implement X?”: `.github/instructions/architecture.md` +- Guardrails (agent operating rules): `.github/instructions/guardrails.md` + +## Verification (compile & test before PR) +- How to verify changes (local + CI): `.github/instructions/verification.md` + - Cloud agent: push to work branch → wait for **Compile Check** workflow. + - Local: run `.github/scripts/verify-compilation.sh` (Linux/macOS) or `.github\scripts\verify-compilation.ps1` (Windows) after creating `unity-dev-settings.json` from the example: + ```json + { + "unity_executable": "", + "test_project_path": "" + } + ``` + +## Conventions & style +- Coding conventions & style guide: `.github/instructions/style-guide.md` +- Patterns cookbook (templates): `.github/instructions/patterns.md` +- Path-specific instructions: + - Public API surface (`Runtime/Game/LootLockerSDKManager.cs`): `.github/instructions/Runtime/Game/LootLockerSDKManager.cs.instructions.md` + - Request implementations (`Runtime/Game/Requests/**`): `.github/instructions/Runtime/Game/Requests.instructions.md` + - PlayMode tests (`Tests/LootLockerTests/PlayMode/**`): `.github/instructions/Tests/LootLockerTests/PlayMode.instructions.md` + - Test utilities (`Tests/LootLockerTestUtils/**`): `.github/instructions/Tests/LootLockerTestUtils.instructions.md` + +## Testing +- How to write and run tests: `.github/instructions/testing.md` + - Local: `.github\scripts\run-tests.ps1 -TestCategory LootLockerCIFast` + - Cloud agent: **Actions → Run Tests → Run workflow** (supports `testCategory` and `testFilter` inputs). +- How to use tests for debugging (temporary debug tests): `.github/instructions/debugging.md` + - Use `Category("LootLockerDebug")` for temporary debug tests; **always delete before committing**. + +## Issue Tracking & Lifecycle +- Full lifecycle rules (status updates, PR linking, DoD): `.github/instructions/implementation-lifecycle.md` diff --git a/.github/instructions/Runtime/Game/LootLockerSDKManager.cs.instructions.md b/.github/instructions/Runtime/Game/LootLockerSDKManager.cs.instructions.md new file mode 100644 index 000000000..d6c9a9783 --- /dev/null +++ b/.github/instructions/Runtime/Game/LootLockerSDKManager.cs.instructions.md @@ -0,0 +1,35 @@ +--- +applyTo: "Runtime/Game/LootLockerSDKManager.cs" +--- + +# Scoped instructions: `LootLockerSDKManager.cs` (public API surface) + +- Treat this file as the primary, customer-facing API surface. Preserve backward compatibility. +- Prefer additive changes (new methods/overloads) over breaking changes to existing signatures, default values, namespaces, or behavior. +- Keep API methods thin: route work to existing request implementations under `Runtime/Game/Requests/` (or shared helpers) rather than duplicating endpoint/transport logic here. +- Always place methods in this file in a `#region` block corresponding to their feature set (for example, Authentication, Inventory, etc.) and keep them organized with related methods. +- XML docs are required for any `public` API you add or change. Match the existing doc style in this file, including clear `param` descriptions and any practical usage notes. + +## XML doc template (adjust as needed) + +```csharp +/// +/// One-sentence description of what the method does. +/// +/// Optional additional details or usage notes. +/// +/// Optional : Execute the request for the specified player. If not supplied, the default player will be used. +/// onComplete Action for handling the response +/// +/// Return value semantics if this API returns a value. Otherwise omit this tag. +/// +public static void ExampleMethod(string forPlayerWithUlid, Action onComplete) +{ + // Keep facade thin; validate input early; call onComplete once on all paths. +} +``` +- If a method takes a player selector, follow the existing `forPlayerWithUlid` convention (optional when appropriate; don’t invent new parameter names for the same concept). +- For callback-based APIs, call `onComplete` exactly once on all code paths (including validation failures). +- Do not log secrets or raw tokens from this layer. Use `LootLockerLogger` (not `Debug.Log`) and rely on log obfuscation (`LootLockerConfig.current.obfuscateLogs`). +- Use the repo’s JSON wrapper (`LootLockerJson`) and existing response/error helpers; do not introduce new serialization/logging dependencies. +- Keep diffs minimal and localized; avoid unrelated refactors in this large file. diff --git a/.github/instructions/Runtime/Game/Requests.instructions.md b/.github/instructions/Runtime/Game/Requests.instructions.md new file mode 100644 index 000000000..02580a4f5 --- /dev/null +++ b/.github/instructions/Runtime/Game/Requests.instructions.md @@ -0,0 +1,18 @@ +--- +applyTo: "Runtime/Game/Requests/**/*.cs" +--- + +# Scoped instructions: `Runtime/Game/Requests/` (request implementations) + +- Follow the established split: + - DTOs/response models in `namespace LootLocker.Requests`. + - Request methods in `namespace LootLocker` inside `public partial class LootLockerAPIManager`. +- Responses should inherit `LootLockerResponse` and use property names that match server JSON (commonly `snake_case`), as seen throughout this folder. +- Validate inputs early. For an unserializable/missing body, return a consistent error response via `LootLockerResponseFactory` and invoke `onComplete` once. +- Serialize request bodies with `LootLockerJson.SerializeObject(...)` (never call Newtonsoft/ZeroDep JSON APIs directly). +- Build endpoints via `LootLockerEndPoints` + `EndPointClass.WithPathParameter(s)`; avoid manual string concatenation for path params. +- For query parameters, prefer `LootLocker.Utilities.HTTP.QueryParamaterBuilder` when available; otherwise ensure values are URL-encoded (for example `WebUtility.UrlEncode`). +- Use the standard transport pattern: + - `LootLockerServerRequest.CallAPI(..., onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, useAuthToken: true/false)` + - Keep `useAuthToken` consistent with the endpoint’s auth requirements and nearby examples. This variable is almost always `true`. +- Keep methods small and consistent with surrounding files; don’t move DTOs or rename existing public types unless explicitly requested. diff --git a/.github/instructions/Tests/LootLockerTestUtils.instructions.md b/.github/instructions/Tests/LootLockerTestUtils.instructions.md new file mode 100644 index 000000000..65b9ad924 --- /dev/null +++ b/.github/instructions/Tests/LootLockerTestUtils.instructions.md @@ -0,0 +1,69 @@ +--- +applyTo: "Tests/LootLockerTestUtils/**/*.cs" +--- + +# Scoped instructions: `Tests/LootLockerTestUtils/` (test configuration utilities) + +This folder holds the admin-API client and helpers that tests use to provision and tear down +real LootLocker game configurations. It is **not** test code itself — it is infrastructure +that backs the tests under `Tests/LootLockerTests/PlayMode/`. + +## Key classes + +| Class / File | Responsibility | +|---|---| +| `LootLockerTestGame` | Creates/deletes a game, exposes helpers for common game setup (enable platforms, create leaderboards, etc.). | +| `LootLockerTestUser` | Gets or signs up a time-scoped admin user so the admin API calls are authenticated. | +| `Auth` | Low-level admin login / signup via the LootLocker admin API. | +| `LootLockerAdminRequest` | Thin wrapper around `LootLockerServerRequest.CallAPI` that injects the `Admin` caller role and handles rate-limit retries. | +| `LootLockerTestConfigurationEndpoints` | Constants for all admin API endpoints used by the utilities. | + +## How authentication works + +`LootLockerTestUser.GetUserOrSignIn` auto-derives a deterministic email from the current +date (e.g. `unity+ci-testrun+2024-04-17-14h@lootlocker.com`) and the same string as the +password, then attempts login and falls back to signup on a 401. This means test runs +are self-contained and need no pre-provisioned secrets in stage environments. + +## Transport pattern + +All admin API calls use `LootLockerAdminRequest.Send(...)`, which sets +`callerRole: LootLockerCallerRole.Admin`. Never call `LootLockerServerRequest.CallAPI` +directly from this folder — go through `LootLockerAdminRequest.Send`. + +```csharp +LootLockerAdminRequest.Send(endPoint.endPoint, endPoint.httpMethod, json, + onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, useAuthToken: true); +``` + +## Adding a new admin API helper + +1. Add the endpoint constant to `LootLockerTestConfigurationEndpoints.cs`. +2. Create a request/response DTO (plain class or `LootLockerResponse` subclass) in the + relevant file, or add a new file for a new domain area. +3. Add a static method to the appropriate class (`LootLockerTestGame`, `LootLockerTestAssets`, + etc.) that: + - Checks `if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken))` first and + invokes `onComplete` with a failed response if not authenticated. + - Uses `LootLockerAdminRequest.Send(...)` for transport. + - Deserializes with `LootLockerResponse.Deserialize(serverResponse)`. +4. Map the requests to the correct endpoint according to the api docs: https://ref.lootlocker.com/admin + +## Namespace + +All classes in this folder live in `namespace LootLockerTestConfigurationUtils`. + +## Conventions + +- Keep methods callback-based (`Action<...> onComplete`) to match the async Unity pattern + used by tests (`yield return new WaitUntil(() => done)`). +- Do not reference `LootLockerTests` from this utility layer — the dependency runs one way: + tests depend on utils, not the other way. +- Use `LootLockerJson.SerializeObject(...)` for request body serialization — same as runtime. +- Error paths must always invoke `onComplete` exactly once. +- Endpoint strings that include a game ID placeholder use `#GAMEID#`, which + `LootLockerAdminRequest` replaces automatically from `LootLockerAdminRequest.ActiveGameId`. diff --git a/.github/instructions/Tests/LootLockerTests/PlayMode.instructions.md b/.github/instructions/Tests/LootLockerTests/PlayMode.instructions.md new file mode 100644 index 000000000..58d53161c --- /dev/null +++ b/.github/instructions/Tests/LootLockerTests/PlayMode.instructions.md @@ -0,0 +1,191 @@ +--- +applyTo: "Tests/LootLockerTests/PlayMode/**/*.cs" +--- + +# Scoped instructions: `Tests/LootLockerTests/PlayMode/` (PlayMode integration tests) + +These are the SDK's integration tests. They run as Unity PlayMode tests and use the +`LootLockerTestUtils` infrastructure to create and tear down a real, isolated LootLocker +game via the admin API for every test run. + +## Anatomy of a test file + +Every production test file follows this structure exactly: + +```csharp +using System.Collections; +using LootLocker; +using LootLocker.Requests; +using LootLockerTestConfigurationUtils; +using NUnit.Framework; +using UnityEditor; // only if required by the feature under test +using UnityEngine; +using UnityEngine.TestTools; + +namespace LootLockerTests.PlayMode +{ + public class MyFeatureTest + { + private LootLockerTestGame gameUnderTest = null; + private LootLockerConfig configCopy = null; + private static int TestCounter = 0; + private bool SetupFailed = false; + + [UnitySetUp] + public IEnumerator Setup() + { + TestCounter++; + configCopy = LootLockerConfig.current; + Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} setup #####"); + + if (!LootLockerConfig.ClearSettings()) + { + Debug.LogError("Could not clear LootLocker config"); + } + + // Create isolated game + bool gameCreationCallCompleted = false; + LootLockerTestGame.CreateGame(testName: this.GetType().Name + TestCounter + " ", onComplete: (success, errorMessage, game) => + { + if (!success) + { + SetupFailed = true; + gameCreationCallCompleted = true; + return; + } + gameUnderTest = game; + gameCreationCallCompleted = true; + }); + yield return new WaitUntil(() => gameCreationCallCompleted); + if (SetupFailed) { yield break; } + + gameUnderTest?.SwitchToStageEnvironment(); + + // Enable required platform(s) — add only what the feature under test needs + bool enableGuestLoginCallCompleted = false; + gameUnderTest?.EnableGuestLogin((success, errorMessage) => + { + if (!success) { SetupFailed = true; } + enableGuestLoginCallCompleted = true; + }); + yield return new WaitUntil(() => enableGuestLoginCallCompleted); + if (SetupFailed) { yield break; } + + Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Failed to initialize LootLocker"); + + // Start a default session for the test + bool sessionCompleted = false; + LootLockerSDKManager.StartGuestSession(GUID.Generate().ToString(), response => + { + SetupFailed |= !response.success; + sessionCompleted = true; + }); + yield return new WaitUntil(() => sessionCompleted); + + Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} test case #####"); + } + + [UnityTearDown] + public IEnumerator TearDown() + { + Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} test case #####"); + if (gameUnderTest != null) + { + bool gameDeletionCallCompleted = false; + gameUnderTest.DeleteGame(((success, errorMessage) => + { + if (!success) { Debug.LogError(errorMessage); } + gameUnderTest = null; + gameDeletionCallCompleted = true; + })); + yield return new WaitUntil(() => gameDeletionCallCompleted); + } + LootLockerStateData.ClearAllSavedStates(); + LootLockerConfig.CreateNewSettings(configCopy); + Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public IEnumerator MyFeature_DoesExpectedThing_Succeeds() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // Given + // ... + + // When + MyResponseType actualResponse = null; + bool callCompleted = false; + LootLockerSDKManager.SomeMethod(param, response => + { + actualResponse = response; + callCompleted = true; + }); + yield return new WaitUntil(() => callCompleted); + + // Then + Assert.IsTrue(actualResponse.success, "Expected call to succeed"); + Assert.AreEqual(expected, actualResponse.someField, "Got unexpected value"); + } + } +} +``` + +## Test method naming + +Format: `__` + +Examples: +- `Leaderboard_ListTopTen_ReturnsScoresInDescendingOrder` +- `GuestSession_StartWithValidId_Succeeds` +- `PlayerStorage_UpdateNonExistentKey_CreatesIt` + +## Category attributes + +Every production test method must carry the appropriate category tags: + +| Category | Meaning | +|---|---| +| `LootLocker` | All SDK tests — the catch-all for manual / local runs. | +| `LootLockerCI` | Included in the standard CI run (full suite). | +| `LootLockerCIFast` | Included in the fast CI subset. **Only add this if the test consistently finishes in under ~10 seconds.** | + +Typical annotation: `[UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]` + +Do **not** use `Category("LootLockerDebug")` on production tests — that category is reserved +for temporary debugging tests that must be removed before any PR. + +## Game lifecycle rules + +Every test class creates a **fresh isolated game** per test run; there is no shared state +between test methods. This keeps tests hermetic but requires strict discipline: + +- Always call `gameUnderTest.DeleteGame(...)` in `TearDown` even if `SetupFailed` — otherwise + orphaned games accumulate in the admin account. +- Always guard teardown with `if (gameUnderTest != null)`. +- Always check `if (SetupFailed) { yield break; }` after every blocking setup step. +- Call `LootLockerStateData.ClearAllSavedStates()` and `LootLockerConfig.CreateNewSettings(configCopy)` + at the end of every `TearDown` to restore SDK state for the next test. + +## Test body conventions + +- First line of every test: `Assert.IsFalse(SetupFailed, "Failed to setup game");` +- Follow **Given / When / Then** comment structure inside the test body. +- Use `yield return new WaitUntil(() => done)` to await async SDK calls; always + pair with a `bool done = false` flag set in the callback. +- Use NUnit `Assert.*`; never use `Debug.LogError` as a substitute for assertions. +- Do not add `using UnityEditor` unless the test must run in edit mode. + +## One test class per test file + +One file = one public class. Keep feature scope narrow — a file named `LeaderboardTest.cs` +should only cover the Leaderboard feature. + +## What NOT to do + +- Do not leave debugging logic (`Debug.Log` spam, disabled asserts as comments, etc.) in + committed code. +- Do not skip the `TearDown` game deletion — orphaned admin games are costly. +- Do not commit any test with `Category("LootLockerDebug")` — see `.github/instructions/debugging.md`. +- Do not add new test helpers or shared utilities directly in this folder; put them under + `Tests/LootLockerTestUtils/` instead (see `.github/instructions/Tests/LootLockerTestUtils.instructions.md`). diff --git a/.github/instructions/architecture.md b/.github/instructions/architecture.md new file mode 100644 index 000000000..f4fa59de5 --- /dev/null +++ b/.github/instructions/architecture.md @@ -0,0 +1,107 @@ +# Unity SDK architecture & repo structure + +- Repo layout: **UPM package at repository root** (customers receive an `Assets/` wrapper via CI packaging). +- Public entrypoint: `Runtime/Game/LootLockerSDKManager.cs` + +For agent operating rules, see: `.github/instructions/guardrails.md`. + +## Repository map (root) +- `Runtime/` — Shippable SDK code (runtime + editor-guarded pieces). Main entrypoint is in `Runtime/Game/`. +- `Tests/` — Unity Test Runner tests and test utilities. +- `Samples~/` — UPM samples (example scenes/scripts). +- `Prefabs/` — Exported `.unitypackage` artifacts (not runtime code). +- `.github/` — Internal repo automation + agent docs (excluded from customer packaging). +- `docs/` — Internal developer documentation (excluded from customer packaging). +- `package.json` — UPM package manifest (do not bump version unless explicitly tasked). + +## Where the code lives + +### Public API surface +- `Runtime/Game/LootLockerSDKManager.cs` + - The user-facing SDK class. Most SDK features are exposed as static methods here. + - The class is `partial` and very large; changes here are inherently API-surface changes. + +### Runtime “core” services / transport +- `Runtime/Client/LootLockerHTTPClient.cs` — HTTP transport built on `UnityEngine.Networking.UnityWebRequest`. +- `Runtime/Client/LootLockerHttpRequestData.cs` — Request data model/formatting used by the HTTP client. +- `Runtime/Client/LootLockerHTTPExecutionQueueItem.cs` — Execution/queue bookkeeping for HTTP requests. +- `Runtime/Client/LootLockerRateLimiter.cs` — Client-side rate limiting. +- `Runtime/Client/LootLockerEndPoints.cs` + `Runtime/Client/EndPointClass.cs` — Endpoint path/method definitions. + +### Requests + response models +- `Runtime/Game/Requests/` — Feature-focused request code. + - Convention observed in this repo: many request files also define their **response DTOs** in the same file (e.g., classes deriving from `LootLockerResponse`). + +Shared response base + errors: +- `Runtime/Client/LootLockerResponse.cs` — Base response type + deserialization helper. +- `Runtime/Client/LootLockerErrorData.cs` — Error payload shape. +- `Runtime/Client/LootLockerRequestContext.cs` — Context attached to responses. + +### Serialization +- `Runtime/Client/LootLockerJson.cs` — Serialization wrapper. + - Uses Newtonsoft JSON when `LOOTLOCKER_USE_NEWTONSOFTJSON` is defined. + - Otherwise uses `Runtime/Libraries/ZeroDepJson/`. +- `Runtime/Libraries/ZeroDepJson/` — Built-in JSON implementation (no external dependency). + +### Session / player state (auth tokens, persistence) +- `Runtime/Client/LootLockerPlayerData.cs` — In-memory player/session token fields. +- `Runtime/Client/LootLockerStateData.cs` — Service that persists player state and reacts to session lifecycle events. +- `Runtime/Client/LootLockerStateWriter.cs` — `ILootLockerStateWriter` abstraction; default uses `PlayerPrefs` unless disabled. + +### Lifecycle + events +- `Runtime/Client/ILootLockerService.cs` — Service interface. +- `Runtime/Client/LootLockerLifecycleManager.cs` — Central manager that instantiates services and coordinates Unity lifecycle. +- `Runtime/Client/LootLockerEventSystem.cs` — Eventing used by services (e.g., session started/refreshed/ended). + +### Configuration +- `Runtime/Game/Resources/LootLockerConfig.cs` — ScriptableObject settings. + - Uses `Resources.Load` at runtime. + - Editor-only asset creation/editor integrations are guarded by `#if UNITY_EDITOR`. + +### Logging / utilities +- `Runtime/Game/LootLockerLogger.cs` — SDK logging. +- `Runtime/Game/LootLockerObfuscator.cs` — Sensitive log obfuscation. +- `Runtime/Game/Utilities/` — General helpers used by the SDK. + +### Editor tooling (repo’s “Editor” area) +- `Runtime/Editor/` — Editor-only tooling (LootLocker extension UI, log viewer, editor data, etc.). + - This is the effective “Editor/” boundary in this repo. + +### Tests +- `Tests/LootLockerTests/PlayMode/` — PlayMode tests + `PlayModeTests.asmdef`. +- `Tests/LootLockerTestUtils/` — Shared test configuration helpers + `LootLockerTestUtils.asmdef`. + +### Samples +- `Samples~/LootLockerExamples/` — UPM samples + `LootLockerExamples.asmdef`. + +## Related instructions (rules live elsewhere) + +This document is intentionally focused on **structure and navigation** (where things live). + +For behavioral rules, conventions, and safety constraints, follow: +- `.github/instructions/guardrails.md` (change discipline + operational rules) +- `.github/instructions/style-guide.md` (repo-wide coding conventions) +- Path-specific instruction files under `.github/instructions/**.instructions.md` (highest priority for matching paths) + +## Where do I implement X? + +| Change you want to make | Put it here (real paths in this repo) | +|---|---| +| Add/modify a customer-facing SDK method or its documentation | `Runtime/Game/LootLockerSDKManager.cs` | +| Add a new feature request (API call) | `Runtime/Game/Requests/*.cs` contains the DTO structs and their documentation and customer exposure is added through `Runtime/Game/LootLockerSDKManager.cs` and the necessary endpoint constants are added in `Runtime/Client/LootLockerEndPoints.cs` | +| Add/adjust response DTO fields for a request | Usually in the same `Runtime/Game/Requests/*.cs` file (classes deriving `LootLockerResponse`) | +| Add/adjust endpoint URL/method constants | `Runtime/Client/LootLockerEndPoints.cs` and/or `Runtime/Client/EndPointClass.cs` | +| Change HTTP behavior (retries, headers, request creation) | `Runtime/Client/LootLockerHTTPClient.cs` + `Runtime/Client/LootLockerHttpRequestData.cs` | +| Change serialization rules | `Runtime/Client/LootLockerJson.cs` (and `Runtime/Libraries/ZeroDepJson/` if not using Newtonsoft) | +| Change session/persistence behavior | `Runtime/Client/LootLockerStateData.cs`, `Runtime/Client/LootLockerStateWriter.cs`, `Runtime/Client/LootLockerPlayerData.cs` | +| Add/adjust config settings | `Runtime/Game/Resources/LootLockerConfig.cs` | +| Add editor window/extension UI | `Runtime/Editor/` | +| Add/adjust tests | `Tests/LootLockerTests/PlayMode/` (tests) and `Tests/LootLockerTestUtils/` (shared config) | +| Add/adjust samples | `Samples~/LootLockerExamples/` | + +## Links +- Coding Agent guardrails: `.github/instructions/guardrails.md` + +## Future expansion (placeholders) +- Build & test (to be filled by later tasks) +- CI packaging notes (to be filled by later tasks) diff --git a/.github/instructions/debugging.md b/.github/instructions/debugging.md new file mode 100644 index 000000000..60f45c524 --- /dev/null +++ b/.github/instructions/debugging.md @@ -0,0 +1,171 @@ +# Debugging issues using the test infrastructure + +When investigating a bug, you can create **temporary** debugging tests that use the same +`LootLockerTestGame` / `LootLockerSDKManager` infrastructure as the production tests. This +lets you reproduce, isolate, and fix an issue with a tight feedback loop before cleaning +everything up. + +> **Critical rule: debugging tests must never be committed to a PR.** +> They exist only as a local working aid. Always delete them before committing. + +--- + +## Workflow overview + +1. **Create** a temporary test file (or method) that reproduces the symptom. +2. **Run** it with the targeted debug category or filter. +3. **Iterate** — fix code, re-run — until the test passes. +4. **Delete** the temporary test code. +5. Continue with the fix and any supporting production tests. + +--- + +## Creating the debug test file + +Create a file `Tests/LootLockerTests/PlayMode/DebugTests.cs`. This name is conventional +and signals that the file is ephemeral. + +Use the same `[UnitySetUp]` / `[UnityTearDown]` pattern as production tests (see +`.github/instructions/Tests/LootLockerTests/PlayMode.instructions.md`), but mark every +method with `Category("LootLockerDebug")` instead of the production categories: + +```csharp +using System.Collections; +using LootLocker; +using LootLocker.Requests; +using LootLockerTestConfigurationUtils; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; + +namespace LootLockerTests.PlayMode +{ + /// + /// TEMPORARY DEBUG TESTS — delete this file before committing. + /// + public class DebugTests + { + private LootLockerTestGame gameUnderTest = null; + private LootLockerConfig configCopy = null; + private static int TestCounter = 0; + private bool SetupFailed = false; + + [UnitySetUp] + public IEnumerator Setup() + { + TestCounter++; + configCopy = LootLockerConfig.current; + + if (!LootLockerConfig.ClearSettings()) + { + Debug.LogError("Could not clear LootLocker config"); + } + + bool gameCreationCallCompleted = false; + LootLockerTestGame.CreateGame(testName: "Debug_" + TestCounter + " ", onComplete: (success, errorMessage, game) => + { + if (!success) { SetupFailed = true; } + gameUnderTest = game; + gameCreationCallCompleted = true; + }); + yield return new WaitUntil(() => gameCreationCallCompleted); + if (SetupFailed) { yield break; } + + gameUnderTest?.SwitchToStageEnvironment(); + + // Enable platforms as needed for the specific issue being debugged + bool enableGuestLoginCallCompleted = false; + gameUnderTest?.EnableGuestLogin((success, errorMessage) => + { + if (!success) { SetupFailed = true; } + enableGuestLoginCallCompleted = true; + }); + yield return new WaitUntil(() => enableGuestLoginCallCompleted); + if (SetupFailed) { yield break; } + + Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Failed to initialize LootLocker"); + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (gameUnderTest != null) + { + bool gameDeletionCallCompleted = false; + gameUnderTest.DeleteGame(((success, errorMessage) => + { + if (!success) { Debug.LogError(errorMessage); } + gameUnderTest = null; + gameDeletionCallCompleted = true; + })); + yield return new WaitUntil(() => gameDeletionCallCompleted); + } + LootLockerStateData.ClearAllSavedStates(); + LootLockerConfig.CreateNewSettings(configCopy); + } + + // ------------------------------------------------------------------------- + // Debugging test — add/remove methods freely; this whole file gets deleted + // ------------------------------------------------------------------------- + + [UnityTest, Category("LootLockerDebug")] + public IEnumerator Debug_ScoresReturnedInWrongOrder_Reproduces() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // Reproduce the bug by setting up the minimal state that triggers it, + // then assert the expected (correct) behavior. When the test goes green + // the bug is fixed. + + // ... reproduction steps ... + + yield return null; + } + } +} +``` + +--- + +## Running the debug test + +**Locally:** +```powershell +.github\scripts\run-tests.ps1 -TestCategory LootLockerDebug +``` + +Or run a single method by name: +```powershell +.github\scripts\run-tests.ps1 -TestFilter "DebugTests.Debug_ScoresReturnedInWrongOrder_Reproduces" +``` + +**In CI** (if you need cloud execution to reproduce an issue): +``` +Actions → Run Tests → Run workflow + testCategory: LootLockerDebug +``` + +> Tip: push only to a `fix/` branch when running debug tests in CI, and squash/drop +> the `DebugTests.cs` commits before opening a PR. + +--- + +## Checklist before opening a PR + +- [ ] `Tests/LootLockerTests/PlayMode/DebugTests.cs` has been **deleted**. +- [ ] No `Category("LootLockerDebug")` remains anywhere in the diff. +- [ ] Any permanent test covering the fixed behavior has been added as a proper production + test with `Category("LootLocker")` and `Category("LootLockerCI")`. + +--- + +## Tips + +- Keep debug tests minimal — reproduce only the symptom, not a full user scenario. +- If the root cause is unclear, start with a failing assertion that captures incorrect + behavior, then work backwards into the code. +- `Debug.Log` freely inside `DebugTests.cs`; none of it will survive to the PR. +- If the issue turns out to be a missing test case rather than a bug, convert the repro + into a proper production test (rename the file/method, remove `LootLockerDebug` category, + add `LootLockerCI` and `LootLockerCIFast` as appropriate). diff --git a/.github/instructions/guardrails.md b/.github/instructions/guardrails.md new file mode 100644 index 000000000..ba7cb4880 --- /dev/null +++ b/.github/instructions/guardrails.md @@ -0,0 +1,48 @@ +# Coding Agent Guardrails (LootLocker Unity SDK) + +These rules exist to keep changes safe, reviewable, and aligned with how this repo ships. + +## Branching + PRs +- Never commit directly to `dev` or `main`. +- Create a work branch (e.g. `docs/...`, `fix/...`, `feat/...`). +- Open PRs targeting `dev` (never `main`). + +## Release / versioning prohibitions +Unless the task explicitly asks for it, do **not**: +- Create tags, GitHub Releases, or publish packages. +- Bump versions or edit release metadata (for example `package.json` version). + +## Change discipline +- Keep diffs minimal and scoped to the task. +- Do not move/rename files or restructure folders unless explicitly requested. +- Search before adding new helpers/utilities to avoid duplication. +- Avoid drive-by refactors (formatting, naming, reorganization) unless requested. + +## Runtime vs Editor boundary +- Runtime code must be build-safe (no unguarded `UnityEditor` dependencies). +- Put editor tooling under `Runtime/Editor/` (this repo’s editor-only area) and/or guard editor-only code with `#if UNITY_EDITOR`. + +## Verification (compilation & tests) +Before opening a PR, verify that changes compile and pass tests. See +`.github/instructions/verification.md` for the full procedure. In short: + +- **Cloud agent**: push to your work branch and wait for the **`Compile Check`** + workflow (`.github/workflows/agent-sanity-check.yml`) to go green. +- **Local**: run `.github/scripts/verify-compilation.sh` (Linux/macOS) or + `.github\scripts\verify-compilation.ps1` (Windows), after setting up + `unity-dev-settings.json` from the provided example: + ```json + { + "unity_executable": "", + "test_project_path": "" + } + ``` + +## When unsure +If a change would require guessing architecture, conventions, or customer-facing API behavior: +- Stop and ask for clarification rather than inventing a new pattern. + +## Reference +- Architecture & structure overview: `.github/instructions/architecture.md` +- Style Guide: `.github/instructions/style-guide.md` +- Code patterns: `.github/instructions/patterns.md` diff --git a/.github/instructions/implementation-lifecycle.md b/.github/instructions/implementation-lifecycle.md new file mode 100644 index 000000000..0061cef04 --- /dev/null +++ b/.github/instructions/implementation-lifecycle.md @@ -0,0 +1,28 @@ +# Issue Tracking & Lifecycle + +All SDK work is driven by a tracking issue in [lootlocker/index](https://github.com/lootlocker/index). That issue is the single source of truth for status, decisions, and acceptance criteria. **You must keep it up to date throughout your work.** + +## Project Status + +This issue will almost always be tracked in project https://github.com/orgs/lootlocker/projects/75. Update the issue's project status as your work progresses: + +| Situation | Status to set | +|-----------|--------------| +| You start working on the task | **In Progress** | +| You are blocked and need input from a human | **Blocked** | +| A PR has been opened and is ready for review | **In Review** | + +## Architectural Decisions & Questions + +Do not make undocumented assumptions. If a question or decision arises during implementation: +- Leave a comment on the tracking issue describing the question or decision clearly. +- Tag @kirre-bylund so it can be addressed. +- Set the project status to **Blocked** and stop work on the affected area until answered. + +## Linking PRs + +As soon as you open a PR in this repo, post a comment on the tracking issue with the PR link. Also link the PR formally via GitHub's "Development" section on the tracking issue. + +## Acceptance Criteria & Definition of Done + +Check off items in the tracking issue's Definition of Done as they are completed. If scope changes during implementation, update the acceptance criteria in the tracking issue and leave a comment explaining what changed and why. diff --git a/.github/instructions/patterns.md b/.github/instructions/patterns.md new file mode 100644 index 000000000..4930ac00b --- /dev/null +++ b/.github/instructions/patterns.md @@ -0,0 +1,51 @@ +# LootLocker Unity SDK — Patterns cookbook + +Scope: Copy/paste-friendly templates based on how this repo already implements features. + +This file is intentionally **templates**, not policy. For rules and conventions, follow: +- `.github/instructions/style-guide.md` +- `.github/instructions/guardrails.md` +- Path-specific instruction files under `.github/instructions/**.instructions.md` + +## A) Adding a new API call (typical flow) + +1) Add endpoint constant in `Runtime/Client/LootLockerEndPoints.cs`: +- Add `public static EndPointClass = new EndPointClass("path", LootLockerHTTPMethod.);` +- Place it under the matching `[Header("...")]` section or create a new one if this is a new feature set. +- Match local naming style (some sections use `camelCase`, some use `PascalCase`). + +2) Add request/response DTOs + request method under `Runtime/Game/Requests/Request(s).cs`: +- Co-locate DTOs and the execution method in the same file (common in this repo). +- Put DTOs in the `LootLocker.Requests` namespace. +- Put execution method(s) in `namespace LootLocker { public partial class LootLockerAPIManager { ... } }`. + +For folder-specific conventions (validation, error responses, endpoint building, transport usage), follow `.github/instructions/Runtime/Game/Requests.instructions.md`. + +3) Expose it publicly via `Runtime/Game/LootLockerSDKManager.cs` if it is customer-facing. + +For public API surface rules (compatibility, callback guarantees, parameter conventions, XML docs expectations), follow `.github/instructions/Runtime/Game/LootLockerSDKManager.cs.instructions.md`. + +## B) Request method template (CallAPI + typed response) + +Pattern: + +- Validate inputs early; if invalid, invoke `onComplete?.Invoke(LootLockerResponseFactory.(forPlayerWithUlid))` and return. +- Build endpoint using `EndPointClass.WithPathParameter(s)` for path parameters and `LootLocker.Utilities.HTTP.QueryParamaterBuilder` for query parameters. +- Serialize body using `LootLockerJson.SerializeObject(request)`. +- Call transport using `LootLockerServerRequest.CallAPI(...)`. +- Deserialize into typed response using `LootLockerResponse.Deserialize(onComplete, serverResponse)`. + +## C) DTO conventions (request/response/data) + +- Responses: `class Response : LootLockerResponse { ... }` +- Requests: plain classes, often named `LootLockerRequest`. +- Properties commonly use `snake_case` to match JSON field names. +- Add XML docs on DTO fields that are user-facing or otherwise important. + +## D) Public API docs (LootLockerSDKManager) + +See `.github/instructions/Runtime/Game/LootLockerSDKManager.cs.instructions.md`. + +## E) Logging + +See `.github/instructions/style-guide.md` (Logging & secrets). diff --git a/.github/instructions/style-guide.md b/.github/instructions/style-guide.md new file mode 100644 index 000000000..db0ae1a15 --- /dev/null +++ b/.github/instructions/style-guide.md @@ -0,0 +1,142 @@ +# LootLocker Unity SDK — Coding conventions & style guide + +Scope: This document describes conventions *observed in this repository* and rules we want contributors/agents to follow when changing or adding code. + +Note: This is the repo-wide baseline. More specific rules are defined in path-specific instruction files under `.github/instructions/**.instructions.md` and should be treated as higher priority for matching files. + +This repo ships as a Unity UPM package. + +## 0) Golden rules + +- Keep changes scoped: large formatting-only work, refactors, renames, and moves should only be done if explicitly requested and then always in their own PRs. +- Search-first: before adding a helper/DTO/utility, search under `Runtime/Client/`, `Runtime/Game/`, and `Runtime/Game/Requests/`. +- Runtime vs Editor boundary: runtime code must not depend on `UnityEditor` unless behind `#if UNITY_EDITOR`. + +## 1) Public API discipline (Unity) + +### 1.1 The API surface + +- **Primary customer-facing API surface**: `Runtime/Game/LootLockerSDKManager.cs`. + - This class is `partial` and very large; edits here are customer-visible. + - Any new `public` method, signature change, rename, or behavior change should be treated as an API change and should go through a deprecation period if needed. + - For file-specific rules (callback guarantees, parameter conventions, doc expectations), follow `.github/instructions/Runtime/Game/LootLockerSDKManager.cs.instructions.md`. + +### 1.2 When to add a new public method + +Add a new public method in `LootLockerSDKManager` when: +- The feature is new customer-facing functionality. +- The existing method(s) would become misleading if extended (e.g., parameters would become ambiguous or overload set becomes confusing). + +Prefer extending existing public methods when: +- You are adding a small optional behavior that fits an existing concept. +- The change can be represented as an optional parameter with a safe default. + +### 1.3 When and how to deprecate + +Go through a deprecation flow when: +- A public method/DTO is going out of support. +- A public method/DTO signature has been updated in a way that is not backwards compatible. + +Deprecations are done by marking the method/DTO with `[Obsolete("This method is deprecated, please use ")] // Deprecation date `. +Deprecations should be done in their own commits and explicitly stated. +Deprecations also require mention in release notes as well as an update of user facing documentation. + +### 1.4 Visibility defaults + +- Default to `private`/`internal` for new helpers and types. +- Make a type `public` only when it is intentionally part of the SDK surface (e.g., response DTOs used in callbacks or facade methods). + +### 1.5 Naming conventions + +- Public methods: **PascalCase** (e.g., `Init`, `CheckInitialized`, `StartGuestSession`). +- Types: **PascalCase** prefixed with `LootLocker` for SDK DTOs. +- Request/response DTO properties: **match the server JSON field names**. + - Commonly `snake_case` (e.g., `session_token`, `provider_name`). + - Some DTOs contain PascalCase properties (e.g., `Code`, `Nonce`) — when adding fields, follow the server contract and surrounding file conventions. + +### 1.6 Documentation expectations + +Documentation requirements vary by surface area: + +- Public API: XML docs should be clear and complete for any new/changed `public` members. For `LootLockerSDKManager.cs`, follow the scoped instructions in `.github/instructions/Runtime/Game/LootLockerSDKManager.cs.instructions.md`. +- Requests folder: follow `.github/instructions/Runtime/Game/Requests.instructions.md`. +- Internal code: keep docs light; prefer clarity through naming and small helpers. + +## 2) Request/response & endpoint patterns + +### 2.1 Where request code lives + +For the repo map and “where do I implement X?”, see `.github/instructions/architecture.md`. + +For folder-specific conventions (namespaces, validation/error patterns, endpoint building), follow `.github/instructions/Runtime/Game/Requests.instructions.md`. + +### 2.2 How API calls are executed (transport) + +- The common transport entry is `LootLockerServerRequest.CallAPI(...)`, defined as a `struct` inside `Runtime/Client/LootLockerHTTPClient.cs`. +- Typical pattern: + 1) Choose an endpoint from `Runtime/Client/LootLockerEndPoints.cs`. + 2) Format it using `EndPointClass.WithPathParameter(s)` when needed (`Runtime/Client/EndPointClass.cs`). + 3) Serialize the request body with `LootLockerJson.SerializeObject(...)`. + 4) Call `LootLockerServerRequest.CallAPI(...)`. + 5) Convert the raw response into your typed response using `LootLockerResponse.Deserialize(onComplete, serverResponse)`. + +See `.github/instructions/patterns.md` for copy/paste templates. + +### 2.3 Response & error handling conventions + +- All typed responses inherit `LootLockerResponse` (`Runtime/Client/LootLockerResponse.cs`). + - Key fields used by consumers: + - `success` (bool) + - `statusCode` (int) + - `errorData` (`LootLockerErrorData`, `Runtime/Client/LootLockerErrorData.cs`) + - `text` (raw response body) + +- Use `LootLockerResponse.Deserialize(...)` to create typed DTOs from raw responses. + - If `serverResponse.errorData != null`, deserialization returns a failed `T` that carries `errorData` and the HTTP fields. + +- For client-side validation failures (e.g., null input), return a typed error response via `LootLockerResponseFactory`: + - Example observed: `LootLockerResponseFactory.InputUnserializableError(forPlayerWithUlid)`. + +### 2.4 Endpoint constants (`LootLockerEndPoints`) + +- Endpoint constants are declared in `Runtime/Client/LootLockerEndPoints.cs` as `static EndPointClass` fields. +- Add new endpoints near related ones and keep the section headers (`[Header("...")]`) consistent. +- Naming is not fully consistent across the file (both `camelCase` and `PascalCase` exist). + - Default rule for new endpoints: **match the naming style of the surrounding section**. + - If you’re unsure (or the section is mixed), ask for a decision before introducing a new naming style. + +## 3) Logging & secrets + +### 3.1 Logger to use + +- Use `LootLockerLogger.Log(...)` (`Runtime/Game/LootLockerLogger.cs`). +- Avoid calling `UnityEngine.Debug.Log*` directly in SDK code unless you are inside the logger implementation or temporarily debugging. + +### 3.2 Do not log secrets + +- Never log API keys, domain keys, passwords, session tokens, refresh tokens, or other credentials unless obfuscated. +- When logging HTTP request/response bodies: + - Route logs through the logger’s obfuscation/prettify pipeline. + - The SDK supports obfuscation using `LootLockerObfuscator.ObfuscateJsonStringForLogging(...)` (`Runtime/Game/LootLockerObfuscator.cs`) and `LootLockerConfig.current.obfuscateLogs`. + +## 4) Serialization conventions + +- Use `LootLockerJson` (`Runtime/Client/LootLockerJson.cs`) for all serialization/deserialization. +- Serializer selection is compile-time: + - If `LOOTLOCKER_USE_NEWTONSOFTJSON` is defined, the SDK uses Newtonsoft. + - Otherwise it uses the built-in `ZeroDepJson` implementation under `Runtime/Libraries/ZeroDepJson/`. +- Do **not** introduce new serialization libraries. +- Prefer DTO property names that match the server’s JSON field names to ensure consistent behavior across serializers. + +## 5) Formatting & tooling + +- No repo-level `.editorconfig` is currently present. +- Rule: **match surrounding style** and do not reformat unrelated code. + - Keep braces/spacing consistent with the file you are editing. + - Keep using directives consistent with existing patterns in that file. + +## 6) Diff hygiene + +- Keep diffs minimal and localized to the requested change. +- Avoid large renames/moves; avoid mechanical formatting changes. +- Search-first to prevent duplicated helpers/utilities. diff --git a/.github/instructions/testing.md b/.github/instructions/testing.md new file mode 100644 index 000000000..fefc60a1b --- /dev/null +++ b/.github/instructions/testing.md @@ -0,0 +1,135 @@ +# Testing: writing and running SDK integration tests + +This document explains how tests are structured, how to write new tests, and how to run +them locally or in CI. + +For path-specific rules (templates, naming, categories), see the scoped instructions: +- `Tests/LootLockerTests/PlayMode/**`: `.github/instructions/Tests/LootLockerTests/PlayMode.instructions.md` +- `Tests/LootLockerTestUtils/**`: `.github/instructions/Tests/LootLockerTestUtils.instructions.md` + +--- + +## Repository layout + +``` +Tests/ + LootLockerTests/ + PlayMode/ ← integration tests (one file per feature) + PlayModeTests.asmdef + LootLockerTestUtils/ ← admin-API helpers consumed by tests + LootLockerTestUtils.asmdef +``` + +All tests are **Unity PlayMode tests** under `LootLockerTests/PlayMode/`. There are no +EditMode tests in this folder (except `BroadcastTests.cs`, which is intentionally `[Test]` +not `[UnityTest]` because it tests pure C# logic). + +--- + +## How tests provision game configuration + +Tests do **not** rely on pre-existing API keys or shared game state. Instead, every test +class creates a fresh LootLocker game via the **admin API** in `[UnitySetUp]` and deletes +it in `[UnityTearDown]`: + +1. `LootLockerTestGame.CreateGame(...)` calls the admin API to create a new game, returning + a `LootLockerTestGame` object with the game's API key and helpers. +2. `gameUnderTest.SwitchToStageEnvironment()` targets the stage environment. +3. Platform/feature enablement — e.g. `gameUnderTest.EnableGuestLogin(...)`, + `gameUnderTest.CreateLeaderboard(...)` — configures the game for the test. +4. `gameUnderTest.InitializeLootLockerSDK()` configures the SDK singleton with the fresh + game's API key so subsequent `LootLockerSDKManager.*` calls hit the correct game. +5. `[UnityTearDown]` always calls `gameUnderTest.DeleteGame(...)` to clean up. + +Admin authentication is handled automatically by `LootLockerTestUser.GetUserOrSignIn`, which +derives credentials from the current date and signs up or logs in as needed — no pre-stored +secrets are required for stage environments. + +--- + +## Test categories + +| Category | Run by | Use when | +|---|---|---| +| `LootLocker` | Manual / local run (all tests) | Always add this | +| `LootLockerCI` | Full CI run | Always add this for new tests | +| `LootLockerCIFast` | Fast CI subset | Add only when the test reliably finishes in < ~10 s | +| `LootLockerDebug` | Targeted debug run | **Temporary only** — never commit; see `debugging.md` | + +Every production test method should be decorated (but choose categories according to the above table): +```csharp +[UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] +``` + +--- + +## Writing a new test + +1. Create a file `Tests/LootLockerTests/PlayMode/Tests.cs`. +2. Follow the `[UnitySetUp]` / `[UnityTearDown]` template in + `.github/instructions/Tests/LootLockerTests/PlayMode.instructions.md`. +3. In `Setup`, enable only the platforms and game features the test actually needs. +4. Write test methods following the `__` naming pattern. +5. Follow the **Given / When / Then** structure in the test body. +6. Tag each method with the appropriate `Category` attributes. + +Do not create shared state between test methods. With `[UnitySetUp]`/`[UnityTearDown]`, +each test method gets its own fresh game, so tests can and will run in any order. + +--- + +## Adding admin API helpers (LootLockerTestUtils) + +If your test needs game configuration that no existing helper covers: + +1. Add the admin API endpoint constant to `LootLockerTestConfigurationEndpoints.cs`. +2. Add a method to the relevant class in `LootLockerTestUtils/` + (e.g. `LootLockerTestGame`, `LootLockerTestAssets`) or create a new file for a new + domain area. +3. Follow the patterns in `.github/instructions/Tests/LootLockerTestUtils.instructions.md`. + +--- + +## Running tests locally + +Use the PowerShell script `.github/scripts/run-tests.ps1`: + +```powershell +# Run fast subset (LootLockerCIFast) +.github\scripts\run-tests.ps1 -TestCategory LootLockerCIFast + +# Run a specific test class by name +.github\scripts\run-tests.ps1 -TestFilter "LeaderboardTest" + +# Run a specific test method by full name +.github\scripts\run-tests.ps1 -TestFilter "LootLockerTests.PlayMode.LeaderboardTest.Leaderboard_ListTopTen_Succeeds" + +# Run all SDK tests +.github\scripts\run-tests.ps1 -TestCategory LootLocker +``` + +Requirements: `unity-dev-settings.json` at repo root with a valid `unity_executable`. +See `.github/instructions/verification.md` for setup instructions. + +--- + +## Running tests in CI + +Push to a work branch — the **`Run Tests`** workflow +(`.github/workflows/run-tests.yml`) can be triggered manually via `workflow_dispatch` +from the **Actions** tab with optional `testCategory` and `testFilter` inputs. + +The full integration test suite (`LootLockerCI`) runs automatically on PRs to `dev` and +`main` via the existing `run-tests-and-package.yml` workflow. + +--- + +## What counts as "tested" + +A test pass is verified when **either** of the following is true after your change: + +- The `Run Tests` CI workflow is green on your branch, **or** +- The local `.github/scripts/run-tests.ps1` exits `0`. + +Always run the fast subset (`LootLockerCIFast`) at minimum before opening a PR. If your +change directly touches a feature area with existing tests, run those tests specifically. diff --git a/.github/instructions/verification.md b/.github/instructions/verification.md new file mode 100644 index 000000000..8e77c394e --- /dev/null +++ b/.github/instructions/verification.md @@ -0,0 +1,111 @@ +# Verifying Changes: Compilation & Tests + +This repo ships as a Unity UPM package. Unity C# must be verified through the Unity +Editor's compilation pipeline — `dotnet build` does **not** work on this codebase. + +--- + +## Cloud coding agent (GitHub Copilot agent / CI) + +When you push commits to a work branch the **`Compile Check`** workflow +(`.github/workflows/compile-check.yml`) runs automatically and verifies: + +1. All SDK C# code compiles without errors (including Samples). +2. The configured Unity **editmode** tests pass (see the workflow for exact coverage). + +### Workflow to follow after each batch of commits + +1. Push your work branch (e.g. `feat/my-feature`, `fix/something`). +2. Navigate to the **Actions** tab → **Compile Check** → your branch's run. +3. Wait for it to complete and confirm it is **green** before opening a PR. + +If the workflow fails: + +1. **Job Summary** (fastest) — the run's Summary page shows a compact + `Compilation Errors` table with file path, line number, and error code/message. + This is the primary place to read errors; no log-digging required. +2. **Annotations** — the Summary page also lists inline annotations (e.g. + `sdk/Runtime/Game/Requests/TriggersRequests.cs#L3`) that link directly to the + offending line. These also appear in the PR diff view. +3. **Raw log** (fallback) — if neither of the above is present the compile step itself + may have crashed before producing output; click the failed step and search for + `compilationhadfailure: True` to find the relevant section. + +Fix the reported errors and push again. + +> The full CI suite (`run-tests-and-package.yml`) runs on PRs to `dev` and `main`. +> The compile check is a smaller, faster subset for in-progress work branches. + +--- + +## Local (human developer or local Copilot instance) + +### Prerequisites + +1. Unity is installed locally (any Unity **2019.2+** version works; best to match the + project's minimum version in `package.json`). +2. You have a `unity-dev-settings.json` at the repo root (gitignored). +3. Python 3 is installed and available on your `PATH` as `python3` (used by the + local verification scripts to parse `unity-dev-settings.json`). + +### One-time setup + +Open or create `unity-dev-settings.json` and fill in the two fields: + +```json +{ + "unity_executable": "", + "test_project_path": "" +} +``` + +**`unity_executable` examples by platform:** + +| Platform | Example path | +|---|---| +| macOS | `/Applications/Unity/Hub/Editor/2022.3.22f1/Unity.app/Contents/MacOS/Unity` | +| Windows | `C:\Program Files\Unity\Hub\Editor\2022.3.22f1\Editor\Unity.exe` | +| Linux | `/opt/unity/Editor/Unity` | + +**`test_project_path`**: leave empty to let the script auto-create a temporary project +that references the SDK. Set to an absolute path only if you already maintain a +dedicated local Unity project that points at this SDK via a local package reference. + +### Running the check + +**Linux / macOS (bash):** +```bash +.github/scripts/verify-compilation.sh +``` + +**Windows (PowerShell):** +```powershell +.github\scripts\verify-compilation.ps1 +``` + +The script will: + +1. Read `unity-dev-settings.json`. +2. Create a temporary Unity project at `Temp~/VerificationProject` (gitignored) that + references the SDK as a local package and includes the Samples. +3. Launch Unity in batch mode with `-batchmode -nographics -quit`. +4. Print Unity's compilation output filtered to errors and warnings. +5. Exit `0` on success, `1` on any compilation error. + +--- + +## What counts as "verified" + +A change is verified when **either** of the following is true: + +- The `Compile Check` CI workflow is green on your branch, **or** +- The local verification script exits `0`. + +**Additionally** ensure: + +- No new `error CS` compiler errors appear. +- No existing public API signatures were changed without going through the deprecation + flow described in `.github/instructions/style-guide.md`. + +Running the full integration tests (`run-tests-and-package.yml`) is a CI-only step and +is not required for local verification. diff --git a/.github/scripts/run-tests.ps1 b/.github/scripts/run-tests.ps1 new file mode 100644 index 000000000..b54a8433f --- /dev/null +++ b/.github/scripts/run-tests.ps1 @@ -0,0 +1,239 @@ +#Requires -Version 5.0 +<# +.SYNOPSIS + Runs LootLocker SDK PlayMode tests against a local Unity installation. + +.DESCRIPTION + Reads unity-dev-settings.json from the repo root, creates (or reuses) a temporary + Unity test project that references the SDK as a local package, then runs Unity's + test runner in batch mode and reports results. + + Use -TestCategory to filter by NUnit category (e.g. LootLockerCIFast, LootLockerDebug). + Use -TestFilter to filter by full or partial test name / regex. + Both can be combined; Unity applies them with AND logic. + + See .github/instructions/testing.md for more details. + +.PARAMETER TestCategory + NUnit category to run (e.g. "LootLockerCIFast", "LootLockerCI", "LootLockerDebug"). + If omitted, all tests in the LootLocker category are run. + +.PARAMETER TestFilter + Full or partial test name, class name, or NUnit filter string + (e.g. "LeaderboardTest", "Leaderboard_ListTopTen_Succeeds"). + Supports Unity's "-testFilter" regex syntax. + +.PARAMETER TestMode + Unity test platform — PlayMode (default) or EditMode. + +.PARAMETER Force + If specified, always recreates the temporary test project even if it already exists. + +.EXAMPLE + # Fast subset (good default before a commit) + .github\scripts\run-tests.ps1 -TestCategory LootLockerCIFast + + # All tests for a specific feature + .github\scripts\run-tests.ps1 -TestFilter "LeaderboardTest" + + # Debug category (temporary tests only) + .github\scripts\run-tests.ps1 -TestCategory LootLockerDebug + + # Combine category and name filter + .github\scripts\run-tests.ps1 -TestCategory LootLockerCI -TestFilter "GuestSession" +#> + +param( + [string] $TestCategory = "", + [string] $TestFilter = "", + [ValidateSet("PlayMode", "EditMode")] + [string] $TestMode = "PlayMode", + [switch] $Force +) + +$ErrorActionPreference = 'Stop' + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$SettingsFile = Join-Path $RepoRoot "unity-dev-settings.json" +$TempProject = Join-Path $RepoRoot "Temp~\TestProject" +$ResultsFile = Join-Path $TempProject "TestResults.xml" +$LogFile = Join-Path $TempProject "test-run.log" + +function Write-Step { param([string]$Msg) Write-Host $Msg } +function Write-Ok { param([string]$Msg) Write-Host $Msg -ForegroundColor Green } +function Write-Fail { param([string]$Msg) Write-Host $Msg -ForegroundColor Red } +function Write-Warn { param([string]$Msg) Write-Host $Msg -ForegroundColor Yellow } + +Write-Step "=========================================" +Write-Step " LootLocker SDK - Test Runner" +Write-Step "=========================================" +Write-Step "" + +# --------------------------------------------------------------------------- +# 1. Load settings +# --------------------------------------------------------------------------- +if (-not (Test-Path $SettingsFile)) { + Write-Warn "SETUP REQUIRED: 'unity-dev-settings.json' not found at repo root." + Write-Step " Create unity-dev-settings.json with your Unity path." + Write-Step " See .github/instructions/verification.md for the required format." + exit 1 +} + +$Settings = Get-Content $SettingsFile -Raw | ConvertFrom-Json +$UnityExe = $Settings.unity_executable + +if ([string]::IsNullOrWhiteSpace($UnityExe)) { + Write-Fail "ERROR: 'unity_executable' is empty in unity-dev-settings.json." + exit 1 +} +if (-not (Test-Path $UnityExe)) { + Write-Fail "ERROR: Unity executable not found: $UnityExe" + exit 1 +} + +# --------------------------------------------------------------------------- +# 2. Create / refresh the test project if needed +# --------------------------------------------------------------------------- +function Initialize-TestProject { + Write-Step "Creating test project at Temp~/TestProject ..." + + if (Test-Path $TempProject) { Remove-Item $TempProject -Recurse -Force } + New-Item -ItemType Directory -Path (Join-Path $TempProject "Assets") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $TempProject "Packages") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $TempProject "ProjectSettings") -Force | Out-Null + + $SdkRef = $RepoRoot -replace '\\', '/' + $nl = [char]10 + # Include "testables" so Unity imports the SDK's test assemblies (UNITY_INCLUDE_TESTS) + $manifestContent = '{' + $nl + ' "dependencies": {' + $nl + ' "com.lootlocker.lootlockersdk": "file:' + $SdkRef + '"' + $nl + ' },' + $nl + ' "testables": [' + $nl + ' "com.lootlocker.lootlockersdk"' + $nl + ' ]' + $nl + '}' + [IO.File]::WriteAllText((Join-Path $TempProject 'Packages\manifest.json'), $manifestContent) + + $psContent = '%YAML 1.1' + $nl + '%TAG !u! tag:unity3d.com,2011:' + $nl + '--- !u!129 &1' + $nl + 'PlayerSettings:' + $nl + ' companyName: LootLockerSDKVerification' + $nl + ' productName: LootLockerSDKVerification' + [IO.File]::WriteAllText((Join-Path $TempProject 'ProjectSettings\ProjectSettings.asset'), $psContent) +} + +if ($Force -or -not (Test-Path (Join-Path $TempProject "Packages\manifest.json"))) { + Initialize-TestProject +} else { + Write-Step "Reusing existing test project at Temp~/TestProject (use -Force to recreate)." +} + +# --------------------------------------------------------------------------- +# 3. Build Unity arguments +# --------------------------------------------------------------------------- +$UnityArgs = @( + "-batchmode", + "-nographics", + "-projectPath", $TempProject, + "-logFile", $LogFile, + "-runTests", + "-testPlatform", $TestMode, + "-testResults", $ResultsFile +) + +if (-not [string]::IsNullOrWhiteSpace($TestCategory)) { + $UnityArgs += @("-testCategory", $TestCategory) +} +if (-not [string]::IsNullOrWhiteSpace($TestFilter)) { + $UnityArgs += @("-testFilter", $TestFilter) +} + +# --------------------------------------------------------------------------- +# 4. Run Unity test runner +# --------------------------------------------------------------------------- +Write-Step "" +Write-Step "Unity: $UnityExe" +Write-Step "Project: $TempProject" +Write-Step "Mode: $TestMode" +if (-not [string]::IsNullOrWhiteSpace($TestCategory)) { Write-Step "Category: $TestCategory" } +if (-not [string]::IsNullOrWhiteSpace($TestFilter)) { Write-Step "Filter: $TestFilter" } +Write-Step "Results: $ResultsFile" +Write-Step "Log: $LogFile" +Write-Step "" + +if (-not (Test-Path (Split-Path $LogFile))) { New-Item -ItemType Directory -Path (Split-Path $LogFile) -Force | Out-Null } +if (Test-Path $LogFile) { Remove-Item $LogFile -Force } +if (Test-Path $ResultsFile) { Remove-Item $ResultsFile -Force } + +$prevEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +& $UnityExe @UnityArgs +$script:unityExitCode = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } else { 1 } +$ErrorActionPreference = $prevEAP + +# --------------------------------------------------------------------------- +# 5. Wait for Unity to finish writing the results file +# --------------------------------------------------------------------------- +$resultsContent = "" +for ($i = 0; $i -lt 360; $i++) { + Start-Sleep -Milliseconds 500 + if (-not (Test-Path $ResultsFile)) { continue } + try { + $stream = [System.IO.File]::Open($ResultsFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + $reader = [System.IO.StreamReader]::new($stream, $true) + $resultsContent = $reader.ReadToEnd() + $reader.Close(); $stream.Close() + if ($resultsContent -match "") { break } + } catch { } +} + +# --------------------------------------------------------------------------- +# 6. Parse and report results +# --------------------------------------------------------------------------- +Write-Step "" +Write-Step "--- Test results -----------------------------------------" + +if ([string]::IsNullOrWhiteSpace($resultsContent)) { + Write-Fail "No test results file found. Unity may have crashed or timed out." + Write-Step "Check the log: $LogFile" + exit 1 +} + +# Parse NUnit XML summary attributes from the element +$totalMatch = [regex]::Match($resultsContent, 'total="(\d+)"') +$passedMatch = [regex]::Match($resultsContent, 'passed="(\d+)"') +$failedMatch = [regex]::Match($resultsContent, 'failed="(\d+)"') +$skippedMatch = [regex]::Match($resultsContent, 'skipped="(\d+)"') + +$total = if ($totalMatch.Success) { $totalMatch.Groups[1].Value } else { "?" } +$passed = if ($passedMatch.Success) { $passedMatch.Groups[1].Value } else { "?" } +$failed = if ($failedMatch.Success) { $failedMatch.Groups[1].Value } else { "?" } +$skipped = if ($skippedMatch.Success) { $skippedMatch.Groups[1].Value } else { "?" } + +Write-Step " Total: $total" +Write-Step " Passed: $passed" +Write-Step " Failed: $failed" +Write-Step " Skipped: $skipped" + +# Print individual failure messages for quick diagnosis +if ($failed -ne "0" -and $failed -ne "?") { + Write-Step "" + Write-Step "--- Failed tests -----------------------------------------" + $failureMatches = [regex]::Matches($resultsContent, ']*?\sname="([^"]+)"[^>]*?\sresult="Failed"[^>]*?>[\s\S]*?') + foreach ($m in $failureMatches) { + Write-Fail "FAIL: $($m.Groups[1].Value)" + $msg = $m.Groups[2].Value.Trim() + if ($msg.Length -gt 0) { + $msg -split "`n" | Select-Object -First 5 | ForEach-Object { Write-Step " $_" } + } + } +} + +Write-Step "----------------------------------------------------------" +Write-Step "" + +if ($failed -eq "0" -or $failed -eq "?") { + if ($script:unityExitCode -eq 0) { + Write-Ok "ALL TESTS PASSED" + exit 0 + } else { + Write-Warn "Unity exited with code $($script:unityExitCode) but no test failures found." + Write-Warn "Check the log for non-test errors: $LogFile" + exit $script:unityExitCode + } +} else { + Write-Fail "TESTS FAILED ($failed failure(s))" + Write-Step "Full results: $ResultsFile" + Write-Step "Full log: $LogFile" + exit 1 +} diff --git a/.github/scripts/verify-compilation.ps1 b/.github/scripts/verify-compilation.ps1 new file mode 100644 index 000000000..663e46447 --- /dev/null +++ b/.github/scripts/verify-compilation.ps1 @@ -0,0 +1,235 @@ +#Requires -Version 5.0 +<# +.SYNOPSIS + Verifies that the LootLocker Unity SDK compiles without errors. + +.DESCRIPTION + Reads unity-dev-settings.json from the repo root, optionally creates a temporary + Unity project referencing the SDK, then runs Unity in batch mode to check + compilation. + + See .github/instructions/verification.md for setup instructions. +#> + +$ErrorActionPreference = 'Stop' + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$SettingsFile = Join-Path $RepoRoot "unity-dev-settings.json" +$TempProject = Join-Path $RepoRoot "Temp~\VerificationProject" +$LogFile = Join-Path $TempProject "compilation.log" + +function Write-Step { param([string]$Msg) Write-Host $Msg } +function Write-Ok { param([string]$Msg) Write-Host $Msg -ForegroundColor Green } +function Write-Fail { param([string]$Msg) Write-Host $Msg -ForegroundColor Red } +function Write-Warn { param([string]$Msg) Write-Host $Msg -ForegroundColor Yellow } + +Write-Step "=========================================" +Write-Step " LootLocker SDK - Compilation Check" +Write-Step "=========================================" +Write-Step "" + +# --------------------------------------------------------------------------- +# 1. Load settings +# --------------------------------------------------------------------------- +if (-not (Test-Path $SettingsFile)) { + Write-Warn "SETUP REQUIRED: 'unity-dev-settings.json' not found at repo root." + Write-Step " Create unity-dev-settings.json with your Unity path." + Write-Step " See .github/instructions/verification.md for the required format and examples." + exit 1 +} + +$Settings = Get-Content $SettingsFile -Raw | ConvertFrom-Json +$UnityExe = $Settings.unity_executable +$CustomProject = $Settings.test_project_path + +if ([string]::IsNullOrWhiteSpace($UnityExe)) { + Write-Fail "ERROR: 'unity_executable' is empty in unity-dev-settings.json." + exit 1 +} +if (-not (Test-Path $UnityExe)) { + Write-Fail "ERROR: Unity executable not found: $UnityExe" + exit 1 +} + +# --------------------------------------------------------------------------- +# 2. Helper: create / refresh the temporary verification project +# --------------------------------------------------------------------------- +function Initialize-TempProject { + Write-Step "Creating temporary verification project at Temp~/VerificationProject ..." + + if (Test-Path $TempProject) { Remove-Item $TempProject -Recurse -Force } + New-Item -ItemType Directory -Path (Join-Path $TempProject "Assets") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $TempProject "Packages") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $TempProject "ProjectSettings") -Force | Out-Null + + $SdkRef = $RepoRoot -replace '\\', '/' + $nl = [char]10 + $manifestContent = '{' + $nl + ' "dependencies": {' + $nl + ' "com.lootlocker.lootlockersdk": "file:' + $SdkRef + '"' + $nl + ' }' + $nl + '}' + [IO.File]::WriteAllText((Join-Path $TempProject 'Packages\manifest.json'), $manifestContent) + + $psContent = '%YAML 1.1' + $nl + '%TAG !u! tag:unity3d.com,2011:' + $nl + '--- !u!129 &1' + $nl + 'PlayerSettings:' + $nl + ' companyName: LootLockerSDKVerification' + $nl + ' productName: LootLockerSDKVerification' + [IO.File]::WriteAllText((Join-Path $TempProject 'ProjectSettings\ProjectSettings.asset'), $psContent) + + $SamplesPath = Join-Path $RepoRoot "Samples~\LootLockerExamples" + if (Test-Path $SamplesPath) { + Copy-Item $SamplesPath (Join-Path $TempProject "Assets\") -Recurse -Force + } +} + +# --------------------------------------------------------------------------- +# 3. Determine project path +# --------------------------------------------------------------------------- +if (-not [string]::IsNullOrWhiteSpace($CustomProject) -and (Test-Path $CustomProject)) { + $ProjectPath = (Resolve-Path $CustomProject).Path + Write-Step "Using custom project: $ProjectPath" + + # Delete only the LootLocker compiled output artifacts so Tundra is forced to + # recompile the SDK from source. Deleting the entire Bee folder crashes Unity; + # deleting only outputs is safe — Tundra detects missing outputs and rebuilds them. + Write-Step "Removing cached LootLocker assemblies to force recompilation..." + $artifactsPath = Join-Path $ProjectPath "Library\Bee\artifacts" + Get-ChildItem $artifactsPath -Recurse -Filter "*lootlocker*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue + $scriptAssemblies = Join-Path $ProjectPath "Library\ScriptAssemblies" + Get-ChildItem $scriptAssemblies -Filter "*lootlocker*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue +} else { + Initialize-TempProject + $ProjectPath = $TempProject +} + +# --------------------------------------------------------------------------- +# 4. Run Unity in batch mode +# --------------------------------------------------------------------------- +function Invoke-UnityCompile { + param([string]$ProjectDir) + Write-Step "" + Write-Step "Unity: $UnityExe" + Write-Step "Project: $ProjectDir" + Write-Step "Log: $LogFile" + Write-Step "" + + # Ensure log directory exists; remove any stale log from a previous run. + if (-not (Test-Path (Split-Path $LogFile))) { New-Item -ItemType Directory -Path (Split-Path $LogFile) -Force | Out-Null } + if (Test-Path $LogFile) { Remove-Item $LogFile -Force } + + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + & $UnityExe -batchmode -nographics -projectPath $ProjectDir -logFile $LogFile -quit + $script:unityExitCode = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } else { 1 } + $ErrorActionPreference = $prevEAP +} + +$script:unityExitCode = 1 +Invoke-UnityCompile $ProjectPath + +# Wait for Unity to finish writing the log. Unity child processes (e.g. LicensingClient) +# may hold the file open after the main process exits. Poll until the log contains +# Unity's end-of-session marker. A full first-time compile can take 60-90 seconds so we +# wait up to 3 minutes. We also require the file size to be stable for 3 consecutive +# reads (1.5 s) before accepting the content, since LicensingClient may still be writing +# small amounts after the main process has exited. +$logContent = "" +$lastSize = -1 +$stableRuns = 0 +for ($i = 0; $i -lt 360; $i++) { + Start-Sleep -Milliseconds 500 + if (-not (Test-Path $LogFile)) { continue } + try { + $stream = [System.IO.File]::Open($LogFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + $reader = [System.IO.StreamReader]::new($stream, $true) # $true = detect encoding from BOM + $logContent = $reader.ReadToEnd() + $reader.Close(); $stream.Close() + + $currentSize = $logContent.Length + if ($currentSize -eq $lastSize) { $stableRuns++ } else { $stableRuns = 0 } + $lastSize = $currentSize + + # Stop once Unity's final line is present AND the file hasn't grown for 3 checks + if (($logContent -match "Application will terminate|Exiting batchmode") -and $stableRuns -ge 3) { break } + } catch { } +} + +# If the custom project crashed on startup (Package Manager never ran — log is tiny with +# no compilation output), fall back to the temp project automatically. +if ($ProjectPath -ne $TempProject -and ($logContent.Length -lt 5000 -or $logContent -notmatch "Package Manager")) { + Write-Warn "Custom project did not open correctly (startup crash). Falling back to temporary project." + Write-Warn "To fix: open '$ProjectPath' in the Unity Editor once, then re-run." + Initialize-TempProject + $ProjectPath = $TempProject + Invoke-UnityCompile $ProjectPath + + $lastSize = -1; $stableRuns = 0; $logContent = "" + for ($i = 0; $i -lt 360; $i++) { + Start-Sleep -Milliseconds 500 + if (-not (Test-Path $LogFile)) { continue } + try { + $stream = [System.IO.File]::Open($LogFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + $reader = [System.IO.StreamReader]::new($stream, $true) + $logContent = $reader.ReadToEnd() + $reader.Close(); $stream.Close() + $currentSize = $logContent.Length + if ($currentSize -eq $lastSize) { $stableRuns++ } else { $stableRuns = 0 } + $lastSize = $currentSize + if (($logContent -match "Application will terminate|Exiting batchmode") -and $stableRuns -ge 3) { break } + } catch { } + } +} + +$logLines = $logContent -split "`n" + +# --------------------------------------------------------------------------- +# 5. Report results +# --------------------------------------------------------------------------- +Write-Step "" +Write-Step "--- Compilation result -------" + +$compileErrors = $logLines | Select-String -Pattern "error CS\d+" | Select-String -NotMatch "Licensing::" +$tundraSuccess = ($logLines | Select-String -Pattern "Tundra build success").Count -gt 0 +$tundraFailure = ($logLines | Select-String -Pattern "Tundra build failure|Tundra build failed").Count -gt 0 + +if ($compileErrors) { + $compileErrors | ForEach-Object { Write-Host $_.Line } +} + +Write-Step "-----------------------------------" +Write-Step "" + +# Determine outcome from log content: +# - Any "error CS####" line => compilation failed +# - "Tundra build failed" marker => compilation failed +# - No compiler errors and Unity exit code 0 => compilation succeeded (even if no Tundra marker) +# - "Tundra build success" found => compilation succeeded (Unity may still exit non-zero +# due to unrelated project setup issues unrelated to the SDK) +if ($compileErrors.Count -gt 0) { + Write-Fail "COMPILATION FAILED ($($compileErrors.Count) compiler error(s))" + Write-Step "Full log: $LogFile" + exit 1 +} +elseif ($tundraFailure) { + # Explicit Tundra failure should be treated as a hard failure regardless of exit code + if ($logLines) { + $logLines | Select-String -Pattern "error CS\d+|Scripts have compiler errors|error:" | Select-String -NotMatch "Licensing::" | + ForEach-Object { Write-Host $_.Line } + } + Write-Fail "COMPILATION FAILED (Tundra build failed)" + Write-Step "Full log: $LogFile" + exit 1 +} +elseif ($unityExitCode -eq 0) { + # Treat a clean Unity exit with no compiler errors as success, even if we did not see a Tundra marker + Write-Ok "COMPILATION SUCCEEDED" +} +elseif ($tundraSuccess) { + Write-Ok "COMPILATION SUCCEEDED" + Write-Warn "Note: Unity exited with code $unityExitCode after compilation (likely unrelated project setup - not an SDK issue)." +} +else { + # Non-zero Unity exit code with no clear Tundra success/failure marker; surface diagnostics and fail + if ($logLines) { + $logLines | Select-String -Pattern "error CS\d+|Scripts have compiler errors|error:" | Select-String -NotMatch "Licensing::" | + ForEach-Object { Write-Host $_.Line } + } + $reason = "exit code: $unityExitCode" + Write-Fail "COMPILATION FAILED ($reason)" + Write-Step "Full log: $LogFile" + exit 1 +} diff --git a/.github/scripts/verify-compilation.sh b/.github/scripts/verify-compilation.sh new file mode 100644 index 000000000..ace83874d --- /dev/null +++ b/.github/scripts/verify-compilation.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# verify-compilation.sh — LootLocker Unity SDK local compilation check +# See .github/instructions/verification.md for setup instructions. +set -uo pipefail # intentionally no -e: we handle non-zero exits ourselves + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SETTINGS_FILE="$REPO_ROOT/unity-dev-settings.json" +TEMP_PROJECT="$REPO_ROOT/Temp~/VerificationProject" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' + +echo "=========================================" +echo " LootLocker SDK - Compilation Check" +echo "=========================================" +echo "" + +# --------------------------------------------------------------------------- +# 1. Load settings +# --------------------------------------------------------------------------- +if [[ ! -f "$SETTINGS_FILE" ]]; then + echo -e "${YELLOW}SETUP REQUIRED:${NC} 'unity-dev-settings.json' not found at repo root." + echo " Create unity-dev-settings.json with your Unity path." + echo " See .github/instructions/verification.md for the required format and examples." + exit 1 +fi + +read_json_field() { + python3 -c "import json; d=json.load(open('$1')); print(d.get('$2',''))" 2>/dev/null || true +} + +UNITY_EXE=$(read_json_field "$SETTINGS_FILE" "unity_executable") +CUSTOM_PROJECT=$(read_json_field "$SETTINGS_FILE" "test_project_path") + +if [[ -z "$UNITY_EXE" ]]; then + echo -e "${RED}ERROR:${NC} 'unity_executable' is empty in unity-dev-settings.json." + exit 1 +fi +if [[ ! -f "$UNITY_EXE" || ! -x "$UNITY_EXE" ]]; then + echo -e "${RED}ERROR:${NC} Unity executable not found or not executable: $UNITY_EXE" + exit 1 +fi + +# --------------------------------------------------------------------------- +# 2. Helper: create / refresh the temporary verification project +# --------------------------------------------------------------------------- +init_temp_project() { + echo "Creating temporary verification project at Temp~/VerificationProject ..." + rm -rf "$TEMP_PROJECT" + mkdir -p "$TEMP_PROJECT/Assets" "$TEMP_PROJECT/Packages" "$TEMP_PROJECT/ProjectSettings" + + # manifest.json - local file: reference to the SDK root + cat > "$TEMP_PROJECT/Packages/manifest.json" < "$TEMP_PROJECT/ProjectSettings/ProjectSettings.asset" <<'EOF' +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!129 &1 +PlayerSettings: + companyName: LootLockerSDKVerification + productName: LootLockerSDKVerification +EOF + + # Copy Samples so they are also compiled + if [[ -d "$REPO_ROOT/Samples~/LootLockerExamples" ]]; then + cp -r "$REPO_ROOT/Samples~/LootLockerExamples" "$TEMP_PROJECT/Assets/" + fi +} + +# --------------------------------------------------------------------------- +# 3. Determine project path +# --------------------------------------------------------------------------- +if [[ -n "$CUSTOM_PROJECT" && -d "$CUSTOM_PROJECT" ]]; then + PROJECT_PATH="$CUSTOM_PROJECT" + echo "Using custom project: $PROJECT_PATH" + + # Delete only the LootLocker compiled output artifacts so Tundra is forced to + # recompile the SDK from source. Deleting the entire Bee folder crashes Unity; + # deleting only outputs is safe — Tundra detects missing outputs and rebuilds them. + echo "Removing cached LootLocker assemblies to force recompilation..." + find "$PROJECT_PATH/Library/Bee/artifacts" -iname "*lootlocker*" -delete 2>/dev/null || true + find "$PROJECT_PATH/Library/ScriptAssemblies" -iname "*lootlocker*" -delete 2>/dev/null || true +else + init_temp_project + PROJECT_PATH="$TEMP_PROJECT" +fi + +# --------------------------------------------------------------------------- +# 4. Run Unity in batch mode +# --------------------------------------------------------------------------- +# On Linux/macOS, -logFile - pipes Unity output directly to stdout, which is +# simpler and more reliable than polling a file. +run_unity() { + local proj="$1" + echo "" + echo "Unity: $UNITY_EXE" + echo "Project: $proj" + echo "" + UNITY_EXIT_CODE=0 + LOG_CONTENT=$("$UNITY_EXE" -batchmode -nographics -projectPath "$proj" -logFile - -quit 2>&1) \ + || UNITY_EXIT_CODE=$? +} + +run_unity "$PROJECT_PATH" + +# If the custom project crashed on startup (Package Manager never ran — output is tiny), +# fall back to the temp project automatically. +if [[ "$PROJECT_PATH" != "$TEMP_PROJECT" ]] && \ + ! echo "$LOG_CONTENT" | grep -q "Package Manager"; then + echo -e "${YELLOW}Custom project did not open correctly (startup crash). Falling back to temporary project.${NC}" + echo "To fix: open '$PROJECT_PATH' in the Unity Editor once, then re-run." + init_temp_project + PROJECT_PATH="$TEMP_PROJECT" + run_unity "$PROJECT_PATH" +fi + +# --------------------------------------------------------------------------- +# 5. Report results +# --------------------------------------------------------------------------- +echo "" +echo "--- Compilation result -------" + +# Collect compiler error lines, excluding Licensing noise +COMPILE_ERRORS=$(echo "$LOG_CONTENT" | grep -E "error CS[0-9]+" | grep -v "Licensing::" || true) +TUNDRA_SUCCESS=$(echo "$LOG_CONTENT" | grep -c "Tundra build success" || true) +TUNDRA_FAILURE=$(echo "$LOG_CONTENT" | grep -cE "Tundra build failure|Tundra build failed" || true) + +if [[ -n "$COMPILE_ERRORS" ]]; then + echo "$COMPILE_ERRORS" +fi +echo "-----------------------------------" +echo "" + +if [[ -n "$COMPILE_ERRORS" ]]; then + ERROR_COUNT=$(echo "$COMPILE_ERRORS" | wc -l | tr -d ' ') + echo -e "${RED}COMPILATION FAILED${NC} (${ERROR_COUNT} compiler error(s))" + exit 1 +elif [[ "$TUNDRA_FAILURE" -gt 0 ]]; then + # Explicit Tundra failure is a hard fail regardless of exit code + echo "$LOG_CONTENT" | grep -E "error CS[0-9]+|Scripts have compiler errors|error:" | grep -v "Licensing::" || true + echo -e "${RED}COMPILATION FAILED${NC} (Tundra build failed)" + exit 1 +elif [[ "$TUNDRA_SUCCESS" -gt 0 ]]; then + echo -e "${GREEN}COMPILATION SUCCEEDED${NC}" + if [[ $UNITY_EXIT_CODE -ne 0 ]]; then + echo -e "${YELLOW}Note: Unity exited with code $UNITY_EXIT_CODE after compilation (likely unrelated project setup - not an SDK issue).${NC}" + fi +elif [[ $UNITY_EXIT_CODE -eq 0 ]]; then + # No Tundra marker but Unity exited cleanly with no compiler errors — treat as success + echo -e "${GREEN}COMPILATION SUCCEEDED${NC}" +else + # Non-zero exit, no Tundra success, no compiler errors extracted — surface diagnostics + echo "$LOG_CONTENT" | grep -E "error CS[0-9]+|Scripts have compiler errors|error:" | grep -v "Licensing::" || true + echo -e "${RED}COMPILATION FAILED${NC} (exit code: $UNITY_EXIT_CODE)" + exit 1 +fi diff --git a/.github/workflows/compile-check.yml b/.github/workflows/compile-check.yml new file mode 100644 index 000000000..5db1c2834 --- /dev/null +++ b/.github/workflows/compile-check.yml @@ -0,0 +1,135 @@ +name: Compile Check +run-name: "Compile Check on ${{ github.ref_name }} (${{ github.sha }})" + +# Runs on every push to work branches so agents (and humans) get fast compilation +# feedback without waiting for the full CI pipeline. +# See .github/instructions/verification.md for details. +on: + push: + branches: + - 'feat/**' + - 'fix/**' + - 'docs/**' + - 'refactor/**' + - 'chore/**' + - 'test/**' + - 'scout/**' + - 'ci/**' + - 'copilot/**' + workflow_dispatch: + inputs: + unityVersion: + description: "Unity version to use (leave empty to use AGENT_CHECK_UNITY_VERSION variable)" + type: string + default: "" + +jobs: + compilation-check: + name: Verify SDK compiles + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + UNITY_VERSION: ${{ github.event.inputs.unityVersion || vars.AGENT_CHECK_UNITY_VERSION || '2022.3.22f1' }} + + steps: + - name: Checkout SDK + uses: actions/checkout@v4 + with: + path: sdk + + - name: Create minimal test project + run: | + mkdir -p TestProject/Assets TestProject/Packages TestProject/ProjectSettings + + # Package manifest — local reference to the SDK, no external secrets needed + cat > TestProject/Packages/manifest.json <<'EOF' + { + "dependencies": { + "com.lootlocker.lootlockersdk": "file:../../sdk" + } + } + EOF + + # Minimal ProjectSettings — no game keys or environment settings required + printf '%%YAML 1.1\n%%TAG !u! tag:unity3d.com,2011:\n--- !u!129 &1\nPlayerSettings:\n companyName: LootLockerSDKVerification\n productName: LootLockerSDKVerification\n' \ + > TestProject/ProjectSettings/ProjectSettings.asset + + # Include Samples so they are also compiled + if [ -d "sdk/Samples~/LootLockerExamples" ]; then + cp -r sdk/Samples~/LootLockerExamples TestProject/Assets/ + fi + + - name: Cache Unity Library + uses: actions/cache@v4 + with: + path: TestProject/Library + key: Library-compile-check-${{ env.UNITY_VERSION }} + restore-keys: | + Library-compile-check- + + - name: Compile SDK (edit-mode via Unity test runner) + id: compile + continue-on-error: true + uses: game-ci/unity-test-runner@v4.3.1 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + projectPath: TestProject + unityVersion: ${{ env.UNITY_VERSION }} + testMode: editmode + githubToken: ${{ secrets.GITHUB_TOKEN }} + checkName: Compile Check (${{ env.UNITY_VERSION }}) + + - name: Report compilation errors + if: always() + run: | + # game-ci writes the Unity editor log to artifacts/ alongside test results. + # If it wrote a log file, parse it; otherwise skip gracefully. + LOG_FILE=$(grep -rl "compilationhadfailure: True" artifacts/ 2>/dev/null | head -1) + + if [ -z "$LOG_FILE" ]; then + if [ "${{ steps.compile.outcome }}" = "failure" ]; then + echo "::warning::Compile step failed but no Unity log file found in artifacts/ to parse." + echo "## ⚠️ Compile step failed — no parseable log found" >> "$GITHUB_STEP_SUMMARY" + echo "Check the raw step output above for details." >> "$GITHUB_STEP_SUMMARY" + fi + exit 0 + fi + + # Extract standard MSBuild-style CS compiler errors: + # /github/workspace/sdk/Some/File.cs(line,col): error CSxxxx: message + ERRORS=$(grep -E "^.+\([0-9]+,[0-9]+\): error CS[0-9]+:" "$LOG_FILE" \ + | sed 's|/github/workspace/sdk/||g') + + if [ -z "$ERRORS" ]; then + # Compile step may have failed for a non-CS-error reason; still report it + if [ "${{ steps.compile.outcome }}" = "failure" ]; then + echo "::error::Compile step failed but no CS errors were extracted from the log." + fi + exit 0 + fi + + # ── Job Summary (compact card, ideal for agent consumption) ────────── + { + echo "## ❌ Compilation Errors" + echo "" + echo "| File | Line | Error |" + echo "| ---- | ---- | ----- |" + while IFS= read -r line; do + if [[ "$line" =~ ^(.*)\(([0-9]+),[0-9]+\):\ error\ (CS[0-9]+):\ (.+)$ ]]; then + echo "| \`${BASH_REMATCH[1]}\` | ${BASH_REMATCH[2]} | **${BASH_REMATCH[3]}**: ${BASH_REMATCH[4]} |" + fi + done <<< "$ERRORS" + } >> "$GITHUB_STEP_SUMMARY" + + # ── Inline annotations (show up in PR diff and Annotations panel) ──── + while IFS= read -r line; do + if [[ "$line" =~ ^(.*)\(([0-9]+),([0-9]+)\):\ error\ (CS[0-9]+):\ (.+)$ ]]; then + echo "::error file=sdk/${BASH_REMATCH[1]},line=${BASH_REMATCH[2]},col=${BASH_REMATCH[3]}::${BASH_REMATCH[4]}: ${BASH_REMATCH[5]}" + fi + done <<< "$ERRORS" + + # Propagate failure so the job still turns red + exit 1 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 000000000..36b4f8e74 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,172 @@ +name: Run Tests +run-name: "Run Tests on ${{ github.ref_name }} (category: ${{ inputs.testCategory || 'LootLockerCIFast' }}, filter: ${{ inputs.testFilter || '(none)' }})" + +# Runs the SDK's PlayMode integration tests. +# +# Triggered: +# - Manually via workflow_dispatch (primary use-case for agents and developers). +# - Automatically on push to test/** branches (for iterating on test work). +# +# The tests create real LootLocker games via the admin API; they handle their own +# authentication using time-scoped auto-generated credentials, so no pre-provisioned +# admin secrets are needed for stage environments. +# +# See .github/instructions/testing.md for full documentation. +on: + push: + branches: + - 'test/**' + workflow_dispatch: + inputs: + testCategory: + description: > + NUnit category to run. + Options: LootLockerCIFast, LootLockerCI, LootLocker, LootLockerDebug. + Leave empty to run LootLockerCIFast. + type: string + default: "LootLockerCIFast" + testFilter: + description: > + Optional NUnit test name filter (class name, method name, or regex). + Example: "LeaderboardTest" or "Leaderboard_ListTopTen_Succeeds". + Combined with testCategory using AND logic when both are supplied. + type: string + default: "" + unityVersion: + description: "Unity version to use (leave empty to use AGENT_CHECK_UNITY_VERSION variable)" + type: string + default: "" + +jobs: + run-tests: + name: Run SDK PlayMode tests + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + UNITY_VERSION: ${{ github.event.inputs.unityVersion || vars.AGENT_CHECK_UNITY_VERSION || '2022.3.22f1' }} + TEST_CATEGORY: ${{ github.event.inputs.testCategory || 'LootLockerCIFast' }} + TEST_FILTER: ${{ github.event.inputs.testFilter || '' }} + + steps: + - name: Checkout SDK + uses: actions/checkout@v4 + with: + path: sdk + + - name: Create test project + run: | + mkdir -p TestProject/Assets TestProject/Packages TestProject/ProjectSettings + + # Package manifest — local SDK reference + testables so Unity imports test assemblies + cat > TestProject/Packages/manifest.json <<'EOF' + { + "dependencies": { + "com.lootlocker.lootlockersdk": "file:../../sdk" + }, + "testables": [ + "com.lootlocker.lootlockersdk" + ] + } + EOF + + printf '%%YAML 1.1\n%%TAG !u! tag:unity3d.com,2011:\n--- !u!129 &1\nPlayerSettings:\n companyName: LootLockerSDKVerification\n productName: LootLockerSDKVerification\n' \ + > TestProject/ProjectSettings/ProjectSettings.asset + + - name: Cache Unity Library + uses: actions/cache@v4 + with: + path: TestProject/Library + key: Library-run-tests-${{ env.UNITY_VERSION }} + restore-keys: | + Library-run-tests- + Library-compile-check- + + - name: Build custom parameters string + id: params + run: | + PARAMS="" + if [ -n "${{ env.TEST_CATEGORY }}" ]; then + PARAMS="$PARAMS -testCategory ${{ env.TEST_CATEGORY }}" + fi + if [ -n "${{ env.TEST_FILTER }}" ]; then + PARAMS="$PARAMS -testFilter \"${{ env.TEST_FILTER }}\"" + fi + echo "custom_params=$PARAMS" >> "$GITHUB_OUTPUT" + + - name: Run PlayMode tests + id: tests + continue-on-error: true + uses: game-ci/unity-test-runner@v4.3.1 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + projectPath: TestProject + unityVersion: ${{ env.UNITY_VERSION }} + testMode: playmode + githubToken: ${{ secrets.GITHUB_TOKEN }} + checkName: "Run Tests (${{ env.UNITY_VERSION }}) — ${{ env.TEST_CATEGORY }}" + customParameters: ${{ steps.params.outputs.custom_params }} + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ env.UNITY_VERSION }} + path: artifacts/ + + - name: Report test results in Job Summary + if: always() + run: | + RESULTS_FILE=$(find artifacts/ -name "*.xml" -print -quit 2>/dev/null || true) + + if [ -z "$RESULTS_FILE" ]; then + if [ "${{ steps.tests.outcome }}" = "failure" ]; then + echo "## ⚠️ Test run failed — no results file found" >> "$GITHUB_STEP_SUMMARY" + echo "Check the raw step output for details." >> "$GITHUB_STEP_SUMMARY" + fi + exit 0 + fi + + # Extract summary counts from NUnit XML element + TOTAL=$(grep -oP 'total="\K[0-9]+' "$RESULTS_FILE" | head -1) + PASSED=$(grep -oP 'passed="\K[0-9]+' "$RESULTS_FILE" | head -1) + FAILED=$(grep -oP 'failed="\K[0-9]+' "$RESULTS_FILE" | head -1) + SKIPPED=$(grep -oP 'skipped="\K[0-9]+' "$RESULTS_FILE" | head -1) + + if [ "${FAILED:-0}" -gt 0 ]; then + echo "## ❌ Test Results — $FAILED failure(s)" >> "$GITHUB_STEP_SUMMARY" + else + echo "## ✅ Test Results — all passed" >> "$GITHUB_STEP_SUMMARY" + fi + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| | Count |" >> "$GITHUB_STEP_SUMMARY" + echo "|---|---|" >> "$GITHUB_STEP_SUMMARY" + echo "| Total | ${TOTAL:-?} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Passed | ${PASSED:-?} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Failed | ${FAILED:-?} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Skipped | ${SKIPPED:-?} |" >> "$GITHUB_STEP_SUMMARY" + + if [ "${FAILED:-0}" -gt 0 ]; then + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "### Failed tests" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + # Extract failure details: test name + first line of message + python3 - "$RESULTS_FILE" >> "$GITHUB_STEP_SUMMARY" <<'PYEOF' + import sys, xml.etree.ElementTree as ET + tree = ET.parse(sys.argv[1]) + for tc in tree.iter('test-case'): + if tc.get('result') == 'Failed': + name = tc.get('name', '(unknown)') + msg_el = tc.find('.//message') + msg = (msg_el.text or '').strip().split('\n')[0] if msg_el is not None else '' + print(f'- **{name}**: {msg}') + PYEOF + fi + + # Propagate failure so the job turns red on test failures + if [ "${FAILED:-0}" -gt 0 ]; then + exit 1 + fi diff --git a/.gitignore b/.gitignore index 16d7b6b96..5ef217be9 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,13 @@ crashlytics-build.properties .DS_Store +# Local Unity SDK dev settings (machine-specific, contains path to Unity installation) +unity-dev-settings.json +unity-dev-settings.json.meta + +# Temporary verification project created by .github/scripts/verify-compilation.* +Temp~/ + #IDE generated files .idea .vscode