feat: add minigame room player service

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ud18010
2026-07-29 12:24:58 +08:00
co-authored by Claude Opus 4.8
parent 053a95b4fb
commit 31afce6882
13 changed files with 1878 additions and 10 deletions
@@ -0,0 +1,812 @@
# 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<PlayerInfo>
{
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<PlayerInfo> 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<PlayerInfo> first = service.GetRoomPlayers();
first[0].Name = "Changed";
IReadOnlyList<PlayerInfo> 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<PlayerInfo> GetRoomPlayers();
bool TryGetRoomPlayer(int playerId, out PlayerInfo player);
void RequestPlayerInfo(int playerId, Action<PlayerInfo> onLoaded);
}
public sealed class RoomPlayerService : IRoomPlayerService
{
private readonly List<PlayerInfo> _players = new List<PlayerInfo>();
private readonly Dictionary<int, PlayerInfo> _byId = new Dictionary<int, PlayerInfo>();
public void SetRoomPlayers(IEnumerable<PlayerInfo> 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<PlayerInfo> GetRoomPlayers()
{
var copy = new List<PlayerInfo>(_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<PlayerInfo> 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<NetMessage> _send;
private readonly Action _exit;
public GameClientCtx(IAssetLoader assets, IFwkLogger logger, IRoomPlayerService players, Action<NetMessage> 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<PlayerInfo> 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<Choice> choose)
```
with:
```csharp
public RpsView(GameObject root, System.Action<Choice> 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<PlayerInfo> GetRoomPlayers();
bool TryGetRoomPlayer(int playerId, out PlayerInfo player);
void RequestPlayerInfo(int playerId, Action<PlayerInfo> 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 <noreply@anthropic.com>
```
---
## 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.