Files
AIC-Project/docs/superpowers/plans/2026-06-22-remove-hello-complete-rps.md
T
2026-07-10 10:24:29 +08:00

1288 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 删除 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/2`
- `Server/Server.Host.Tests/GameModuleLoaderTests.cs` — 去冗余,仅保留 rps 缺版本测试
- `Server/Server.Host.Tests/AlcUnloadTests.cs` — 改 rps
- `Server/Server.Host.Tests/GameModuleRegistryTests.cs` — 改 rps 1/2
- `Server/Server.Host.Tests/GameManifestTests.cs` — Valid 常量改 rps
- `Server/Server.Host.Tests/RoomHostTests.cs` — 重写为 rps 语义
- `Server/Gateway.Tests/ServerLoopTests.cs` — 删冗余 + 重连/在房重复匹配改 rps
- `Server/Gateway.Tests/GatewayEndToEndTests.cs` — 删冗余 + 重连改 rps
- `Server/Gateway.Tests/MatchmakerTests.cs` — playerCount=1 用例去 hello 标签
- `Server/Server.Host/GameManifest.cs` — 文档注释示例改 RPS
- `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1` — Hello → RPS
- `Client/Assets/Script/xmain/MiniGame/PcMiniGameSmokeLauncher.cs` — gameId/文案 hello → rps
- `Client/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.cs`
- `Client/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`**
```bash
#!/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:
```bash
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:
```bash
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 字样**
把:
```csharp
/// Task 4: Proves "framework zero-change loads a new game via ALC" — same paradigm as Hello.
```
改为:
```csharp
/// Proves "framework zero-change loads a new game via ALC": loads RPS through the existing ALC contract.
```
- [ ] **Step 2: 用以下内容整体替换 `GameModuleLoaderTests.cs`**
```csharp
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`**
```csharp
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`**
```csharp
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 行)为:**
```csharp
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 行)为:**
```csharp
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`**
```csharp
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)出 Rockopcode=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); // 第一大 tickTotalElapsed=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`**
```csharp
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→33-1=2,不 >2,保留
loop.DrainAndTick(1f); // _tick→44-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 行注释**
把:
```xml
<!-- 复用 2a 已构建的 Hello 夹具(由 build-test-games.sh 生成到 Server.Host.Tests/TestGames -->
```
改为:
```xml
<!-- 复用 build-test-games.sh 生成到 Server.Host.Tests/TestGames 的 rps 夹具 -->
```
- [ ] **Step 1: 用以下内容整体替换 `GatewayEndToEndTests.cs`**
```csharp
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 行)为:**
```csharp
[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 版本:
```csharp
// 两个 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`**
```csharp
using RPS.Core;
using UnityEngine;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace RPS.Client
{
// RPS 客户端薄层:解码服务端快照、渲染 IMGUI、在 Choosing 阶段提交一次出招。
// 协议:服务端广播 SnapshotOpcode=1;客户端出招 ChoiceOpcode=1payload=单字节 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`**
```xml
<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:
```bash
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:
```bash
rm -f "Client/Assets/MiniGames/RockPaperScissors/scripts/Client/main.lua" \
"Client/Assets/MiniGames/RockPaperScissors/scripts/Client/main.lua.meta"
```
- [ ] **Step 3: 替换 README 的"当前状态"小节(原 15-22 行)为:**
```markdown
## 当前状态(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`**
```powershell
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 行注释**
把:
```csharp
// PC 本地端到端烟测入口:连接本地 Gateway,再用 MiniGameHost 下载/加载 hello 小游戏。
```
改为:
```csharp
// PC 本地端到端烟测入口:连接本地 Gateway,再用 MiniGameHost 下载/加载 rps 小游戏。
```
- [ ] **Step 3: 改第 13 行默认 GameId**
把:
```csharp
public string GameId = "hello";
```
改为:
```csharp
public string GameId = "rps";
```
- [ ] **Step 4: 改第 48 行 Expected 文案**
把:
```csharp
GUILayout.Label("Expected: download hello DLLs -> connect -> MatchFound -> 3 snapshots -> RoomEnd.");
```
改为:
```csharp
GUILayout.Label("Expected: download rps DLLs -> connect -> MatchFound(+AI) -> snapshots -> RoomEnd.");
```
- [ ] **Step 4b: 改 `Client/Assets/Script/Editor/PcMiniGameSmokeMenu.cs` 第 30 行默认 GameId**
把:
```csharp
launcher.GameId = "hello";
```
改为:
```csharp
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 行注释**
把:
```csharp
public string ServerAssembly { get; set; } // 例如 "Hello.Server.dll"
public string ServerEntryType { get; set; } // 例如 "Hello.Server.HelloServerRoom"
```
改为:
```csharp
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:
```bash
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.csproj` csc 编译 0 错误。
- 仓库内无 Hello 源码、夹具、测试引用、构建脚本引用(文档目录除外)。
- 弃用 Lua 已删,README 已更新。
- PC 烟雾的 .NET 段构建通过;Unity Play/AB 段标注为人工核对。
## 遗留(本环境无 Unity,无法验证)
- RPS C# 客户端运行时表现、HybridCLR 加载、AB 打包。
- `Build-PcMiniGameSmoke.ps1` 实际运行 + Play 烟测。
- `RPS.Core` 收敛为 `scripts/Core` 单源(设计 §10.1,独立改造项)。