24 KiB
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, andAvatarIdfields toPlayerInfo.
- Add
- Modify:
Client/Assets/Framework/Shared/IGameClient.cs- Add
IRoomPlayerService Players { get; }toIGameClientCtx.
- Add
- Create:
Client/Assets/Framework/Shared/RoomPlayerService.cs- Define
IRoomPlayerServiceand pure C#RoomPlayerServiceimplementation.
- Define
- Modify:
Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs- Encode/decode the new
PlayerInfodisplay fields inMatchFoundMsg; decode remains compatible with old payloads by checkingPacketReader.HasMore.
- Encode/decode the new
- 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
MatchFoundMsground-trips new display fields and can decode legacy player payloads.
- Verify
- Modify:
Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs- Store and expose
IRoomPlayerService Players.
- Store and expose
- Modify:
Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs- Create
RoomPlayerService, populate it fromMatchFound, addSetMatchInfooverload with players, and inject it intoGameClientCtx.
- Create
- Modify:
Client/Assets/MiniGames/RockPaperScissors/scripts~/Client/RpsGameClient.cs- Read player names from
ctx.Playersand pass them to the view.
- Read player names from
- 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:
[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:
[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:
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:
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:
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:
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:
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:
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:
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
PlayerstoIGameClientCtx
In Client/Assets/Framework/Shared/IGameClient.cs, replace IGameClientCtx with:
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:
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:
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:
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
GameClientCtxconstructor and property
In Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs, replace the class body with:
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:
private RoomPlayerService _players;
- Step 3: Add
SetMatchInfooverload with players
Replace the existing SetMatchInfo(string roomId, int selfPlayerId) method with:
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
RoomPlayerServiceduring enter
In CoEnter, before creating GameClientCtx, ensure the service exists:
if (_players == null)
{
_players = new RoomPlayerService();
}
Then replace the GameClientCtx construction with:
_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:
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:
if (_players != null)
{
_players.SetRoomPlayers(null);
}
- Step 7: Build runtime verify project
Run:
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:
private string _leftPlayerName = "You";
private string _rightPlayerName = "Opponent";
In OnEnter, after _ctx = ctx;, add:
ApplyRoomPlayers();
Add this method to RpsGameClient:
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:
_view = new RpsView(instance, SubmitChoice);
with:
_view = new RpsView(instance, SubmitChoice, _leftPlayerName, _rightPlayerName);
Replace the RpsView constructor signature:
public RpsView(GameObject root, System.Action<Choice> choose)
with:
public RpsView(GameObject root, System.Action<Choice> choose, string leftPlayerName, string rightPlayerName)
Inside the constructor, replace:
SetText(_leftNameText, "You");
SetText(_rightNameText, "Opponent");
with:
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:
SetText(_rightNameText, state.IsAi[1] ? "AI" : "Opponent");
with:
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:
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:
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:
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:
dotnet test Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj --no-restore
Expected: PASS.
Run:
dotnet build Client/HotUpdateGames/RPS.Client/RPS.Client.csproj -c Release -nologo
Expected: build succeeds with exit code 0.
Run:
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:
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:
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:
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
IRoomPlayerServiceandctx.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, andGameClientCtxconstructor signatures match across tests, shared framework, host runtime, and RPS client usage.