49 KiB
删除 Hello + 完善 RPS 替代它 实施计划
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: 彻底删除测试夹具 Hello,让剪刀石头布(RPS)同时承担"框架测试夹具"和"端到端示例游戏(含 C# 客户端)"两个角色,且 dotnet test Server/Server.sln 保持全绿。
Architecture: RPS 服务端(RPS.Core+RPS.Server)已完整且已有 RPS 专属测试(逻辑/房间/ALC加载/匹配/端到端)。本计划把"借 Hello 验证框架通用能力"的测试迁到 RPS(部分直接删冗余),用 rps/1+rps/2(同 IL、版本号不同)替代 Hello 的多版本夹具,新增对标 HelloFlowGameClient 的 RPS C# 客户端薄层,删除弃用 Lua,并把 PC 烟雾测试改用 RPS。
Tech Stack: C# / .NET 10(测试 xUnit)、netstandard2.1(游戏 DLL/客户端薄层)、bash 构建脚本、Unity 2022.3 + IMGUI(客户端,本环境仅 csc 编译验证)。
重要约定(本仓库非 git): 跳过所有 git commit 步骤;每个 Task 末尾以 dotnet test / dotnet build 的实际输出作为检查点。本环境无 Unity:客户端与 PC 烟雾的 Unity 行为无法 Play 验证,只能编译验证 + 标注人工核对。
File Structure
修改:
Server/build-test-games.sh— 去掉 Hello 构建,RPS 同时产出rps/1与rps/2Server/Server.Host.Tests/GameModuleLoaderTests.cs— 去冗余,仅保留 rps 缺版本测试Server/Server.Host.Tests/AlcUnloadTests.cs— 改 rpsServer/Server.Host.Tests/GameModuleRegistryTests.cs— 改 rps 1/2Server/Server.Host.Tests/GameManifestTests.cs— Valid 常量改 rpsServer/Server.Host.Tests/RoomHostTests.cs— 重写为 rps 语义Server/Gateway.Tests/ServerLoopTests.cs— 删冗余 + 重连/在房重复匹配改 rpsServer/Gateway.Tests/GatewayEndToEndTests.cs— 删冗余 + 重连改 rpsServer/Gateway.Tests/MatchmakerTests.cs— playerCount=1 用例去 hello 标签Server/Server.Host/GameManifest.cs— 文档注释示例改 RPSTools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1— Hello → RPSClient/Assets/Script/xmain/MiniGame/PcMiniGameSmokeLauncher.cs— gameId/文案 hello → rpsClient/Assets/MiniGames/RockPaperScissors/README.md— 状态更新
新建:
Client/Assets/MiniGames/RockPaperScissors/scripts/Client/RpsGameClient.cs— C# 客户端薄层(唯一源码位)Client/HotUpdateGames/RPS.Client/RPS.Client.csproj— 链接上面源码做 csc 验证(对标 HelloFlow.Client)
删除:
Server/games-src/Hello/(整目录)Client/HotUpdateGames/HelloFlow.Client/(整目录)Server/Server.Host.Tests/TestGames/hello/(夹具,及 bin 内拷贝)Server/Server.Host.Tests/HotReloadEndToEndTests.csClient/Assets/MiniGames/RockPaperScissors/Client/main.lua、.../Server/main.lua、.../scripts/Client/main.lua、.../scripts/Server/main.lua(及.meta)
Task 1: 构建脚本去 Hello、RPS 产出 rps/1 + rps/2
Files:
-
Modify:
Server/build-test-games.sh(整文件替换) -
Step 1: 用以下内容整体替换
Server/build-test-games.sh
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
build_rps () {
local RPS_SRC="$ROOT/Server/games-src/RPS"
local RPS_BIN="$RPS_SRC/RPS.Server/bin/Release/netstandard2.1"
local TG="$ROOT/Server/Server.Host.Tests/TestGames/rps"
# 不使用 -o:-o 会绕过 Private=false,把 Framework.Shared.dll 也拷进来。
dotnet build "$RPS_SRC/RPS.Server/RPS.Server.csproj" -c Release --no-incremental >/dev/null
# 断言框架程序集未进入构建输出(Private=false 回归保险)
if [ -f "$RPS_BIN/XWorld.Framework.Shared.dll" ]; then
echo "ERROR: Framework.Shared.dll 出现在构建输出 $RPS_BIN —— Private=false 可能被破坏" >&2
exit 1
fi
for dll in RPS.Core.dll RPS.Server.dll; do
if [ ! -f "$RPS_BIN/$dll" ]; then
echo "ERROR: 构建后缺少 $dll" >&2
exit 1
fi
done
# rps/1 = 真实版本;rps/2 = 同一批 IL(仅 version 字段不同),供版本号/淘汰/refcount 测试
for ver in 1 2; do
local out="$TG/$ver"
rm -rf "$out"; mkdir -p "$out"
cp "$RPS_BIN/RPS.Core.dll" "$out/RPS.Core.dll"
cp "$RPS_BIN/RPS.Server.dll" "$out/RPS.Server.dll"
cat > "$out/game.json" <<EOF
{
"gameId": "rps",
"version": $ver,
"serverAssembly": "RPS.Server.dll",
"serverEntryType": "RPS.Server.RpsServerRoom",
"minFrameworkVersion": 1,
"playerCount": 2,
"tickRateHz": 10
}
EOF
echo "built rps v$ver -> $out"
done
}
build_rps
echo "TestGames ready."
- Step 2: 运行构建脚本
Run: bash Server/build-test-games.sh
Expected: 输出 built rps v1 ...、built rps v2 ...、TestGames ready.,无 ERROR。
- Step 3: 确认夹具产出且无框架 DLL 泄漏
Run: ls Server/Server.Host.Tests/TestGames/rps/1 Server/Server.Host.Tests/TestGames/rps/2
Expected: 两目录各含 RPS.Core.dll、RPS.Server.dll、game.json;无 XWorld.Framework.Shared.dll。
Task 2: 删除 Hello 源码、夹具与 C# 客户端
Files:
-
Delete:
Server/games-src/Hello/、Client/HotUpdateGames/HelloFlow.Client/、Server/Server.Host.Tests/TestGames/hello/ -
Step 1: 删除 Hello 源码与客户端目录
Run:
rm -rf "Server/games-src/Hello"
rm -rf "Client/HotUpdateGames/HelloFlow.Client"
rm -rf "Server/Server.Host.Tests/TestGames/hello"
- Step 2: 清理测试输出目录里残留的 hello 夹具拷贝(PreserveNewest 不会自动删除已移除文件)
Run:
find Server -type d -path "*/bin/*/TestGames/hello" -prune -exec rm -rf {} +
Expected: 无输出即成功(无残留或已删)。
- Step 3: 确认 Hello 痕迹已无源码引用(仅测试 .cs 里还有字符串 "hello",下面任务逐个处理)
Run: grep -rln "Hello\." Server/games-src Client/HotUpdateGames 2>/dev/null || echo "no hello sources"
Expected: no hello sources。
Task 3: 删 HotReload 测试 + GameModuleLoaderTests 去冗余
RpsModuleLoadTests 已覆盖 "ALC 加载 + 跨界类型隔离 + 广播 + 推进到 Finished",故 GameModuleLoaderTests 的加载用例为冗余;HotReloadEndToEndTests 依赖两份不同 IL,按决策删除。
Files:
-
Delete:
Server/Server.Host.Tests/HotReloadEndToEndTests.cs -
Modify:
Server/Server.Host.Tests/GameModuleLoaderTests.cs(整文件替换) -
Modify:
Server/Server.Host.Tests/RpsModuleLoadTests.cs:10(去掉注释里残留的 Hello 字样) -
Step 1: 删除 HotReload 测试文件
Run: rm "Server/Server.Host.Tests/HotReloadEndToEndTests.cs"
- Step 1b: 清理
RpsModuleLoadTests.cs第 10 行注释里的 Hello 字样
把:
/// Task 4: Proves "framework zero-change loads a new game via ALC" — same paradigm as Hello.
改为:
/// Proves "framework zero-change loads a new game via ALC": loads RPS through the existing ALC contract.
- Step 2: 用以下内容整体替换
GameModuleLoaderTests.cs
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class GameModuleLoaderTests
{
// ALC 加载 + 跨界类型隔离 + 广播已由 RpsModuleLoadTests 覆盖;此处只保留缺版本的失败路径。
[Fact]
public void Load_MissingVersion_Throws()
{
Assert.ThrowsAny<System.Exception>(
() => new GameModuleLoader().Load(TestGames.Root, "rps", 99));
}
}
}
- Step 3: 编译并运行该文件的测试
Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~GameModuleLoaderTests"
Expected: PASS(1 个测试通过),无编译错误。
Task 4: AlcUnloadTests 改用 rps
Files:
-
Modify:
Server/Server.Host.Tests/AlcUnloadTests.cs(整文件替换) -
Step 1: 用以下内容整体替换
AlcUnloadTests.cs
using System;
using System.Runtime.CompilerServices;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class AlcUnloadTests
{
private sealed class NullOutput : IRoomOutput
{
public void Broadcast(NetMessage m) { }
public void SendTo(int playerId, NetMessage m) { }
}
// 在独立方法里加载/运行/卸载,确保局部强引用随方法返回而消失
[MethodImpl(MethodImplOptions.NoInlining)]
private static (WeakReference alc, WeakReference asm) LoadRunAndUnload()
{
var module = new GameModuleLoader().Load(TestGames.Root, "rps", 1);
IGameServerRoom room = module.CreateRoom();
room.OnRoomStart(
new[]
{
new PlayerInfo { PlayerId = 1, Name = "H", IsAI = false },
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
},
new RoomCtx(new DeterministicRandom(1), new ManualClock(), new ConsoleLogger(),
new InMemoryStorage(), new NullOutput(), () => { }));
room.OnTick(1f); // dt=1 < 5s,仍在 Choosing,仅广播一次快照
var asmWeak = new WeakReference(room.GetType().Assembly); // 游戏 ALC 内加载的程序集
room = null;
return (module.Unload(), asmWeak);
}
[Fact]
public void GameModule_Unload_ReclaimsMemory()
{
var (alcWeak, asmWeak) = LoadRunAndUnload();
for (int i = 0; i < 12 && (alcWeak.IsAlive || asmWeak.IsAlive); i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Assert.False(alcWeak.IsAlive, "游戏模块 ALC 未被回收 —— 存在泄漏引用");
Assert.False(asmWeak.IsAlive, "游戏程序集未被回收 —— 存在泄漏引用");
}
}
}
- Step 2: 运行该测试
Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~AlcUnloadTests"
Expected: PASS。
Task 5: GameModuleRegistryTests 改用 rps 1/2
rps/2 与 rps/1 同 IL、版本号不同,正好驱动 refcount/淘汰/卸载逻辑(这些只看版本号,不看 IL 差异)。
Files:
-
Modify:
Server/Server.Host.Tests/GameModuleRegistryTests.cs(整文件替换) -
Step 1: 用以下内容整体替换
GameModuleRegistryTests.cs
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class GameModuleRegistryTests
{
private static GameModuleRegistry NewRegistry()
=> new GameModuleRegistry(new GameModuleLoader(), TestGames.Root);
[Fact]
public void Acquire_SameVersionTwice_LoadsOnce_RefCounts()
{
var reg = NewRegistry();
var a = reg.Acquire("rps", 1);
var b = reg.Acquire("rps", 1);
Assert.Same(a, b); // 复用同一模块
Assert.Equal(2, reg.RefCount("rps", 1));
}
[Fact]
public void Release_ToZero_WithoutSupersede_KeepsLoaded()
{
var reg = NewRegistry();
var m = reg.Acquire("rps", 1);
reg.Release(m);
Assert.Equal(0, reg.RefCount("rps", 1));
Assert.True(reg.IsLoaded("rps", 1)); // 未被淘汰则保留以复用
}
[Fact]
public void NewVersion_Supersedes_Old_And_OldUnloadsWhenRefZero()
{
var reg = NewRegistry();
var v1 = reg.Acquire("rps", 1); // v1 引用=1
var v2 = reg.Acquire("rps", 2); // 取 v2 → v1 被标记淘汰
Assert.True(reg.IsLoaded("rps", 1));
Assert.True(reg.IsLoaded("rps", 2));
reg.Release(v1); // v1 引用归零且被淘汰 → 卸载并移除
Assert.False(reg.IsLoaded("rps", 1));
Assert.True(reg.IsLoaded("rps", 2)); // v2 仍在
}
[Fact]
public void Acquire_FailedNewVersionLoad_DoesNotSupersedeExisting()
{
var reg = NewRegistry();
var v1 = reg.Acquire("rps", 1);
Assert.ThrowsAny<System.Exception>(() => reg.Acquire("rps", 99)); // v99 不存在,加载抛出
reg.Release(v1); // v1 引用归零
Assert.True(reg.IsLoaded("rps", 1)); // 未被错误淘汰,仍保留
}
[Fact]
public void Release_MoreThanAcquired_Throws()
{
var reg = NewRegistry();
var m = reg.Acquire("rps", 1);
reg.Release(m);
Assert.Throws<System.InvalidOperationException>(() => reg.Release(m));
}
}
}
- Step 2: 运行该测试
Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~GameModuleRegistryTests"
Expected: PASS(5 个测试)。
Task 6: GameManifestTests 的 Valid 常量改 rps
Files:
-
Modify:
Server/Server.Host.Tests/GameManifestTests.cs:9-30 -
Step 1: 替换
Valid常量(原 9-17 行)为:
private const string Valid = @"{
""gameId"": ""rps"",
""version"": 2,
""serverAssembly"": ""RPS.Server.dll"",
""serverEntryType"": ""RPS.Server.RpsServerRoom"",
""minFrameworkVersion"": 1,
""playerCount"": 2,
""tickRateHz"": 10
}";
- Step 2: 替换
Parse_Valid_ReadsAllFields方法体(原 20-30 行)为:
public void Parse_Valid_ReadsAllFields()
{
var m = GameManifest.Parse(Valid);
Assert.Equal("rps", m.GameId);
Assert.Equal(2, m.Version);
Assert.Equal("RPS.Server.dll", m.ServerAssembly);
Assert.Equal("RPS.Server.RpsServerRoom", m.ServerEntryType);
Assert.Equal(1, m.MinFrameworkVersion);
Assert.Equal(2, m.PlayerCount);
Assert.Equal(10, m.TickRateHz);
}
(其余测试 Parse_CaseInsensitive_FieldNames、Parse_Invalid_Throws 与游戏无关,不改。)
- Step 3: 运行该测试
Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~GameManifestTests"
Expected: PASS。
Task 7: 重写 RoomHostTests 为 rps 语义
Hello 用"计数器、第 3 tick 结束、单人"建模;RPS 用"回合制、60s 结束、2 人(含 AI)、出招 opcode=1"。用 RpsLogic.Decode 读快照(本项目已引用 RPS.Core)。
Files:
-
Modify:
Server/Server.Host.Tests/RoomHostTests.cs(整文件替换) -
Step 1: 用以下内容整体替换
RoomHostTests.cs
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
using RPS.Core;
namespace XWorld.Server.Host.Tests
{
public class RoomHostTests
{
private sealed class Capture : IRoomOutput
{
public List<NetMessage> Broadcasts = new List<NetMessage>();
public void Broadcast(NetMessage m) => Broadcasts.Add(m);
public void SendTo(int playerId, NetMessage m) { }
}
// 1 真人(seat 0) + 1 AI(seat 1)
private static readonly IReadOnlyList<PlayerInfo> Players = new[]
{
new PlayerInfo { PlayerId = 1, Name = "H", IsAI = false },
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
};
private static RpsState LastState(Capture cap)
=> new RpsLogic().Decode(cap.Broadcasts[cap.Broadcasts.Count - 1].Payload);
private static RoomHost NewHost()
=> new RoomHost(new GameModuleRegistry(new GameModuleLoader(), TestGames.Root), new ConsoleLogger());
private static RoomConfig Cfg()
=> new RoomConfig { GameId = "rps", Version = 1, Seed = 1, PlayerCount = 2 };
[Fact]
public void CreateRoom_TickToEnd_ReleasesModule()
{
var host = NewHost();
var cap = new Capture();
var room = host.CreateRoom("A", "rps", 1, Cfg(), Players, cap);
Assert.Equal(1, host.ActiveRoomCount);
// 大 dt 推进:60s 对局在数个大 tick 内结束
for (int i = 0; i < 10 && host.ActiveRoomCount > 0; i++) host.TickAll(10f);
Assert.Equal(0, host.ActiveRoomCount);
Assert.False(room.IsActive);
Assert.Equal(RpsPhase.Finished, LastState(cap).Phase);
}
[Fact]
public void Deliver_RoutesChoice_AffectsState()
{
var host = NewHost();
var cap = new Capture();
host.CreateRoom("A", "rps", 1, Cfg(), Players, cap);
// seat 0(玩家 1)出 Rock:opcode=1(ChoiceOpcode),payload=单字节 choice
var w = new PacketWriter();
w.WriteByte((byte)Choice.Rock);
host.DeliverTo("A", 1, new NetMessage(1, w.ToArray()));
host.TickAll(0.1f); // 小 dt,仍在 Choosing,广播快照
Assert.Equal(Choice.Rock, LastState(cap).Choices[0]);
}
[Fact]
public void CreateRoom_DuplicateRoomId_Throws()
{
var host = NewHost();
host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture());
Assert.Throws<System.InvalidOperationException>(
() => host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture()));
}
[Fact]
public void TickAll_ReturnsEndedRoomIds()
{
var host = NewHost();
host.CreateRoom("E", "rps", 1, Cfg(), Players, new Capture());
var first = host.TickAll(10f); // 第一大 tick:TotalElapsed=10 < 60,不结束
Assert.Empty(first);
var ended = new List<string>();
for (int i = 0; i < 10 && !ended.Contains("E"); i++)
ended.AddRange(host.TickAll(10f));
Assert.Contains("E", ended); // 累计超过 60s 后该 tick 返回结束房间 id
}
}
}
- Step 2: 运行该测试
Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~RoomHostTests"
Expected: PASS(4 个测试)。
Task 8: ServerLoopTests 去冗余 + 重连/在房重复匹配改 rps
删除 hello 专属的 Join_RepliesMatchFound_... 与 GameInput_RoutedToRoom_...(已被 ServerLoopMatchmakingTests 覆盖);保留与游戏无关的心跳/过期/无房间帧用例;把重连、在房重复匹配改为 rps 2 人;MatchRequest_WithNoSession 的 gameId 改 rps。
Files:
-
Modify:
Server/Gateway.Tests/ServerLoopTests.cs(整文件替换) -
Step 1: 用以下内容整体替换
ServerLoopTests.cs
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class ServerLoopTests
{
private static ServerLoop NewLoop(long reconnectWindow = 100)
=> new ServerLoop(TestGames.Root, new ConsoleLogger("[loop]"), reconnectWindow);
private static byte[] JoinFrame(string gameId, int version)
=> FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = gameId, Version = version }.Encode()));
private static List<Frame> Frames(FakeConnection c)
{
var list = new List<Frame>();
foreach (var bytes in c.Sent) list.Add(FrameCodec.Decode(bytes));
return list;
}
[Fact]
public void Heartbeat_IsEchoed()
{
var loop = NewLoop();
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
var hb = new HeartbeatMsg { ClientTimeMs = 123456789L };
loop.Submit(new FrameCommand { PlayerId = 1,
Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat, hb.Encode()) });
loop.DrainAndTick(0f);
var echo = Frames(c).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat);
Assert.NotEqual(default, echo);
Assert.Equal(123456789L, HeartbeatMsg.Decode(echo.Payload).ClientTimeMs);
}
[Fact]
public void Reconnect_ReroutesRoomOutputToNewConnection()
{
var loop = NewLoop(reconnectWindow: 100);
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.DrainAndTick(1f); // 2 人成团:c1/c2 收到 MatchFound + 快照
Assert.True(c1.Sent.Count >= 2);
// pid=1 断线后重连到新连接 c1b
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 });
var c1b = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1b });
loop.DrainAndTick(1f); // 房间仍在跑(60s 未到):下一快照应发往 c1b
Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game);
}
[Fact]
public void ExpiredSession_IsSwept()
{
var loop = NewLoop(reconnectWindow: 2);
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
loop.DrainAndTick(1f); // _tick: 0→1
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c });
loop.DrainAndTick(1f); // 排空时 _tick=1 → DisconnectedAtTick=1;随后 _tick→2
loop.DrainAndTick(1f); // _tick→3:3-1=2,不 >2,保留
loop.DrainAndTick(1f); // _tick→4:4-1=3 >2 → 过期清除
Assert.False(loop.HasSession(1));
}
[Fact]
public void DuplicateMatchRequest_WhileInRoom_IsRejectedWithError()
{
var loop = NewLoop();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.DrainAndTick(1f); // 2 人入房
// 已在房间内再次 join → 回 Error
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.DrainAndTick(1f);
Assert.Contains(Frames(c1), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
}
[Fact]
public void GameFrame_WithNoRoom_IsIgnoredSafely()
{
var loop = NewLoop();
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
var w = new PacketWriter(); w.WriteVarInt(3);
loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Game, 0, w.ToArray()) });
var ex = Record.Exception(() => loop.DrainAndTick(1f));
Assert.Null(ex); // 无房间的游戏帧被安全忽略,不抛
Assert.Empty(c.Sent); // 无任何输出
}
[Fact]
public void MatchRequest_WithNoSession_IsIgnoredSafely()
{
var loop = NewLoop();
// 未发 ConnectCommand,直接来一个 join 帧
loop.Submit(new FrameCommand { PlayerId = 99, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
var ex = Record.Exception(() => loop.DrainAndTick(1f));
Assert.Null(ex);
Assert.False(loop.HasSession(99));
}
}
}
- Step 2: 运行该测试
Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~ServerLoopTests"
Expected: PASS(6 个测试)。注意 filter ~ServerLoopTests 也会匹配 ServerLoopMatchmakingTests/ServerLoopSignatureEnforcementTests,它们应一并通过。
Task 9: GatewayEndToEndTests 去冗余 + 重连改 rps(AI 兜底)
删除 hello e2e(已被 RpsEndToEndTests 覆盖);心跳用例与游戏无关保留;重连用例改为 rps 单人 + AI 兜底,小 logicalDt 让 60s 对局慢跑以留出重连时间。
Files:
-
Modify:
Server/Gateway.Tests/GatewayEndToEndTests.cs(整文件替换) -
Modify:
Server/Gateway.Tests/Gateway.Tests.csproj:27(注释去 Hello 字样) -
Step 0: 更新
Gateway.Tests.csproj第 27 行注释
把:
<!-- 复用 2a 已构建的 Hello 夹具(由 build-test-games.sh 生成到 Server.Host.Tests/TestGames) -->
改为:
<!-- 复用 build-test-games.sh 生成到 Server.Host.Tests/TestGames 的 rps 夹具 -->
- Step 1: 用以下内容整体替换
GatewayEndToEndTests.cs
using System.Threading.Tasks;
using Xunit;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class GatewayEndToEndTests
{
private static Frame JoinRps()
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode());
[Fact]
public async Task Heartbeat_IsEchoedBack()
{
var host = new GameServerHost();
await host.StartAsync(TestGames.Root, tickIntervalMs: 20);
try
{
using var client = new WsTestClient();
await client.ConnectAsync(host.WsBaseUrl, pid: 7);
await client.SendFrameAsync(new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat,
new HeartbeatMsg { ClientTimeMs = 999L }.Encode()));
Frame? echo = null;
for (int i = 0; i < 10; i++)
{
var f = await client.ReceiveFrameAsync(5000);
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat) { echo = f; break; }
}
Assert.True(echo.HasValue, "未收到心跳回显");
Assert.Equal(999L, HeartbeatMsg.Decode(echo.Value.Payload).ClientTimeMs);
await client.CloseAsync();
}
finally { await host.StopAsync(); }
}
[Fact]
public async Task Reconnect_ContinuesReceivingFromSameRoom()
{
var host = new GameServerHost();
// matchTimeoutTicks=3 → 单人后 AI 兜底成团;logicalDt=0.2 让 60s 对局慢跑;大重连窗口
await host.StartAsync(TestGames.Root, tickIntervalMs: 200,
reconnectWindowTicks: 100, matchTimeoutTicks: 3, logicalDt: 0.2f);
try
{
var client1 = new WsTestClient();
await client1.ConnectAsync(host.WsBaseUrl, pid: 42);
await client1.SendFrameAsync(JoinRps());
// 收到 MatchFound 后的第一个游戏快照
bool gotSnapshot = false;
for (int i = 0; i < 15 && !gotSnapshot; i++)
{
var f = await client1.ReceiveFrameAsync(5000);
if (f.Channel == Channel.Game) gotSnapshot = true;
}
Assert.True(gotSnapshot, "重连前应先收到一个快照");
// 断开
await client1.CloseAsync();
client1.Dispose();
// 用同 pid 重连
using var client2 = new WsTestClient();
await client2.ConnectAsync(host.WsBaseUrl, pid: 42);
// 房间仍在跑,下一个房间输出应发到 client2
bool gotAfterReconnect = false;
for (int i = 0; i < 15 && !gotAfterReconnect; i++)
{
var f = await client2.ReceiveFrameAsync(5000);
if (f.Channel == Channel.Game) gotAfterReconnect = true;
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd) gotAfterReconnect = true;
}
Assert.True(gotAfterReconnect, "重连后应继续收到房间输出(快照或 RoomEnd)");
await client2.CloseAsync();
}
finally { await host.StopAsync(); }
}
}
}
- Step 2: 运行该测试
Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~GatewayEndToEndTests"
Expected: PASS(2 个测试)。
Task 10: MatchmakerTests 的 playerCount=1 用例去 hello 标签
Matchmaker 是纯队列逻辑,不加载具体游戏,gameId 仅作分桶标签,改成非 hello 字符串即可。
Files:
-
Modify:
Server/Gateway.Tests/MatchmakerTests.cs:63-75 -
Step 1: 替换
PlayerCount1_SingleEnqueue_PollImmediately_ReturnsMatch方法(原 63-75 行)为:
[Fact]
public void PlayerCount1_SingleEnqueue_PollImmediately_ReturnsMatch()
{
// playerCount=1 应立即成团(Matchmaker 仅按 playerCount 分桶,不加载具体游戏)
var mm = new Matchmaker(timeoutTicks: 100);
mm.Enqueue(pid: 5, gameId: "solo1p", version: 1, playerCount: 1, tick: 0);
var matches = mm.Poll(currentTick: 0);
Assert.Single(matches);
Assert.Single(matches[0].Seats);
Assert.False(matches[0].Seats[0].IsAi);
}
- Step 2: 运行该测试
Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~MatchmakerTests"
Expected: PASS(5 个测试)。
Task 10b: 修正受 rps/2 影响的 lobby 发现测试(执行期发现的计划缺口)
引入 rps/2 后,ServerLoop.BuildGameList() 按"每 gameId 最新版本"发现,大厅改列 rps v2,导致既有测试 ServerLoopMatchmakingTests.Lobby_GameList_AndRandomMatch_AssignsDiscoverableGame 的 Version == 1 断言失败。版本号非该测试重点,改为版本无关 + 断言"随机匹配分配大厅所列版本"。
Files:
-
Modify:
Server/Gateway.Tests/ServerLoopMatchmakingTests.cs(Lobby 测试两处断言) -
Step 1: 把
Assert.Contains(list.Games, g => g.GameId == "rps" && g.Version == 1);改为版本无关并捕获 rps 版本:
// 两个 rps 夹具(v1/v2,同 IL)并存时,大厅只列出最新版本;具体版本号非本测试重点
Assert.Contains(list.Games, g => g.GameId == "rps");
int rpsVersion = 0;
foreach (var g in list.Games) if (g.GameId == "rps") rpsVersion = g.Version;
- Step 2: 把
Assert.Equal(1, assigned.Version);改为Assert.Equal(rpsVersion, assigned.Version); - Step 3: Run
dotnet test Server/Gateway.Tests/Gateway.Tests.csproj→ 全绿。
Task 11: 全量服务端回归(删 Hello 后第一道总闸)
Files: 无(仅运行)
- Step 1: 运行整个解决方案测试
Run: dotnet test Server/Server.sln
Expected: 全部 PASS,0 失败。失败项必为某处仍引用 hello 夹具或断言未适配——回到对应 Task 修正后再跑。
Task 12: 新增 RPS C# 客户端薄层(对标 HelloFlowGameClient)
源码唯一位置在 Assets 下(Unity/HybridCLR 编译它);另建一个 RPS.Client.csproj(放 Client/HotUpdateGames/,与 HelloFlow.Client 同级)用 <Compile Include> 链接同一批源做 csc 验证。客户端按现有 RpsState 字段(座位制,无 net id)呈现,不扩协议。
Files:
-
Create:
Client/Assets/MiniGames/RockPaperScissors/scripts/Client/RpsGameClient.cs -
Create:
Client/HotUpdateGames/RPS.Client/RPS.Client.csproj -
Step 1: 创建
Client/Assets/MiniGames/RockPaperScissors/scripts/Client/RpsGameClient.cs
using RPS.Core;
using UnityEngine;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace RPS.Client
{
// RPS 客户端薄层:解码服务端快照、渲染 IMGUI、在 Choosing 阶段提交一次出招。
// 协议:服务端广播 SnapshotOpcode=1;客户端出招 ChoiceOpcode=1,payload=单字节 Choice。
public sealed class RpsGameClient : IGameClient
{
private const ushort SnapshotOpcode = 1;
private const ushort ChoiceOpcode = 1;
private readonly RpsLogic _logic = new RpsLogic();
private IGameClientCtx _ctx;
private GameObject _viewObject;
private RpsView _view;
public void OnEnter(IGameClientCtx ctx)
{
_ctx = ctx;
_viewObject = new GameObject("RpsView");
Object.DontDestroyOnLoad(_viewObject);
_view = _viewObject.AddComponent<RpsView>();
_view.Client = this;
_view.Status = "waiting for match...";
_ctx.Logger.Info("rps client entered");
}
public void OnNetMessage(NetMessage msg)
{
if (msg.Opcode != SnapshotOpcode) return;
var state = _logic.Decode(msg.Payload);
if (_view == null) return;
// 进入新一回合的 Choosing 时解锁本地出招
if (state.Phase == RpsPhase.Choosing && _view.State != null
&& _view.State.Round != state.Round)
_view.LocalChoice = Choice.None;
_view.State = state;
if (state.Phase == RpsPhase.Finished)
_view.Status = state.Winner == 2 ? "finished: draw"
: "finished: seat " + state.Winner + " wins";
else
_view.Status = "round " + state.Round + " / " + state.Phase;
}
public void OnUpdate(float dt)
{
if (_view != null) _view.Elapsed += dt;
}
public void OnExit()
{
_ctx?.Logger.Info("rps client exited");
if (_viewObject != null) Object.Destroy(_viewObject);
_viewObject = null;
_view = null;
_ctx = null;
}
// 由视图按钮调用:Choosing 阶段提交一次出招(本回合只发一次)
public void SubmitChoice(Choice choice)
{
if (_ctx == null || _view == null || _view.State == null) return;
if (_view.State.Phase != RpsPhase.Choosing) return;
if (_view.LocalChoice != Choice.None) return;
_view.LocalChoice = choice;
var w = new PacketWriter();
w.WriteByte((byte)choice);
_ctx.Send(new NetMessage(ChoiceOpcode, w.ToArray()));
_view.Status = "locked: " + choice;
}
}
public sealed class RpsView : MonoBehaviour
{
public RpsGameClient Client;
public string Status;
public RpsState State;
public Choice LocalChoice = Choice.None;
public float Elapsed;
private static string ChoiceName(Choice c)
=> c == Choice.Rock ? "Rock"
: c == Choice.Paper ? "Paper"
: c == Choice.Scissors ? "Scissors" : "-";
private void OnGUI()
{
GUILayout.BeginArea(new Rect(16, 16, 460, 320), GUI.skin.box);
GUILayout.Label("Rock-Paper-Scissors (Hot DLL)");
GUILayout.Label("Status: " + Status);
GUILayout.Label("Elapsed: " + Elapsed.ToString("0.0") + "s");
if (State == null)
{
GUILayout.Label("No snapshot yet");
}
else
{
GUILayout.Label("Round: " + State.Round + " Phase: " + State.Phase);
GUILayout.Label("Score: seat0 " + State.Scores[0] + " : " + State.Scores[1] + " seat1");
GUILayout.Label("This round: seat0=" + ChoiceName(State.Choices[0])
+ " seat1=" + ChoiceName(State.Choices[1]));
bool canChoose = State.Phase == RpsPhase.Choosing && LocalChoice == Choice.None;
GUI.enabled = canChoose;
GUILayout.BeginHorizontal();
if (GUILayout.Button("Rock")) Client?.SubmitChoice(Choice.Rock);
if (GUILayout.Button("Paper")) Client?.SubmitChoice(Choice.Paper);
if (GUILayout.Button("Scissors")) Client?.SubmitChoice(Choice.Scissors);
GUILayout.EndHorizontal();
GUI.enabled = true;
if (LocalChoice != Choice.None)
GUILayout.Label("Your choice: " + ChoiceName(LocalChoice));
if (State.Phase == RpsPhase.Finished)
GUILayout.Label("Winner: " + (State.Winner == 2 ? "draw" : "seat " + State.Winner));
}
GUILayout.EndArea();
}
}
}
- Step 2: 创建
Client/HotUpdateGames/RPS.Client/RPS.Client.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>RPS.Client</AssemblyName>
<RootNamespace>RPS.Client</RootNamespace>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<UnityEditorPath Condition="'$(UnityEditorPath)' == ''">D:\Program Files\Unity 2022.3.62f2c1\Editor</UnityEditorPath>
</PropertyGroup>
<ItemGroup>
<!-- 客户端薄层源码唯一位置在 Assets 下(Unity/HybridCLR 编译它);本工程仅链接同一批源做 csc 验证 -->
<Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts\Client\**\*.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Server\Framework.Shared\Framework.Shared.csproj">
<Private>false</Private>
</ProjectReference>
<ProjectReference Include="..\..\..\Server\games-src\RPS\RPS.Core\RPS.Core.csproj">
<Private>false</Private>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Reference Include="UnityEngine">
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.IMGUIModule">
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.IMGUIModule.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
- Step 3: csc 编译验证(0 错误)
Run: dotnet build "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj" -c Release -nologo
Expected: Build succeeded,0 Error。若报 UnityEngine 找不到,确认本机 D:\Program Files\Unity 2022.3.62f2c1\Editor 存在(与 HelloFlow.Client.csproj 同路径);路径不同则传 -p:UnityEditorPath=<你的Unity路径>。
- Step 4: 确认未把框架/Core/Unity DLL 拷进输出(Private=false 生效)
Run: ls Client/HotUpdateGames/RPS.Client/bin/Release/netstandard2.1/
Expected: 只有 RPS.Client.dll(及 pdb/deps.json),无 XWorld.Framework.Shared.dll、RPS.Core.dll、UnityEngine*.dll。
备注:
RpsGameClient.cs在 Unity 中需要.meta,由 Unity 导入时自动生成,本环境不手工创建。仍须 Unity 人工核对:运行时表现、HybridCLR 加载、AB 打包。
Task 13: 删除弃用 Lua + 更新 README
Files:
-
Delete:
Client/Assets/MiniGames/RockPaperScissors/Client/main.lua(+.meta)、.../Server/main.lua(+.meta)、.../scripts/Client/main.lua(+.meta)、.../scripts/Server/main.lua(+.meta) -
Modify:
Client/Assets/MiniGames/RockPaperScissors/README.md:15-22 -
Step 1: 删除四份 Lua 及其 .meta,并清理空目录
Run:
cd "Client/Assets/MiniGames/RockPaperScissors"
rm -f Client/main.lua Client/main.lua.meta Server/main.lua Server/main.lua.meta
rm -f scripts/Server/main.lua scripts/Server/main.lua.meta
rm -rf Client Client.meta Server Server.meta scripts/Server scripts/Server.meta
cd -
(注:scripts/Client/main.lua 不在此删 —— 下一步会被 Task 12 的 RpsGameClient.cs 取代;若仍存在则一并删。)
- Step 2: 删除残留的 scripts/Client/main.lua(C# 已取代)
Run:
rm -f "Client/Assets/MiniGames/RockPaperScissors/scripts/Client/main.lua" \
"Client/Assets/MiniGames/RockPaperScissors/scripts/Client/main.lua.meta"
- Step 3: 替换 README 的"当前状态"小节(原 15-22 行)为:
## 当前状态(C# 客户端已落地)
- `res/UI/Texture/`:三张出招贴图(rock / paper / scissors),将随 AB 打包。
- `scripts/Client/`:客户端薄层 C# 实现 `RpsGameClient`(`IGameClient`),由 Unity/HybridCLR 编译为热更 DLL;
另有 `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj` 链接同一份源做 csc 编译验证。
- 共享核心 `RPS.Core`(规则/状态/编解码)与服务端薄层 `RPS.Server`(`IGameServerRoom`)的 C# 源码在
`Server/games-src/RPS/`(旧 Lua 版已删除)。
- 待办(独立改造,须 Unity):将 `RPS.Core` 收敛为 `scripts/Core` 单源、服务端 `<Compile Include>` 链接(设计 §10.1)。
- Step 4: 确认 Lua 已清除
Run: find "Client/Assets/MiniGames/RockPaperScissors" -name "*.lua"
Expected: 无输出。
Task 14: PC 烟雾测试改用 RPS
Files:
-
Modify:
Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1(整文件替换) -
Modify:
Client/Assets/Script/xmain/MiniGame/PcMiniGameSmokeLauncher.cs:7,13,48 -
Modify:
Client/Assets/Script/Editor/PcMiniGameSmokeMenu.cs:30 -
Step 1: 用以下内容整体替换
Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1
param(
[string]$UnityEditorPath = "D:\Program Files\Unity 2022.3.62f2c1\Editor",
[string]$OutRoot = "Build\PcMiniGameSmoke"
)
$ErrorActionPreference = "Stop"
function Md5Hex([string]$Path) {
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $Path))
$hash = $md5.ComputeHash($bytes)
return -join ($hash | ForEach-Object { $_.ToString("x2") })
}
finally {
$md5.Dispose()
}
}
function WriteMd5Named([string]$Source, [string]$LogicalName, [string]$DestDir) {
$hash = Md5Hex $Source
$ext = [System.IO.Path]::GetExtension($LogicalName)
$nameNoExt = if ($ext.Length -gt 0) { $LogicalName.Substring(0, $LogicalName.Length - $ext.Length) } else { $LogicalName }
$destName = if ($ext.Length -gt 0) { "${nameNoExt}_${hash}${ext}" } else { "${LogicalName}_${hash}" }
Copy-Item -LiteralPath $Source -Destination (Join-Path $DestDir $destName) -Force
$size = (Get-Item -LiteralPath $Source).Length
return "{'$LogicalName','$hash',$size},"
}
$repo = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$out = Join-Path $repo $OutRoot
$serverDir = Join-Path $out "server\games\rps\1"
$clientDir = Join-Path $out "client\minigame\rps\1"
$stageDir = Join-Path $out "_stage"
New-Item -ItemType Directory -Force -Path $serverDir, $clientDir, $stageDir | Out-Null
foreach ($dir in @($serverDir, $clientDir, $stageDir)) {
Get-ChildItem -LiteralPath $dir -Force | Remove-Item -Recurse -Force
}
dotnet build (Join-Path $repo "Server\games-src\RPS\RPS.Server\RPS.Server.csproj") -c Release -nologo | Write-Host
dotnet build (Join-Path $repo "Client\HotUpdateGames\RPS.Client\RPS.Client.csproj") -c Release -nologo -p:UnityEditorPath="$UnityEditorPath" | Write-Host
$rpsCore = Join-Path $repo "Server\games-src\RPS\RPS.Server\bin\Release\netstandard2.1\RPS.Core.dll"
$rpsServer = Join-Path $repo "Server\games-src\RPS\RPS.Server\bin\Release\netstandard2.1\RPS.Server.dll"
$rpsClient = Join-Path $repo "Client\HotUpdateGames\RPS.Client\bin\Release\netstandard2.1\RPS.Client.dll"
Copy-Item -LiteralPath $rpsCore -Destination (Join-Path $serverDir "RPS.Core.dll") -Force
Copy-Item -LiteralPath $rpsServer -Destination (Join-Path $serverDir "RPS.Server.dll") -Force
@"
{
"gameId": "rps",
"version": 1,
"serverAssembly": "RPS.Server.dll",
"serverEntryType": "RPS.Server.RpsServerRoom",
"minFrameworkVersion": 1,
"playerCount": 2,
"tickRateHz": 10
}
"@ | Set-Content -Encoding UTF8 (Join-Path $serverDir "game.json")
@"
{
"gameId": "rps",
"version": 1,
"clientEntryType": "RPS.Client.RpsGameClient",
"coreDll": "RPS.Core.dll",
"clientDll": "RPS.Client.dll",
"minFrameworkVersion": 1,
"assets": []
}
"@ | Set-Content -Encoding UTF8 (Join-Path $clientDir "game.json")
$coreBytes = Join-Path $stageDir "RPS.Core.dll.bytes"
$clientBytes = Join-Path $stageDir "RPS.Client.dll.bytes"
Copy-Item -LiteralPath $rpsCore -Destination $coreBytes -Force
Copy-Item -LiteralPath $rpsClient -Destination $clientBytes -Force
$lines = @()
$lines += WriteMd5Named $coreBytes "RPS.Core.dll.bytes" $clientDir
$lines += WriteMd5Named $clientBytes "RPS.Client.dll.bytes" $clientDir
$lines -join "`n" | Set-Content -Encoding UTF8 (Join-Path $clientDir "files.txt")
Set-Content -Encoding Byte -Value @() (Join-Path $clientDir "files.txt.sig")
Write-Host "PC smoke package ready:"
Write-Host " server games root: $((Resolve-Path (Join-Path $out 'server\games')).Path)"
Write-Host " client cdn base: file:///$($repo.Replace('\','/'))/$($OutRoot.Replace('\','/'))/client/minigame/"
Write-Host "Run server (rps 为 2 人,单人入房需 AI 兜底,故用较小 matchTimeoutTicks):"
Write-Host " dotnet Server\Gateway.Runner\bin\Debug\net10.0\XWorld.Server.Gateway.Runner.dll --gamesRoot `"$((Resolve-Path (Join-Path $out 'server\games')).Path)`" --port 5005 --tickMs 100 --matchTimeoutTicks 30"
- Step 2: 改
PcMiniGameSmokeLauncher.cs第 7 行注释
把:
// PC 本地端到端烟测入口:连接本地 Gateway,再用 MiniGameHost 下载/加载 hello 小游戏。
改为:
// PC 本地端到端烟测入口:连接本地 Gateway,再用 MiniGameHost 下载/加载 rps 小游戏。
- Step 3: 改第 13 行默认 GameId
把:
public string GameId = "hello";
改为:
public string GameId = "rps";
- Step 4: 改第 48 行 Expected 文案
把:
GUILayout.Label("Expected: download hello DLLs -> connect -> MatchFound -> 3 snapshots -> RoomEnd.");
改为:
GUILayout.Label("Expected: download rps DLLs -> connect -> MatchFound(+AI) -> snapshots -> RoomEnd.");
- Step 4b: 改
Client/Assets/Script/Editor/PcMiniGameSmokeMenu.cs第 30 行默认 GameId
把:
launcher.GameId = "hello";
改为:
launcher.GameId = "rps";
- Step 5: 验证 .NET 构建段(Unity/AB/Play 段无法在本环境验)
Run: dotnet build "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj" -c Release -nologo && dotnet build "Server/games-src/RPS/RPS.Server/RPS.Server.csproj" -c Release -nologo
Expected: 两个 Build succeeded,0 Error。
须人工核对:在 Windows + Unity 下运行
Build-PcMiniGameSmoke.ps1与 Play 烟测(本环境无法执行 PowerShell/Unity)。
Task 15: GameManifest.cs 文档注释改 RPS
Files:
-
Modify:
Server/Server.Host/GameManifest.cs:10-11 -
Step 1: 替换第 10-11 行注释
把:
public string ServerAssembly { get; set; } // 例如 "Hello.Server.dll"
public string ServerEntryType { get; set; } // 例如 "Hello.Server.HelloServerRoom"
改为:
public string ServerAssembly { get; set; } // 例如 "RPS.Server.dll"
public string ServerEntryType { get; set; } // 例如 "RPS.Server.RpsServerRoom"
- Step 2: 确认 Server.Host 仍编译
Run: dotnet build Server/Server.Host/Server.Host.csproj -c Debug -nologo
Expected: Build succeeded。
Task 16: 最终全量回归 + Hello 痕迹清零核对
Files: 无(仅运行)
- Step 1: 全量测试
Run: dotnet test Server/Server.sln
Expected: 全部 PASS,0 失败。
- Step 2: 确认本项目代码内已无 Hello 引用(只扫我们维护的目录,排除第三方 Unity 包/无关示例串)
Run:
grep -rIln "[Hh]ello\|HELLO" \
Server/Server.Host Server/Gateway Server/games-src Server/Server.Host.Tests Server/Gateway.Tests \
Server/build-test-games.sh Tools/PcMiniGameSmoke \
Client/HotUpdateGames Client/Assets/MiniGames \
Client/Assets/Script/xmain/MiniGame Client/Assets/Script/Editor \
2>/dev/null | grep -v "/bin/" | grep -v "/obj/" || echo "no hello references"
Expected: no hello references。
说明:仓库其他位置(
Client/Library/PackageCache、Client/PackageLocal、AES.cs、WX.cs、lsocket.cs、XWMain.lua、sandbox_spec.lua等)出现的 "hello" 是第三方 Unity 包测试或无关示例字符串,不在本计划范围。
- Step 3: 确认 RPS C# 客户端编译通过
Run: dotnet build "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj" -c Release -nologo
Expected: Build succeeded,0 Error。
完成标准
dotnet test Server/Server.sln全绿(0 失败)。Client/HotUpdateGames/RPS.Client/RPS.Client.csprojcsc 编译 0 错误。- 仓库内无 Hello 源码、夹具、测试引用、构建脚本引用(文档目录除外)。
- 弃用 Lua 已删,README 已更新。
- PC 烟雾的 .NET 段构建通过;Unity Play/AB 段标注为人工核对。
遗留(本环境无 Unity,无法验证)
- RPS C# 客户端运行时表现、HybridCLR 加载、AB 打包。
Build-PcMiniGameSmoke.ps1实际运行 + Play 烟测。RPS.Core收敛为scripts/Core单源(设计 §10.1,独立改造项)。