# MiniGame Room Player Service 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:** Add a shared room-player service so hot-update minigames can read the room player list and query cached player display data such as name, level, appearance type, and avatar id through `ctx.Players`. **Architecture:** Extend the shared framework contract (`XWorld.Framework`) with richer `PlayerInfo`, `IRoomPlayerService`, and a pure C# `RoomPlayerService` cache implementation. Populate the service from `MatchFoundMsg.Players` inside `MiniGameHost`, inject it through `GameClientCtx`, and update RPS to consume `ctx.Players` for player names. **Tech Stack:** C# 9, Unity 2022.3, netstandard2.1 shared framework, xUnit tests in `Server/Framework.Shared.Tests`, Unity runtime code under `Client/Assets/Script/xmain/MiniGame`. --- ## File Map - Modify: `Client/Assets/Framework/Shared/GameTypes.cs` - Add `Level`, `AppearanceType`, and `AvatarId` fields to `PlayerInfo`. - Modify: `Client/Assets/Framework/Shared/IGameClient.cs` - Add `IRoomPlayerService Players { get; }` to `IGameClientCtx`. - Create: `Client/Assets/Framework/Shared/RoomPlayerService.cs` - Define `IRoomPlayerService` and pure C# `RoomPlayerService` implementation. - Modify: `Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs` - Encode/decode the new `PlayerInfo` display fields in `MatchFoundMsg`; decode remains compatible with old payloads by checking `PacketReader.HasMore`. - Create: `Server/Framework.Shared.Tests/RoomPlayerServiceTests.cs` - Verify room player cache snapshots, lookup, async-style callback, and defensive copies. - Modify: `Server/Framework.Shared.Tests/FrameworkMessagesTests.cs` - Verify `MatchFoundMsg` round-trips new display fields and can decode legacy player payloads. - Modify: `Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs` - Store and expose `IRoomPlayerService Players`. - Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs` - Create `RoomPlayerService`, populate it from `MatchFound`, add `SetMatchInfo` overload with players, and inject it into `GameClientCtx`. - Modify: `Client/Assets/MiniGames/RockPaperScissors/scripts~/Client/RpsGameClient.cs` - Read player names from `ctx.Players` and pass them to the view. - Modify: `Client/Assets/Doc/MiniGameDevelopmentDesign.md` - Update the room-player service section if implementation details differ from the draft. --- ## Task 1: Shared Player Contract And Protocol Tests **Files:** - Modify: `Client/Assets/Framework/Shared/GameTypes.cs` - Modify: `Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs` - Modify: `Server/Framework.Shared.Tests/FrameworkMessagesTests.cs` - [ ] **Step 1: Extend the existing MatchFound round-trip test first** In `Server/Framework.Shared.Tests/FrameworkMessagesTests.cs`, replace `MatchFound_RoundTrips_WithPlayers` with: ```csharp [Fact] public void MatchFound_RoundTrips_WithPlayers() { var m = new MatchFoundMsg { RoomId = "room-42", Version = 3, SelfPlayerId = 1001, Players = { new PlayerInfo { PlayerId = 1001, Name = "Alice", IsAI = false, Level = 12, AppearanceType = 3, AvatarId = 77, }, new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true, Level = 1, AppearanceType = 0, AvatarId = 0, }, }, }; var d = MatchFoundMsg.Decode(m.Encode()); Assert.Equal("room-42", d.RoomId); Assert.Equal(3, d.Version); Assert.Equal(1001, d.SelfPlayerId); Assert.Equal(2, d.Players.Count); Assert.Equal(1001, d.Players[0].PlayerId); Assert.Equal("Alice", d.Players[0].Name); Assert.False(d.Players[0].IsAI); Assert.Equal(12, d.Players[0].Level); Assert.Equal(3, d.Players[0].AppearanceType); Assert.Equal(77, d.Players[0].AvatarId); Assert.Equal(-1, d.Players[1].PlayerId); Assert.Equal("AI", d.Players[1].Name); Assert.True(d.Players[1].IsAI); Assert.Equal(1, d.Players[1].Level); } ``` - [ ] **Step 2: Add legacy payload decode test** Append this test to `FrameworkMessagesTests`: ```csharp [Fact] public void MatchFound_DecodesLegacyPlayersWithoutDisplayFields() { var w = new PacketWriter(); w.WriteString("legacy-room"); w.WriteVarInt(1); w.WriteVarInt(1001); w.WriteVarUInt(1); w.WriteVarInt(1001); w.WriteString("LegacyAlice"); w.WriteBool(false); MatchFoundMsg d = MatchFoundMsg.Decode(w.ToArray()); Assert.Equal("legacy-room", d.RoomId); Assert.Single(d.Players); Assert.Equal(1001, d.Players[0].PlayerId); Assert.Equal("LegacyAlice", d.Players[0].Name); Assert.False(d.Players[0].IsAI); Assert.Equal(0, d.Players[0].Level); Assert.Equal(0, d.Players[0].AppearanceType); Assert.Equal(0, d.Players[0].AvatarId); } ``` - [ ] **Step 3: Run tests and verify they fail** Run: ```bash dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --filter "FullyQualifiedName~FrameworkMessagesTests.MatchFound" --no-restore ``` Expected: FAIL because `PlayerInfo` does not yet define `Level`, `AppearanceType`, or `AvatarId`. - [ ] **Step 4: Extend `PlayerInfo`** In `Client/Assets/Framework/Shared/GameTypes.cs`, replace the current `PlayerInfo` class with: ```csharp public sealed class PlayerInfo { public int PlayerId; public string Name; public bool IsAI; public int Level; public int AppearanceType; public int AvatarId; } ``` - [ ] **Step 5: Extend MatchFound encode/decode** In `Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs`, update `MatchFoundMsg.Encode()` player loop to: ```csharp foreach (var p in Players) { w.WriteVarInt(p.PlayerId); w.WriteString(p.Name); w.WriteBool(p.IsAI); w.WriteVarInt(p.Level); w.WriteVarInt(p.AppearanceType); w.WriteVarInt(p.AvatarId); } ``` Update `MatchFoundMsg.Decode()` player loop to: ```csharp for (uint i = 0; i < n; i++) { var player = new PlayerInfo { PlayerId = r.ReadVarInt(), Name = r.ReadString(), IsAI = r.ReadBool(), }; if (r.HasMore) { player.Level = r.ReadVarInt(); } if (r.HasMore) { player.AppearanceType = r.ReadVarInt(); } if (r.HasMore) { player.AvatarId = r.ReadVarInt(); } m.Players.Add(player); } ``` - [ ] **Step 6: Run focused tests and verify they pass** Run: ```bash dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --filter "FullyQualifiedName~FrameworkMessagesTests.MatchFound" --no-restore ``` Expected: PASS for `MatchFound_RoundTrips_WithPlayers`, `MatchFound_EmptyPlayerList_RoundTrips`, and `MatchFound_DecodesLegacyPlayersWithoutDisplayFields`. --- ## Task 2: Shared RoomPlayerService **Files:** - Modify: `Client/Assets/Framework/Shared/IGameClient.cs` - Create: `Client/Assets/Framework/Shared/RoomPlayerService.cs` - Create: `Server/Framework.Shared.Tests/RoomPlayerServiceTests.cs` - [ ] **Step 1: Write RoomPlayerService tests first** Create `Server/Framework.Shared.Tests/RoomPlayerServiceTests.cs` with: ```csharp using System.Collections.Generic; using Xunit; using XWorld.Framework; namespace XWorld.Framework.Tests { public class RoomPlayerServiceTests { [Fact] public void SetRoomPlayers_StoresSnapshotAndSupportsLookup() { var service = new RoomPlayerService(); var source = new List { new PlayerInfo { PlayerId = 10, Name = "Alice", IsAI = false, Level = 7, AppearanceType = 2, AvatarId = 100, }, new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true, Level = 1, AppearanceType = 0, AvatarId = 0, }, }; service.SetRoomPlayers(source); source[0].Name = "Mutated"; IReadOnlyList players = service.GetRoomPlayers(); Assert.Equal(2, players.Count); Assert.Equal("Alice", players[0].Name); Assert.True(service.TryGetRoomPlayer(10, out PlayerInfo alice)); Assert.Equal(7, alice.Level); Assert.Equal(2, alice.AppearanceType); Assert.Equal(100, alice.AvatarId); Assert.True(service.TryGetRoomPlayer(-1, out PlayerInfo ai)); Assert.True(ai.IsAI); } [Fact] public void ReturnedPlayers_AreDefensiveCopies() { var service = new RoomPlayerService(); service.SetRoomPlayers(new[] { new PlayerInfo { PlayerId = 10, Name = "Alice", IsAI = false }, }); IReadOnlyList first = service.GetRoomPlayers(); first[0].Name = "Changed"; IReadOnlyList second = service.GetRoomPlayers(); Assert.Equal("Alice", second[0].Name); Assert.True(service.TryGetRoomPlayer(10, out PlayerInfo lookup)); lookup.Name = "ChangedAgain"; Assert.True(service.TryGetRoomPlayer(10, out PlayerInfo lookupAgain)); Assert.Equal("Alice", lookupAgain.Name); } [Fact] public void RequestPlayerInfo_ReturnsCachedPlayerOrNull() { var service = new RoomPlayerService(); service.SetRoomPlayers(new[] { new PlayerInfo { PlayerId = 10, Name = "Alice", IsAI = false, Level = 5 }, }); PlayerInfo loaded = null; service.RequestPlayerInfo(10, p => loaded = p); Assert.NotNull(loaded); Assert.Equal("Alice", loaded.Name); Assert.Equal(5, loaded.Level); PlayerInfo missing = new PlayerInfo { PlayerId = 99, Name = "should be replaced" }; service.RequestPlayerInfo(99, p => missing = p); Assert.Null(missing); } [Fact] public void SetRoomPlayers_NullClearsTheRoom() { var service = new RoomPlayerService(); service.SetRoomPlayers(new[] { new PlayerInfo { PlayerId = 10, Name = "Alice", IsAI = false }, }); service.SetRoomPlayers(null); Assert.Empty(service.GetRoomPlayers()); Assert.False(service.TryGetRoomPlayer(10, out _)); } } } ``` - [ ] **Step 2: Run tests and verify they fail** Run: ```bash dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --filter FullyQualifiedName~RoomPlayerServiceTests --no-restore ``` Expected: FAIL because `RoomPlayerService` does not exist. - [ ] **Step 3: Add `Players` to `IGameClientCtx`** In `Client/Assets/Framework/Shared/IGameClient.cs`, replace `IGameClientCtx` with: ```csharp public interface IGameClientCtx { IAssetLoader Assets { get; } ILogger Logger { get; } IRoomPlayerService Players { get; } void Send(NetMessage message); // 发往服务端(Game 通道) void Exit(); // 请求退出当前小游戏 } ``` - [ ] **Step 4: Create shared room player service** Create `Client/Assets/Framework/Shared/RoomPlayerService.cs`: ```csharp using System; using System.Collections.Generic; namespace XWorld.Framework { public interface IRoomPlayerService { IReadOnlyList GetRoomPlayers(); bool TryGetRoomPlayer(int playerId, out PlayerInfo player); void RequestPlayerInfo(int playerId, Action onLoaded); } public sealed class RoomPlayerService : IRoomPlayerService { private readonly List _players = new List(); private readonly Dictionary _byId = new Dictionary(); public void SetRoomPlayers(IEnumerable players) { _players.Clear(); _byId.Clear(); if (players == null) { return; } foreach (PlayerInfo player in players) { if (player == null) { continue; } PlayerInfo copy = Clone(player); _players.Add(copy); _byId[copy.PlayerId] = copy; } } public IReadOnlyList GetRoomPlayers() { var copy = new List(_players.Count); for (int i = 0; i < _players.Count; i++) { copy.Add(Clone(_players[i])); } return copy; } public bool TryGetRoomPlayer(int playerId, out PlayerInfo player) { if (_byId.TryGetValue(playerId, out PlayerInfo cached)) { player = Clone(cached); return true; } player = null; return false; } public void RequestPlayerInfo(int playerId, Action onLoaded) { TryGetRoomPlayer(playerId, out PlayerInfo player); onLoaded?.Invoke(player); } private static PlayerInfo Clone(PlayerInfo source) { return new PlayerInfo { PlayerId = source.PlayerId, Name = source.Name, IsAI = source.IsAI, Level = source.Level, AppearanceType = source.AppearanceType, AvatarId = source.AvatarId, }; } } } ``` - [ ] **Step 5: Run service tests and verify they pass** Run: ```bash dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --filter FullyQualifiedName~RoomPlayerServiceTests --no-restore ``` Expected: PASS for all `RoomPlayerServiceTests`. - [ ] **Step 6: Run all shared framework tests** Run: ```bash dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --no-restore ``` Expected: PASS for all shared framework tests. --- ## Task 3: Inject RoomPlayerService Into MiniGameHost **Files:** - Modify: `Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs` - Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs` - [ ] **Step 1: Update `GameClientCtx` constructor and property** In `Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs`, replace the class body with: ```csharp public sealed class GameClientCtx : IGameClientCtx { public IAssetLoader Assets { get; } public IFwkLogger Logger { get; } public IRoomPlayerService Players { get; } private readonly Action _send; private readonly Action _exit; public GameClientCtx(IAssetLoader assets, IFwkLogger logger, IRoomPlayerService players, Action send, Action exit) { Assets = assets; Logger = logger; Players = players; _send = send; _exit = exit; } public void Send(NetMessage message) => _send(message); public void Exit() => _exit(); } ``` - [ ] **Step 2: Add player cache field to `MiniGameHost`** In `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`, add this field near `_assets`: ```csharp private RoomPlayerService _players; ``` - [ ] **Step 3: Add `SetMatchInfo` overload with players** Replace the existing `SetMatchInfo(string roomId, int selfPlayerId)` method with: ```csharp public void SetMatchInfo(string roomId, int selfPlayerId) { SetMatchInfo(roomId, selfPlayerId, null); } public void SetMatchInfo(string roomId, int selfPlayerId, IReadOnlyList players) { _roomId = roomId; _selfPlayerId = selfPlayerId; if (_players == null) { _players = new RoomPlayerService(); } if (players != null) { _players.SetRoomPlayers(players); } } ``` - [ ] **Step 4: Instantiate and inject `RoomPlayerService` during enter** In `CoEnter`, before creating `GameClientCtx`, ensure the service exists: ```csharp if (_players == null) { _players = new RoomPlayerService(); } ``` Then replace the `GameClientCtx` construction with: ```csharp _ctx = new GameClientCtx( _assets, new UnityLogger($"[{gameId}]"), _players, msg => { if (_net != null) _net.SendGame(msg); }, () => ExitGame()); ``` - [ ] **Step 5: Populate players from `MatchFound`** In `MiniGameHost.OnFrameworkFrame`, update the `FrameworkOpcode.MatchFound` case to: ```csharp case FrameworkOpcode.MatchFound: MatchFoundMsg found = MatchFoundMsg.Decode(f.Payload); _roomId = found.RoomId; _selfPlayerId = found.SelfPlayerId; if (_players == null) { _players = new RoomPlayerService(); } _players.SetRoomPlayers(found.Players); Debug.Log("[MiniGameHost] 房间就绪: " + _roomId); break; ``` - [ ] **Step 6: Clear room player cache on exit** In `ExitGame`, after `_ctx = null;`, add: ```csharp if (_players != null) { _players.SetRoomPlayers(null); } ``` - [ ] **Step 7: Build runtime verify project** Run: ```bash dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj -c Release -nologo ``` Expected: build succeeds with exit code 0. If Unity generated `Library/ScriptAssemblies/XWorld.Framework.Shared.dll` is stale and does not contain `IRoomPlayerService`, open Unity once to regenerate script assemblies, then rerun the command. --- ## Task 4: Consume Room Players In RPS Client **Files:** - Modify: `Client/Assets/MiniGames/RockPaperScissors/scripts~/Client/RpsGameClient.cs` - [ ] **Step 1: Capture room player names in `RpsGameClient`** In `RpsGameClient`, add fields near `_status`: ```csharp private string _leftPlayerName = "You"; private string _rightPlayerName = "Opponent"; ``` In `OnEnter`, after `_ctx = ctx;`, add: ```csharp ApplyRoomPlayers(); ``` Add this method to `RpsGameClient`: ```csharp private void ApplyRoomPlayers() { if (_ctx?.Players == null) { return; } var players = _ctx.Players.GetRoomPlayers(); if (players.Count > 0) { _leftPlayerName = DisplayName(players[0], "You"); } if (players.Count > 1) { _rightPlayerName = DisplayName(players[1], players[1].IsAI ? "AI" : "Opponent"); } } private static string DisplayName(PlayerInfo player, string fallback) { if (player == null || string.IsNullOrEmpty(player.Name)) { return fallback; } if (player.Level > 0) { return player.Name + " Lv." + player.Level; } return player.Name; } ``` - [ ] **Step 2: Pass names into the view** In `OnPrefabLoaded`, replace: ```csharp _view = new RpsView(instance, SubmitChoice); ``` with: ```csharp _view = new RpsView(instance, SubmitChoice, _leftPlayerName, _rightPlayerName); ``` Replace the `RpsView` constructor signature: ```csharp public RpsView(GameObject root, System.Action choose) ``` with: ```csharp public RpsView(GameObject root, System.Action choose, string leftPlayerName, string rightPlayerName) ``` Inside the constructor, replace: ```csharp SetText(_leftNameText, "You"); SetText(_rightNameText, "Opponent"); ``` with: ```csharp SetText(_leftNameText, string.IsNullOrEmpty(leftPlayerName) ? "You" : leftPlayerName); SetText(_rightNameText, string.IsNullOrEmpty(rightPlayerName) ? "Opponent" : rightPlayerName); ``` - [ ] **Step 3: Preserve AI fallback during render** In `RpsView.Render`, replace: ```csharp SetText(_rightNameText, state.IsAi[1] ? "AI" : "Opponent"); ``` with: ```csharp if (state.IsAi[1] && _rightNameText != null && string.IsNullOrEmpty(_rightNameText.text)) { SetText(_rightNameText, "AI"); } ``` This prevents each snapshot from overwriting the name supplied by the room player service. - [ ] **Step 4: Build RPS client** Run: ```bash dotnet build Client/HotUpdateGames/RPS.Client/RPS.Client.csproj -c Release -nologo ``` Expected: build succeeds with exit code 0. --- ## Task 5: Documentation And Final Verification **Files:** - Modify: `Client/Assets/Doc/MiniGameDevelopmentDesign.md` - [ ] **Step 1: Align the design doc with final code names** Ensure section `5.3 房间玩家与用户数据服务` states the final contract exactly: ```csharp public interface IRoomPlayerService { IReadOnlyList GetRoomPlayers(); bool TryGetRoomPlayer(int playerId, out PlayerInfo player); void RequestPlayerInfo(int playerId, Action onLoaded); } ``` Ensure the documented `PlayerInfo` fields are: ```csharp public int PlayerId; public string Name; public bool IsAI; public int Level; public int AppearanceType; public int AvatarId; ``` - [ ] **Step 2: Run all relevant verification commands** Run: ```bash dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --no-restore ``` Expected: PASS. Run: ```bash dotnet build Client/HotUpdateGames/RPS.Client/RPS.Client.csproj -c Release -nologo ``` Expected: build succeeds with exit code 0. Run: ```bash dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj -c Release -nologo ``` Expected: build succeeds with exit code 0, or reports stale Unity script assemblies; if stale, open Unity to regenerate, then rerun and require exit code 0 before completion. - [ ] **Step 3: Inspect changed files** Run: ```bash git diff -- Client/Assets/Framework/Shared/GameTypes.cs Client/Assets/Framework/Shared/IGameClient.cs Client/Assets/Framework/Shared/RoomPlayerService.cs Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs Client/Assets/MiniGames/RockPaperScissors/scripts~/Client/RpsGameClient.cs Client/Assets/Doc/MiniGameDevelopmentDesign.md Server/Framework.Shared.Tests/FrameworkMessagesTests.cs Server/Framework.Shared.Tests/RoomPlayerServiceTests.cs ``` Expected: diff only contains the room-player service feature and design-doc alignment. - [ ] **Step 4: Optional commit only if the user explicitly asks** Do not commit by default. If the user asks for a commit, run: ```bash git add Client/Assets/Framework/Shared/GameTypes.cs Client/Assets/Framework/Shared/IGameClient.cs Client/Assets/Framework/Shared/RoomPlayerService.cs Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs Client/Assets/MiniGames/RockPaperScissors/scripts~/Client/RpsGameClient.cs Client/Assets/Doc/MiniGameDevelopmentDesign.md Server/Framework.Shared.Tests/FrameworkMessagesTests.cs Server/Framework.Shared.Tests/RoomPlayerServiceTests.cs docs/superpowers/plans/2026-07-29-minigame-room-player-service.md git commit -m "feat: add minigame room player service" ``` Commit message body must end with: ```text Co-Authored-By: Claude Opus 4.8 ``` --- ## Self-Review - Spec coverage: The plan implements the requested common room player list and unified player data lookup through `IRoomPlayerService` and `ctx.Players`; it includes name, level, appearance type, avatar id, AI handling, host injection, protocol transport, and an example consumer. - Placeholder scan: No `TBD`, `TODO`, `FIXME`, or unspecified implementation steps remain. - Type consistency: `PlayerInfo`, `IRoomPlayerService`, `RoomPlayerService`, `IGameClientCtx.Players`, and `GameClientCtx` constructor signatures match across tests, shared framework, host runtime, and RPS client usage.