Files
AIC-Project/docs/superpowers/plans/2026-07-29-minigame-room-player-service.md
T
2026-07-29 12:24:58 +08:00

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, 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:

[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 Players to IGameClientCtx

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 GameClientCtx constructor 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 SetMatchInfo overload 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 RoomPlayerService during 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 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.