# Unified Character Catalog Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make `CharacterConfig.json` the sole runtime source for both character selection and lobby prefab loading. **Architecture:** Add deterministic figure-resolution helpers to `CharacterConfigData`, where a one-based figure is the configured item’s list position. `LobbyWorldController` loads that resource configuration, uses it to translate the persisted ID and incoming network figures, then loads the resolved `prefabPath`; it no longer owns an actor-path list. **Tech Stack:** Unity 2022.3.62f3, C#, Unity Test Framework 1.1.33, NUnit. ## Global Constraints - Keep the existing wire protocol unchanged: `LobbyJoinMsg.Figure` remains a one-based `int`. - Preserve the generated alphabetical configuration order, so every client built from identical content derives identical figures. - Do not modify character prefabs, the game server, or the user’s unrelated working-tree changes. - A missing, empty, or invalid character config must log an explicit error and must not index a hard-coded fallback list. --- ### Task 1: Character configuration figure resolver **Files:** - Create: `Client/Assets/Script/Tests/EditMode/XWorld.Link.EditModeTests.asmdef` - Create: `Client/Assets/Script/Tests/EditMode/CharacterConfigDataTests.cs` - Modify: `Client/Assets/Script/xmain/Character/CharacterConfigData.cs` **Interfaces:** - Consumes: the ordered `CharacterConfigData.characters` list generated into `Assets/Resources/Config/CharacterConfig.json`. - Produces: `int GetFigure(string id)`, `int GetFigure(CharacterConfigItem item)`, and `CharacterConfigItem GetByFigure(int figure)` on `CharacterConfigData`. - [ ] **Step 1: Create the EditMode test assembly** Create `Client/Assets/Script/Tests/EditMode/XWorld.Link.EditModeTests.asmdef`: ```json { "name": "XWorld.Link.EditModeTests", "rootNamespace": "", "references": ["XWorld.Link"], "includePlatforms": ["Editor"], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false, "optionalUnityReferences": ["TestAssemblies"] } ``` - [ ] **Step 2: Write the failing catalog-mapping tests** Create `Client/Assets/Script/Tests/EditMode/CharacterConfigDataTests.cs`: ```csharp using System.Collections.Generic; using NUnit.Framework; using XGame; public sealed class CharacterConfigDataTests { private static CharacterConfigData CreateConfig() { return new CharacterConfigData { defaultCharacterId = "Assasin", characters = new List { new CharacterConfigItem { id = "Assasin", prefabPath = "Assets/Game/Art/Actor/Prefab/Assasin.prefab" }, new CharacterConfigItem { id = "bls", prefabPath = "Assets/Game/Art/Actor/Prefab/bls.prefab" }, new CharacterConfigItem { id = "boy", prefabPath = "Assets/Game/Art/Actor/Prefab/boy.prefab" }, new CharacterConfigItem { id = "captain", prefabPath = "Assets/Game/Art/Actor/Prefab/captain.prefab" }, new CharacterConfigItem { id = "cityboy_sk", prefabPath = "Assets/Game/Art/Actor/Prefab/cityboy_sk.prefab" }, } }; } [Test] public void GetFigureReturnsGeneratedListIndexForCityboy() { Assert.That(CreateConfig().GetFigure("cityboy_sk"), Is.EqualTo(5)); } [Test] public void GetFigureUsesDefaultForAnUnknownId() { Assert.That(CreateConfig().GetFigure("missing"), Is.EqualTo(1)); } [Test] public void GetByFigureReturnsTheConfiguredPrefabPath() { CharacterConfigItem item = CreateConfig().GetByFigure(5); Assert.That(item.id, Is.EqualTo("cityboy_sk")); Assert.That(item.prefabPath, Is.EqualTo("Assets/Game/Art/Actor/Prefab/cityboy_sk.prefab")); } [Test] public void GetByFigureUsesDefaultForAnInvalidIndex() { Assert.That(CreateConfig().GetByFigure(99).id, Is.EqualTo("Assasin")); } [Test] public void EmptyConfigHasNoUsableFigure() { CharacterConfigData config = new CharacterConfigData(); Assert.That(config.GetFigure("cityboy_sk"), Is.EqualTo(0)); Assert.That(config.GetByFigure(1), Is.Null); } } ``` - [ ] **Step 3: Run the test to verify RED** Run from the Unity editor Test Runner: `Window > General > Test Runner > EditMode > Run All`. Expected: compilation fails because `CharacterConfigData` does not yet define `GetFigure` or `GetByFigure`. - [ ] **Step 4: Implement the minimal configuration helpers** In `Client/Assets/Script/xmain/Character/CharacterConfigData.cs`, make `GetDefault` and `Find` null-safe and add these members to `CharacterConfigData`: ```csharp public CharacterConfigItem GetDefault() { CharacterConfigItem item = Find(defaultCharacterId); return item ?? (characters != null && characters.Count > 0 ? characters[0] : null); } public CharacterConfigItem Find(string id) { if (string.IsNullOrEmpty(id) || characters == null) { return null; } for (int i = 0; i < characters.Count; i++) { CharacterConfigItem item = characters[i]; if (item != null && string.Equals(item.id, id, StringComparison.OrdinalIgnoreCase)) { return item; } } return null; } public int GetFigure(string id) { return GetFigure(Find(id) ?? GetDefault()); } public int GetFigure(CharacterConfigItem item) { if (item == null || characters == null) { return 0; } for (int i = 0; i < characters.Count; i++) { if (ReferenceEquals(characters[i], item)) { return i + 1; } } return 0; } public CharacterConfigItem GetByFigure(int figure) { int index = figure - 1; if (characters != null && index >= 0 && index < characters.Count && characters[index] != null) { return characters[index]; } return GetDefault(); } ``` - [ ] **Step 5: Run the EditMode tests to verify GREEN** Run from the Unity editor Test Runner: `Window > General > Test Runner > EditMode > Run All`. Expected: all five `CharacterConfigDataTests` pass. - [ ] **Step 6: Commit the independently tested resolver** ```powershell git add -- Client/Assets/Script/xmain/Character/CharacterConfigData.cs Client/Assets/Script/Tests/EditMode/XWorld.Link.EditModeTests.asmdef Client/Assets/Script/Tests/EditMode/CharacterConfigDataTests.cs git commit -m "feat: resolve character figures from config" ``` ### Task 2: Use the catalog to load lobby avatars **Files:** - Modify: `Client/Assets/Script/xmain/Client/LobbyWorldController.cs` **Interfaces:** - Consumes: `CharacterConfigData.GetFigure(string)` and `CharacterConfigData.GetByFigure(int)` from Task 1, plus `Resources/Config/CharacterConfig.json`. - Produces: lobby avatar loading that resolves all selected and remote figures through configured `prefabPath` values. - [ ] **Step 1: Replace the lobby’s hard-coded paths with resource-config resolution** In `LobbyWorldController.cs`: 1. Delete `ActorPaths` and add: ```csharp private const string CharacterConfigResourcePath = "Config/CharacterConfig"; private CharacterConfigData characterConfig; ``` 2. Add these instance methods: ```csharp private bool EnsureCharacterConfig() { if (characterConfig != null) { return characterConfig.GetDefault() != null; } TextAsset asset = Resources.Load(CharacterConfigResourcePath); if (asset == null) { Debug.LogError("[LobbyWorldController] missing character config: Resources/" + CharacterConfigResourcePath + ".json"); return false; } characterConfig = JsonUtility.FromJson(asset.text); if (characterConfig == null || characterConfig.GetDefault() == null) { Debug.LogError("[LobbyWorldController] character config is empty or invalid."); characterConfig = null; return false; } return true; } private int ResolveFigure(int figure) { if (!EnsureCharacterConfig()) { return 0; } return characterConfig.GetFigure(characterConfig.GetByFigure(figure)); } private int GetSelectedFigure() { if (!EnsureCharacterConfig()) { return 0; } return characterConfig.GetFigure(PlayerPrefs.GetString(SelectedCharacterKey, string.Empty)); } private string GetPrefabPath(int figure) { if (!EnsureCharacterConfig()) { return null; } CharacterConfigItem item = characterConfig.GetByFigure(figure); return item == null ? null : item.prefabPath; } ``` 3. In `Initialize`, call `EnsureCharacterConfig()` before creating an avatar; if it returns `false`, return without joining the lobby. Replace the existing `Mathf.Clamp(..., ActorPaths.Length)` assignment with `selfFigure = figure <= 0 ? GetSelectedFigure() : ResolveFigure(figure);` and return with an error if the result is `0`. 4. In `RefreshSelectedFigure`, replace the clamped figure assignment with `int nextFigure = GetSelectedFigure();` and return after logging if it is `0`. 5. In `GetOrCreateAvatar`, replace its clamped assignment with `figure = ResolveFigure(figure);`; if that returns `0`, log `[LobbyWorldController] cannot resolve figure because the character catalog is unavailable.` and return `null`. Update each caller to return early when the result is `null` before dereferencing the avatar. 6. In `CoLoadActorPrefab`, resolve `string prefabPath = GetPrefabPath(figure);`; log and exit if it is null or empty; otherwise call `XResLoader.coLoadRes(prefabPath, typeof(GameObject), ...)`. 7. Delete the static `GetSelectedFigure` implementation at the end of the class. - [ ] **Step 2: Run EditMode tests and compile the client** Run `Window > General > Test Runner > EditMode > Run All` in Unity, then allow Unity to complete script compilation without Console errors. Expected: all `CharacterConfigDataTests` pass and no compiler errors reference `ActorPaths` or a static call to `GetSelectedFigure`. - [ ] **Step 3: Perform the play-mode regression check** In the Unity editor: 1. Run `Tools > Character > Generate Character Config`. 2. Enter Play mode and open the character selector. 3. Select `cityboy_sk`. 4. Join or return to the lobby. Expected: the selected lobby avatar is `cityboy_sk`, not `Assasin`; Console contains no missing-config or failed-prefab error. Repeat once with `Assasin` to confirm existing selection still loads. - [ ] **Step 4: Commit the lobby integration** ```powershell git add -- Client/Assets/Script/xmain/Client/LobbyWorldController.cs Client/Assets/Script/Tests/EditMode/CharacterConfigDataTests.cs git commit -m "fix: load lobby characters from config" ``` ## Self-review - Spec coverage: Task 1 supplies deterministic ID/figure and figure/item mapping; Task 2 removes the duplicate lobby list, loads configured prefab paths, preserves the integer protocol, and verifies `cityboy_sk` in play mode. - Placeholder scan: no incomplete markers or unspecified error behavior remain. - Type consistency: Task 1 defines all three `CharacterConfigData` methods used by Task 2; `ResolveFigure` and `GetSelectedFigure` return an `int`, and `GetPrefabPath` returns a `string`.