# 框架接口与协议层(共享层)实现计划
> **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:** 落地客户端与服务端共用的「框架共享层」:第 5 节接口契约 + 手写二进制网络协议编解码 + 确定性随机数;一份源码两端各编一次(A1),`dotnet test` 全绿,先不接任何具体小游戏。
**Architecture:** 共享源码的**唯一规范副本**放在 `Client/Assets/Framework/Shared/`,带一个 `noEngineReferences: true` 的 asmdef(编译期强制「绝不引用 UnityEngine」,对应设计 §2.1 的依赖铁律,且本程序集**不进** HybridCLR 热更列表 —— 框架接口类型常驻、不随小游戏卸载,对应 §4.2)。服务端 .NET 工程通过 `` **链接同一批 .cs**,不拷贝。绝大部分 TDD 在 .NET 侧用 xUnit 完成(编解码 / 随机数 / 帧封装的往返与边界),无需打开 Unity;Unity 侧只做一次 asmdef 集成与编译核对。
**Tech Stack:** C# 9(语言级别对齐 Unity)· 共享库 `netstandard2.1`(API 面对齐 Unity/HybridCLR)· 测试 `net10.0` + xUnit · 手写二进制编解码(varint/zigzag,无反射,AOT/WASM 友好)· .NET SDK 10.0.201(已安装)。
---
## 背景锁定(来自架构设计与代码勘察)
- 设计文档:`docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md`,本计划落地其 §9 路线**第 1 步**。
- 客户端唯一热更程序集是 `XWorld.Link`(`Client/ProjectSettings/HybridCLRSettings.asset` 的 `hotUpdateAssemblies`)。本共享层**不加入**该列表(常驻不热更)。
- `Server/` 目录目前为空,本计划在其中新建首批 .NET 工程。
- RPS 现为 Lua(`Client/Assets/RockPaperScissors/script/`),本步骤**不改动它**;只为后续 §9 第 5 步用 C# 重写打地基。
- 已确认 `dotnet` 可用:`10.0.201`(SDK 9 与 10 均在)。
## 命名与依赖约定(贯穿全计划,务必一致)
- 命名空间:SDK 契约与基础类型 → `XWorld.Framework`;协议编解码 → `XWorld.Framework.Protocol`。
- 程序集名:`XWorld.Framework.Shared`(Unity asmdef 名与 .NET `AssemblyName` 一致)。
- 共享库**只允许** `netstandard2.1` API + C# 9 语法。禁止:`UnityEngine`、`System.IO`/socket 等平台 API、`record`/顶级语句/`init`-only 之外的 C# 10+ 语法。
- `byte[] Encode()` / `static T Decode(byte[])` 是所有线网 DTO 的统一约定;所有多字节整数一律**小端**,浮点 4 字节小端。
## 文件结构(先定边界,再拆任务)
规范源码(Unity 与 .NET 共用同一批文件):
| 文件 | 职责 |
|---|---|
| `Client/Assets/Framework/Shared/Framework.Shared.asmdef` | Unity 程序集定义(`noEngineReferences: true`,不入热更列表) |
| `Client/Assets/Framework/Shared/Capabilities.cs` | 平台能力接口:`IRandom` `ITimer` `ILogger` `IStorage` |
| `Client/Assets/Framework/Shared/GameTypes.cs` | 基础值类型:`PlayerInfo` `RoomConfig` `NetMessage` `StepResult<,>` |
| `Client/Assets/Framework/Shared/IGameLogic.cs` | Core 纯逻辑契约 `IGameLogic` |
| `Client/Assets/Framework/Shared/IGameClient.cs` | 客户端薄层入口 `IGameClient` `IGameClientCtx` `IAssetLoader` |
| `Client/Assets/Framework/Shared/IGameServerRoom.cs` | 服务端薄层入口 `IGameServerRoom` `IRoomCtx` |
| `Client/Assets/Framework/Shared/DeterministicRandom.cs` | `IRandom` 的确定性实现(SplitMix64,可快照种子状态) |
| `Client/Assets/Framework/Shared/Protocol/PacketWriter.cs` | 低层写入器:varint/zigzag/string/blob/单精度 |
| `Client/Assets/Framework/Shared/Protocol/PacketReader.cs` | 低层读取器(与 Writer 对偶,越界抛 `FormatException`) |
| `Client/Assets/Framework/Shared/Protocol/FrameCodec.cs` | 帧信封 `Channel`/`Frame`/`FrameCodec`(Framework vs Game 双通道) |
| `Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs` | 框架控制消息:opcode 枚举 + 各 DTO 的 Encode/Decode |
.NET 工程(链接上面的源码 + 测试):
| 文件 | 职责 |
|---|---|
| `Server/Framework.Shared/Framework.Shared.csproj` | `netstandard2.1` 类库,`` 链接规范源码 |
| `Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj` | `net10.0` + xUnit 测试工程 |
| `Server/Framework.Shared.Tests/PacketCodecTests.cs` | PacketWriter/Reader 往返与边界测试 |
| `Server/Framework.Shared.Tests/FrameCodecTests.cs` | 帧封装往返测试 |
| `Server/Framework.Shared.Tests/DeterministicRandomTests.cs` | 随机数确定性 / 范围 / 可复现测试 |
| `Server/Framework.Shared.Tests/FrameworkMessagesTests.cs` | 各控制消息往返测试 |
| `Server/Server.sln` | 解决方案,聚合上述两个工程 |
---
## Task 1: 搭建 .NET 工程骨架(库 + 测试 + 解决方案)
**Files:**
- Create: `Server/Framework.Shared/Framework.Shared.csproj`
- Create: `Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj`
- Create: `Server/Server.sln`
- Create(占位规范源码目录): `Client/Assets/Framework/Shared/`
- [ ] **Step 1: 用模板生成两个工程与解决方案**
在仓库根 `D:/UD/AI/AIC#Project` 执行:
```bash
dotnet new classlib -n Framework.Shared -o Server/Framework.Shared -f netstandard2.1
dotnet new xunit -n Framework.Shared.Tests -o Server/Framework.Shared.Tests
dotnet new sln -n Server -o Server
dotnet sln Server/Server.sln add Server/Framework.Shared/Framework.Shared.csproj
dotnet sln Server/Server.sln add Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj
rm -f Server/Framework.Shared/Class1.cs Server/Framework.Shared.Tests/UnitTest1.cs
mkdir -p "Client/Assets/Framework/Shared/Protocol"
```
- [ ] **Step 2: 改写 `Server/Framework.Shared/Framework.Shared.csproj` 为「链接规范源码」**
完整替换文件内容:
```xml
netstandard2.1
9.0
disable
disable
XWorld.Framework.Shared
XWorld.Framework
false
```
- [ ] **Step 3: 让测试工程引用库**
在 `Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj` 的 `` 之前加入(模板已含 xUnit 包引用,只补 ProjectReference):
```xml
```
- [ ] **Step 4: 放一个临时探针源码文件,验证链接链路通**
创建 `Client/Assets/Framework/Shared/_Probe.cs`:
```csharp
namespace XWorld.Framework
{
internal static class _Probe
{
public const int Value = 42;
}
}
```
- [ ] **Step 5: 还原 + 构建,确认两端工程都能编**
Run: `dotnet build Server/Server.sln -v minimal`
Expected: `Build succeeded`,0 error。若报 `netstandard2.1` 目标包缺失,先确认 SDK 自带(10.x 自带 netstandard2.1 参考包,正常无需额外安装)。
- [ ] **Step 6: 删除探针并提交骨架**
```bash
rm -f "Client/Assets/Framework/Shared/_Probe.cs"
git init 2>/dev/null; git add Server "Client/Assets/Framework/Shared"
git commit -m "chore(framework): scaffold shared layer netstandard2.1 lib + xunit tests linking canonical sources"
```
> 注:仓库当前非 git 仓库(环境探测 `Is a git repository: false`)。若团队已有版本库,跳过 `git init`,按实际仓库提交。
---
## Task 2: 平台能力接口与基础值类型(编译核对)
**Files:**
- Create: `Client/Assets/Framework/Shared/Capabilities.cs`
- Create: `Client/Assets/Framework/Shared/GameTypes.cs`
> 纯声明,无运行时行为,用 `dotnet build` 核对(TDD 的红/绿留给后续有行为的任务)。
- [ ] **Step 1: 写 `Capabilities.cs`**
```csharp
namespace XWorld.Framework
{
// 服务端权威随机 / 客户端用服务端下发的种子复现
public interface IRandom
{
int Next(int maxExclusive); // 返回 [0, maxExclusive)
}
public interface ITimer
{
float Now { get; } // 单调递增的秒
}
public interface ILogger
{
void Info(string message);
void Warn(string message);
void Error(string message);
}
// 仅服务端注入;客户端 ctx 不提供
public interface IStorage
{
byte[] Load(string key); // 不存在返回 null
void Save(string key, byte[] data);
}
}
```
- [ ] **Step 2: 写 `GameTypes.cs`**
```csharp
using System;
using System.Collections.Generic;
namespace XWorld.Framework
{
public sealed class PlayerInfo
{
public int PlayerId;
public string Name;
public bool IsAI;
}
public sealed class RoomConfig
{
public string GameId;
public int Version;
public ulong Seed; // 权威随机种子,两端共用以复现
public int PlayerCount;
public IReadOnlyDictionary Params; // 小游戏自定义参数,可为 null
}
// 一条小游戏业务消息(承载在帧的 Game 通道里)
public readonly struct NetMessage
{
public readonly ushort Opcode;
public readonly byte[] Payload;
public NetMessage(ushort opcode, byte[] payload)
{
Opcode = opcode;
Payload = payload ?? Array.Empty();
}
}
// Core.Step 的纯函数式返回:新状态 + 本步事件
public readonly struct StepResult
{
public readonly TState State;
public readonly IReadOnlyList Events;
public StepResult(TState state, IReadOnlyList events)
{
State = state;
Events = events ?? Array.Empty();
}
}
}
```
- [ ] **Step 3: 构建核对**
Run: `dotnet build Server/Server.sln -v minimal`
Expected: `Build succeeded`,0 error/warning。
- [ ] **Step 4: 提交**
```bash
git add "Client/Assets/Framework/Shared/Capabilities.cs" "Client/Assets/Framework/Shared/GameTypes.cs"
git commit -m "feat(framework): platform capability interfaces and core value types"
```
---
## Task 3: 三个入口契约接口(编译核对)
**Files:**
- Create: `Client/Assets/Framework/Shared/IGameLogic.cs`
- Create: `Client/Assets/Framework/Shared/IGameClient.cs`
- Create: `Client/Assets/Framework/Shared/IGameServerRoom.cs`
- [ ] **Step 1: 写 `IGameLogic.cs`(Core 纯逻辑契约)**
```csharp
namespace XWorld.Framework
{
// 平台无关、纯函数式的权威逻辑;两端共用做权威计算与客户端预演
public interface IGameLogic
{
TState CreateInitial(RoomConfig config, IRandom random);
StepResult Step(TState state, TInput input, IRandom random);
byte[] Encode(TState state); // 快照编解码,两端共用
TState Decode(byte[] data);
}
}
```
- [ ] **Step 2: 写 `IGameClient.cs`(客户端薄层入口)**
```csharp
namespace XWorld.Framework
{
// 资源加载抽象,避免共享层引用 UnityEngine;客户端实现里再转成具体 Unity 对象
public interface IAssetLoader
{
object Load(string path); // 返回值由客户端薄层按需强转
}
public interface IGameClientCtx
{
IAssetLoader Assets { get; }
ILogger Logger { get; }
void Send(NetMessage message); // 发往服务端(Game 通道)
void Exit(); // 请求退出当前小游戏
}
public interface IGameClient
{
void OnEnter(IGameClientCtx ctx);
void OnNetMessage(NetMessage message);
void OnUpdate(float deltaTime);
void OnExit();
}
}
```
- [ ] **Step 3: 写 `IGameServerRoom.cs`(服务端薄层入口)**
```csharp
using System.Collections.Generic;
namespace XWorld.Framework
{
public interface IRoomCtx
{
IRandom Random { get; } // 权威随机
ITimer Timer { get; }
ILogger Logger { get; }
IStorage Storage { get; }
void Broadcast(NetMessage message);
void SendTo(int playerId, NetMessage message);
void EndRoom(); // 由小游戏逻辑请求结束本房间
}
public interface IGameServerRoom
{
void OnRoomStart(IReadOnlyList players, IRoomCtx ctx);
void OnMessage(int playerId, NetMessage message);
void OnTick(float deltaTime); // 10-20Hz,内部调用 Core.Step + 广播快照
void OnRoomEnd();
}
}
```
- [ ] **Step 4: 构建核对**
Run: `dotnet build Server/Server.sln -v minimal`
Expected: `Build succeeded`,0 error。
- [ ] **Step 5: 提交**
```bash
git add "Client/Assets/Framework/Shared/IGameLogic.cs" "Client/Assets/Framework/Shared/IGameClient.cs" "Client/Assets/Framework/Shared/IGameServerRoom.cs"
git commit -m "feat(framework): game logic / client / server room entry contracts"
```
---
## Task 4: 低层编解码 PacketWriter / PacketReader(TDD 核心)
**Files:**
- Create: `Client/Assets/Framework/Shared/Protocol/PacketWriter.cs`
- Create: `Client/Assets/Framework/Shared/Protocol/PacketReader.cs`
- Test: `Server/Framework.Shared.Tests/PacketCodecTests.cs`
- [ ] **Step 1: 先写失败测试 `PacketCodecTests.cs`**
```csharp
using System;
using Xunit;
using XWorld.Framework.Protocol;
namespace XWorld.Framework.Tests
{
public class PacketCodecTests
{
[Theory]
[InlineData(0u)]
[InlineData(1u)]
[InlineData(127u)]
[InlineData(128u)]
[InlineData(16384u)]
[InlineData(uint.MaxValue)]
public void VarUInt_RoundTrips(uint value)
{
var w = new PacketWriter();
w.WriteVarUInt(value);
var r = new PacketReader(w.ToArray());
Assert.Equal(value, r.ReadVarUInt());
Assert.False(r.HasMore);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(-1)]
[InlineData(int.MaxValue)]
[InlineData(int.MinValue)]
public void VarInt_RoundTrips(int value)
{
var w = new PacketWriter();
w.WriteVarInt(value);
var r = new PacketReader(w.ToArray());
Assert.Equal(value, r.ReadVarInt());
}
[Theory]
[InlineData(0L)]
[InlineData(-1234567890123L)]
[InlineData(long.MaxValue)]
[InlineData(long.MinValue)]
public void VarLong_RoundTrips(long value)
{
var w = new PacketWriter();
w.WriteVarLong(value);
var r = new PacketReader(w.ToArray());
Assert.Equal(value, r.ReadVarLong());
}
[Fact]
public void MixedSequence_RoundTrips_InOrder()
{
var w = new PacketWriter();
w.WriteBool(true);
w.WriteByte(200);
w.WriteUInt16(50000);
w.WriteVarInt(-42);
w.WriteSingle(3.14159f);
w.WriteString("héllo 世界");
w.WriteBytes(new byte[] { 1, 2, 3, 0, 255 });
var r = new PacketReader(w.ToArray());
Assert.True(r.ReadBool());
Assert.Equal((byte)200, r.ReadByte());
Assert.Equal((ushort)50000, r.ReadUInt16());
Assert.Equal(-42, r.ReadVarInt());
Assert.Equal(3.14159f, r.ReadSingle(), 5);
Assert.Equal("héllo 世界", r.ReadString());
Assert.Equal(new byte[] { 1, 2, 3, 0, 255 }, r.ReadBytes());
Assert.False(r.HasMore);
}
[Fact]
public void EmptyAndNull_String_Bytes_RoundTripAsEmpty()
{
var w = new PacketWriter();
w.WriteString(null);
w.WriteString("");
w.WriteBytes(null);
w.WriteBytes(Array.Empty());
var r = new PacketReader(w.ToArray());
Assert.Equal("", r.ReadString());
Assert.Equal("", r.ReadString());
Assert.Empty(r.ReadBytes());
Assert.Empty(r.ReadBytes());
}
[Fact]
public void ReadPastEnd_Throws()
{
var r = new PacketReader(Array.Empty());
Assert.Throws(() => r.ReadByte());
}
[Fact]
public void ReadBytes_WithBogusLength_Throws()
{
// 长度前缀声称 10 字节,实际无数据
var w = new PacketWriter();
w.WriteVarUInt(10);
var r = new PacketReader(w.ToArray());
Assert.Throws(() => r.ReadBytes());
}
// —— 畸形 varint 拒绝(C# 移位掩码导致的静默损坏防护)——
[Fact]
public void ReadVarUInt_SixthByte_Throws()
{
// 5 个延续字节后还有第 6 字节 → 过长,必须抛异常而非静默损坏
var r = new PacketReader(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0x8F, 0x00 });
Assert.Throws(() => r.ReadVarUInt());
}
[Fact]
public void ReadVarUInt_LastByteOverflow_Throws()
{
// 第 5 字节 0x10 表示值超过 uint.MaxValue
var r = new PacketReader(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0x10 });
Assert.Throws(() => r.ReadVarUInt());
}
[Fact]
public void ReadVarLong_EleventhByte_Throws()
{
var r = new PacketReader(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x00 });
Assert.Throws(() => r.ReadVarLong());
}
[Fact]
public void ReadVarLong_LastByteOverflow_Throws()
{
// 第 10 字节 0x02 表示值超过 ulong 的 64 位
var r = new PacketReader(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x02 });
Assert.Throws(() => r.ReadVarLong());
}
}
}
```
- [ ] **Step 2: 运行测试,确认编译失败/红**
Run: `dotnet test Server/Server.sln`
Expected: 编译失败(`PacketWriter`/`PacketReader` 不存在)。
- [ ] **Step 3: 写 `PacketWriter.cs`**
```csharp
using System;
using System.Collections.Generic;
using System.Text;
namespace XWorld.Framework.Protocol
{
// 手写小端二进制写入器:varint(LEB128) + zigzag,无反射,AOT/WASM 友好
public sealed class PacketWriter
{
private readonly List _buf;
public PacketWriter(int capacity = 64)
{
_buf = new List(capacity);
}
public void WriteByte(byte v) => _buf.Add(v);
public void WriteBool(bool v) => _buf.Add(v ? (byte)1 : (byte)0);
public void WriteUInt16(ushort v)
{
_buf.Add((byte)(v & 0xFF));
_buf.Add((byte)((v >> 8) & 0xFF));
}
public void WriteVarUInt(uint v)
{
while (v >= 0x80)
{
_buf.Add((byte)(v | 0x80));
v >>= 7;
}
_buf.Add((byte)v);
}
public void WriteVarInt(int v)
{
uint zig = (uint)((v << 1) ^ (v >> 31)); // zigzag
WriteVarUInt(zig);
}
public void WriteVarLong(long v)
{
ulong zig = (ulong)((v << 1) ^ (v >> 63));
while (zig >= 0x80)
{
_buf.Add((byte)(zig | 0x80));
zig >>= 7;
}
_buf.Add((byte)zig);
}
public void WriteSingle(float v)
{
byte[] b = BitConverter.GetBytes(v);
if (!BitConverter.IsLittleEndian) Array.Reverse(b);
_buf.Add(b[0]); _buf.Add(b[1]); _buf.Add(b[2]); _buf.Add(b[3]);
}
public void WriteBytes(byte[] data)
{
if (data == null || data.Length == 0)
{
WriteVarUInt(0);
return;
}
WriteVarUInt((uint)data.Length);
_buf.AddRange(data);
}
public void WriteString(string s)
{
if (string.IsNullOrEmpty(s))
{
WriteVarUInt(0);
return;
}
byte[] b = Encoding.UTF8.GetBytes(s);
WriteVarUInt((uint)b.Length);
_buf.AddRange(b);
}
public byte[] ToArray() => _buf.ToArray();
}
}
```
- [ ] **Step 4: 写 `PacketReader.cs`**
```csharp
using System;
using System.Text;
namespace XWorld.Framework.Protocol
{
// 与 PacketWriter 对偶;任何越界/畸形长度抛 FormatException(容错入口统一)
public sealed class PacketReader
{
private readonly byte[] _buf;
private int _pos;
public PacketReader(byte[] data)
{
_buf = data ?? Array.Empty();
_pos = 0;
}
public int Position => _pos;
public bool HasMore => _pos < _buf.Length;
public byte ReadByte()
{
if (_pos >= _buf.Length) throw new FormatException("PacketReader: read past end");
return _buf[_pos++];
}
public bool ReadBool() => ReadByte() != 0;
public ushort ReadUInt16()
{
byte lo = ReadByte();
byte hi = ReadByte();
return (ushort)(lo | (hi << 8));
}
public uint ReadVarUInt()
{
uint result = 0;
int shift = 0;
while (true)
{
if (shift > 28) throw new FormatException("PacketReader: varuint too long");
byte b = ReadByte();
// 第 5 字节(shift==28)只有低 4 位有效,超出即为越界 varuint
// 注意:C# 对 uint 的移位量按 0x1F 取模,必须在读第 6 字节前拦截
if (shift == 28 && (b & 0x70) != 0)
throw new FormatException("PacketReader: varuint overflow");
result |= (uint)(b & 0x7F) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
}
return result;
}
public int ReadVarInt()
{
uint zig = ReadVarUInt();
return (int)(zig >> 1) ^ -(int)(zig & 1); // zigzag decode
}
public long ReadVarLong()
{
ulong result = 0;
int shift = 0;
while (true)
{
if (shift > 63) throw new FormatException("PacketReader: varlong too long");
byte b = ReadByte();
// 第 10 字节(shift==63)只有最低 1 位有效;ulong 移位量按 0x3F 取模
if (shift == 63 && (b & 0x7E) != 0)
throw new FormatException("PacketReader: varlong overflow");
result |= (ulong)(b & 0x7F) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
}
return (long)(result >> 1) ^ -(long)(result & 1);
}
public float ReadSingle()
{
byte[] b = new byte[4];
b[0] = ReadByte(); b[1] = ReadByte(); b[2] = ReadByte(); b[3] = ReadByte();
if (!BitConverter.IsLittleEndian) Array.Reverse(b);
return BitConverter.ToSingle(b, 0);
}
public byte[] ReadBytes()
{
uint len = ReadVarUInt();
if (len == 0) return Array.Empty();
if (_pos + len > _buf.Length) throw new FormatException("PacketReader: bytes length exceeds buffer");
byte[] outv = new byte[len];
Array.Copy(_buf, _pos, outv, 0, (int)len);
_pos += (int)len;
return outv;
}
public string ReadString()
{
uint len = ReadVarUInt();
if (len == 0) return string.Empty;
if (_pos + len > _buf.Length) throw new FormatException("PacketReader: string length exceeds buffer");
string s = Encoding.UTF8.GetString(_buf, _pos, (int)len);
_pos += (int)len;
return s;
}
}
}
```
- [ ] **Step 5: 运行测试,确认全绿**
Run: `dotnet test Server/Server.sln`
Expected: PASS,全部 `PacketCodecTests` 通过。
- [ ] **Step 6: 提交**
```bash
git add "Client/Assets/Framework/Shared/Protocol/PacketWriter.cs" "Client/Assets/Framework/Shared/Protocol/PacketReader.cs" Server/Framework.Shared.Tests/PacketCodecTests.cs
git commit -m "feat(protocol): hand-rolled binary PacketWriter/PacketReader with varint/zigzag"
```
---
## Task 5: 帧信封 FrameCodec(TDD)
**Files:**
- Create: `Client/Assets/Framework/Shared/Protocol/FrameCodec.cs`
- Test: `Server/Framework.Shared.Tests/FrameCodecTests.cs`
帧线网格式:`[channel:u8][opcode:u16 小端][payloadLen:varuint][payload bytes]`,payload 自带长度前缀以便流式自定界(WebSocket 已有消息边界,但保留长度前缀让协议对传输无关)。
- [ ] **Step 1: 写失败测试 `FrameCodecTests.cs`**
```csharp
using System;
using Xunit;
using XWorld.Framework.Protocol;
namespace XWorld.Framework.Tests
{
public class FrameCodecTests
{
[Fact]
public void Frame_RoundTrips()
{
var original = new Frame(Channel.Game, 7, new byte[] { 10, 20, 30 });
byte[] wire = FrameCodec.Encode(original);
Frame decoded = FrameCodec.Decode(wire);
Assert.Equal(Channel.Game, decoded.Channel);
Assert.Equal((ushort)7, decoded.Opcode);
Assert.Equal(new byte[] { 10, 20, 30 }, decoded.Payload);
}
[Fact]
public void Frame_EmptyPayload_RoundTrips()
{
var original = new Frame(Channel.Framework, 1, null);
Frame decoded = FrameCodec.Decode(FrameCodec.Encode(original));
Assert.Equal(Channel.Framework, decoded.Channel);
Assert.Equal((ushort)1, decoded.Opcode);
Assert.Empty(decoded.Payload);
}
[Fact]
public void Encode_WireLayout_IsChannelOpcodeLenPayload()
{
var f = new Frame(Channel.Game, 0x0102, new byte[] { 0xAA });
byte[] wire = FrameCodec.Encode(f);
// channel=1, opcode 小端 = 02 01, len varuint = 1, payload = AA
Assert.Equal(new byte[] { 0x01, 0x02, 0x01, 0x01, 0xAA }, wire);
}
[Fact]
public void Decode_Truncated_Throws()
{
Assert.Throws(() => FrameCodec.Decode(new byte[] { 0x00 }));
}
}
}
```
- [ ] **Step 2: 运行,确认红**
Run: `dotnet test Server/Server.sln --filter FrameCodecTests`
Expected: 编译失败(`Frame`/`Channel`/`FrameCodec` 不存在)。
- [ ] **Step 3: 写 `FrameCodec.cs`**
```csharp
using System;
namespace XWorld.Framework.Protocol
{
public enum Channel : byte
{
Framework = 0, // 匹配/心跳/大厅等框架消息
Game = 1, // 转发给具体小游戏房间的业务消息
}
// 一帧线网消息
public readonly struct Frame
{
public readonly Channel Channel;
public readonly ushort Opcode;
public readonly byte[] Payload;
public Frame(Channel channel, ushort opcode, byte[] payload)
{
Channel = channel;
Opcode = opcode;
Payload = payload ?? Array.Empty();
}
}
public static class FrameCodec
{
// 线网: [channel:u8][opcode:u16 LE][payloadLen:varuint][payload]
public static byte[] Encode(Frame frame)
{
var w = new PacketWriter(frame.Payload.Length + 8);
w.WriteByte((byte)frame.Channel);
w.WriteUInt16(frame.Opcode);
w.WriteBytes(frame.Payload); // 自带 varuint 长度前缀
return w.ToArray();
}
public static Frame Decode(byte[] data)
{
var r = new PacketReader(data);
var channel = (Channel)r.ReadByte();
ushort opcode = r.ReadUInt16();
byte[] payload = r.ReadBytes();
return new Frame(channel, opcode, payload);
}
}
}
```
- [ ] **Step 4: 运行,确认绿**
Run: `dotnet test Server/Server.sln --filter FrameCodecTests`
Expected: PASS。
- [ ] **Step 5: 提交**
```bash
git add "Client/Assets/Framework/Shared/Protocol/FrameCodec.cs" Server/Framework.Shared.Tests/FrameCodecTests.cs
git commit -m "feat(protocol): Frame envelope + FrameCodec with framework/game channels"
```
---
## Task 6: 确定性随机 DeterministicRandom(TDD)
**Files:**
- Create: `Client/Assets/Framework/Shared/DeterministicRandom.cs`
- Test: `Server/Framework.Shared.Tests/DeterministicRandomTests.cs`
对应设计 §4.4:权威随机由种子驱动,两端用同一算法复现。
- [ ] **Step 1: 写失败测试 `DeterministicRandomTests.cs`**
```csharp
using System;
using Xunit;
using XWorld.Framework;
namespace XWorld.Framework.Tests
{
public class DeterministicRandomTests
{
[Fact]
public void SameSeed_ProducesIdenticalSequence()
{
var a = new DeterministicRandom(12345UL);
var b = new DeterministicRandom(12345UL);
for (int i = 0; i < 1000; i++)
Assert.Equal(a.Next(100), b.Next(100));
}
[Fact]
public void DifferentSeed_DivergesQuickly()
{
var a = new DeterministicRandom(1UL);
var b = new DeterministicRandom(2UL);
bool anyDiff = false;
for (int i = 0; i < 20; i++)
if (a.Next(1_000_000) != b.Next(1_000_000)) { anyDiff = true; break; }
Assert.True(anyDiff);
}
[Fact]
public void Next_StaysInRange()
{
var rng = new DeterministicRandom(999UL);
for (int i = 0; i < 100_000; i++)
{
int v = rng.Next(7);
Assert.InRange(v, 0, 6);
}
}
[Fact]
public void Next_One_AlwaysZero()
{
var rng = new DeterministicRandom(42UL);
for (int i = 0; i < 100; i++)
Assert.Equal(0, rng.Next(1));
}
[Theory]
[InlineData(0)]
[InlineData(-3)]
public void Next_NonPositiveMax_Throws(int max)
{
var rng = new DeterministicRandom(42UL);
Assert.Throws(() => rng.Next(max));
}
[Fact]
public void State_SnapshotAndResume_ContinuesIdentically()
{
var rng = new DeterministicRandom(7UL);
for (int i = 0; i < 10; i++) rng.Next(1000);
ulong snapshot = rng.State;
var resumed = new DeterministicRandom(snapshot);
// 注意:State 是当前内部状态,用它重建后序列从“下一步”继续
int[] fromOriginal = new int[5];
int[] fromResumed = new int[5];
for (int i = 0; i < 5; i++) fromOriginal[i] = rng.Next(1000);
for (int i = 0; i < 5; i++) fromResumed[i] = resumed.Next(1000);
Assert.Equal(fromOriginal, fromResumed);
}
}
}
```
- [ ] **Step 2: 运行,确认红**
Run: `dotnet test Server/Server.sln --filter DeterministicRandomTests`
Expected: 编译失败(`DeterministicRandom` 不存在)。
- [ ] **Step 3: 写 `DeterministicRandom.cs`**
```csharp
using System;
namespace XWorld.Framework
{
// SplitMix64:跨平台确定性、无浮点、状态即种子(便于快照/复盘)
public sealed class DeterministicRandom : IRandom
{
private ulong _state;
public DeterministicRandom(ulong seed)
{
_state = seed;
}
// 当前内部状态;用它构造新实例可从“下一步”继续同一序列
public ulong State => _state;
private ulong NextU64()
{
_state += 0x9E3779B97F4A7C15UL;
ulong z = _state;
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
return z ^ (z >> 31);
}
public int Next(int maxExclusive)
{
if (maxExclusive <= 0)
throw new ArgumentOutOfRangeException(nameof(maxExclusive), "maxExclusive 必须为正");
ulong bound = (ulong)maxExclusive;
// 拒绝采样,消除取模偏差
ulong threshold = (ulong.MaxValue - bound + 1) % bound;
while (true)
{
ulong r = NextU64();
if (r >= threshold) return (int)(r % bound);
}
}
}
}
```
- [ ] **Step 4: 运行,确认绿**
Run: `dotnet test Server/Server.sln --filter DeterministicRandomTests`
Expected: PASS。
- [ ] **Step 5: 提交**
```bash
git add "Client/Assets/Framework/Shared/DeterministicRandom.cs" Server/Framework.Shared.Tests/DeterministicRandomTests.cs
git commit -m "feat(framework): deterministic SplitMix64 IRandom with snapshottable state"
```
---
## Task 7: 框架控制消息 DTO 与 opcode(TDD)
**Files:**
- Create: `Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs`
- Test: `Server/Framework.Shared.Tests/FrameworkMessagesTests.cs`
覆盖设计 §3.3 对局流程所需的框架级消息:心跳 / 匹配请求 / 匹配成功 / 玩家输入封装 / 状态快照 / 房间结束 / 错误。
- [ ] **Step 1: 写失败测试 `FrameworkMessagesTests.cs`**
```csharp
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace XWorld.Framework.Tests
{
public class FrameworkMessagesTests
{
[Fact]
public void Heartbeat_RoundTrips()
{
var m = new HeartbeatMsg { ClientTimeMs = 1_725_000_000_123L };
var d = HeartbeatMsg.Decode(m.Encode());
Assert.Equal(m.ClientTimeMs, d.ClientTimeMs);
}
[Fact]
public void MatchRequest_RoundTrips()
{
var m = new MatchRequestMsg { GameId = "rock_paper_scissors", Version = 3 };
var d = MatchRequestMsg.Decode(m.Encode());
Assert.Equal(m.GameId, d.GameId);
Assert.Equal(m.Version, d.Version);
}
[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 },
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
},
};
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(-1, d.Players[1].PlayerId);
Assert.True(d.Players[1].IsAI);
}
[Fact]
public void GameInput_RoundTrips()
{
var m = new GameInputMsg { GameOpcode = 12, Payload = new byte[] { 9, 8, 7 } };
var d = GameInputMsg.Decode(m.Encode());
Assert.Equal((ushort)12, d.GameOpcode);
Assert.Equal(new byte[] { 9, 8, 7 }, d.Payload);
}
[Fact]
public void Snapshot_RoundTrips()
{
var m = new SnapshotMsg { Tick = 250, StateBlob = new byte[] { 0xDE, 0xAD } };
var d = SnapshotMsg.Decode(m.Encode());
Assert.Equal(250, d.Tick);
Assert.Equal(new byte[] { 0xDE, 0xAD }, d.StateBlob);
}
[Fact]
public void RoomEnd_RoundTrips()
{
var m = new RoomEndMsg { WinnerPlayerId = 1001, ResultBlob = new byte[] { 1 } };
var d = RoomEndMsg.Decode(m.Encode());
Assert.Equal(1001, d.WinnerPlayerId);
Assert.Equal(new byte[] { 1 }, d.ResultBlob);
}
[Fact]
public void Error_RoundTrips()
{
var m = new ErrorMsg { Code = 404, Message = "version mismatch" };
var d = ErrorMsg.Decode(m.Encode());
Assert.Equal(404, d.Code);
Assert.Equal("version mismatch", d.Message);
}
[Fact]
public void Opcodes_AreStableValues()
{
// 线网兼容性:opcode 数值固定,改动等于破坏协议
Assert.Equal((ushort)1, (ushort)FrameworkOpcode.Heartbeat);
Assert.Equal((ushort)2, (ushort)FrameworkOpcode.MatchRequest);
Assert.Equal((ushort)3, (ushort)FrameworkOpcode.MatchFound);
Assert.Equal((ushort)4, (ushort)FrameworkOpcode.GameInput);
Assert.Equal((ushort)5, (ushort)FrameworkOpcode.Snapshot);
Assert.Equal((ushort)6, (ushort)FrameworkOpcode.RoomEnd);
Assert.Equal((ushort)7, (ushort)FrameworkOpcode.Error);
}
[Fact]
public void Message_TravelsInsideFrame()
{
// 端到端:DTO -> payload -> Frame -> wire -> Frame -> DTO
var req = new MatchRequestMsg { GameId = "rps", Version = 1 };
var frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest, req.Encode());
byte[] wire = FrameCodec.Encode(frame);
Frame back = FrameCodec.Decode(wire);
Assert.Equal(Channel.Framework, back.Channel);
Assert.Equal((ushort)FrameworkOpcode.MatchRequest, back.Opcode);
var d = MatchRequestMsg.Decode(back.Payload);
Assert.Equal("rps", d.GameId);
Assert.Equal(1, d.Version);
}
// —— null/空边界:记录并验证「null 与空线网等价」的有意行为 ——
[Fact]
public void GameInput_NullPayload_RoundTrips_AsEmpty()
{
var m = new GameInputMsg { GameOpcode = 5, Payload = null };
var d = GameInputMsg.Decode(m.Encode());
Assert.Equal((ushort)5, d.GameOpcode);
Assert.NotNull(d.Payload);
Assert.Empty(d.Payload);
}
[Fact]
public void Error_NullMessage_RoundTrips_AsEmpty()
{
var m = new ErrorMsg { Code = 7, Message = null };
var d = ErrorMsg.Decode(m.Encode());
Assert.Equal(7, d.Code);
Assert.Equal(string.Empty, d.Message);
}
[Fact]
public void MatchFound_EmptyPlayerList_RoundTrips()
{
var m = new MatchFoundMsg { RoomId = "r0", Version = 1, SelfPlayerId = 0 };
// Players 默认空列表,不添加任何玩家
var d = MatchFoundMsg.Decode(m.Encode());
Assert.Equal("r0", d.RoomId);
Assert.Equal(1, d.Version);
Assert.Equal(0, d.SelfPlayerId);
Assert.NotNull(d.Players);
Assert.Empty(d.Players);
}
}
}
```
> 注:DTO 中的不透明 `byte[]`(Payload/StateBlob/ResultBlob)与字符串(GameId/RoomId/Message)字段,null 在线网上等价于空值,Decode 恒返回非 null(空数组/空串)。`FrameworkMessages.cs` 对应字段已加单行注释说明。
- [ ] **Step 2: 运行,确认红**
Run: `dotnet test Server/Server.sln --filter FrameworkMessagesTests`
Expected: 编译失败(消息类型与 `FrameworkOpcode` 不存在)。
- [ ] **Step 3: 写 `FrameworkMessages.cs`**
```csharp
using System.Collections.Generic;
using XWorld.Framework;
namespace XWorld.Framework.Protocol
{
// 框架通道(Channel.Framework)的 opcode;数值是线网契约,禁止随意改动
public enum FrameworkOpcode : ushort
{
Heartbeat = 1,
MatchRequest = 2,
MatchFound = 3,
GameInput = 4,
Snapshot = 5,
RoomEnd = 6,
Error = 7,
}
public sealed class HeartbeatMsg
{
public long ClientTimeMs;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteVarLong(ClientTimeMs);
return w.ToArray();
}
public static HeartbeatMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new HeartbeatMsg { ClientTimeMs = r.ReadVarLong() };
}
}
public sealed class MatchRequestMsg
{
public string GameId;
public int Version;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteString(GameId);
w.WriteVarInt(Version);
return w.ToArray();
}
public static MatchRequestMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new MatchRequestMsg { GameId = r.ReadString(), Version = r.ReadVarInt() };
}
}
public sealed class MatchFoundMsg
{
public string RoomId;
public int Version;
public int SelfPlayerId;
public List Players = new List();
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteString(RoomId);
w.WriteVarInt(Version);
w.WriteVarInt(SelfPlayerId);
w.WriteVarUInt((uint)Players.Count);
foreach (var p in Players)
{
w.WriteVarInt(p.PlayerId);
w.WriteString(p.Name);
w.WriteBool(p.IsAI);
}
return w.ToArray();
}
public static MatchFoundMsg Decode(byte[] data)
{
var r = new PacketReader(data);
var m = new MatchFoundMsg
{
RoomId = r.ReadString(),
Version = r.ReadVarInt(),
SelfPlayerId = r.ReadVarInt(),
};
uint n = r.ReadVarUInt();
for (uint i = 0; i < n; i++)
{
m.Players.Add(new PlayerInfo
{
PlayerId = r.ReadVarInt(),
Name = r.ReadString(),
IsAI = r.ReadBool(),
});
}
return m;
}
}
// 把一条小游戏业务输入封进框架通道(也可直接走 Channel.Game,二者择一,详见服务端子项目)
public sealed class GameInputMsg
{
public ushort GameOpcode;
public byte[] Payload;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteUInt16(GameOpcode);
w.WriteBytes(Payload);
return w.ToArray();
}
public static GameInputMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new GameInputMsg { GameOpcode = r.ReadUInt16(), Payload = r.ReadBytes() };
}
}
public sealed class SnapshotMsg
{
public int Tick;
public byte[] StateBlob; // 由小游戏 Core.Encode(state) 产出,框架不解释内容
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteVarInt(Tick);
w.WriteBytes(StateBlob);
return w.ToArray();
}
public static SnapshotMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new SnapshotMsg { Tick = r.ReadVarInt(), StateBlob = r.ReadBytes() };
}
}
public sealed class RoomEndMsg
{
public int WinnerPlayerId; // 0 表示平局/无胜者
public byte[] ResultBlob; // 小游戏自定义结算数据
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteVarInt(WinnerPlayerId);
w.WriteBytes(ResultBlob);
return w.ToArray();
}
public static RoomEndMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new RoomEndMsg { WinnerPlayerId = r.ReadVarInt(), ResultBlob = r.ReadBytes() };
}
}
public sealed class ErrorMsg
{
public int Code;
public string Message;
public byte[] Encode()
{
var w = new PacketWriter();
w.WriteVarInt(Code);
w.WriteString(Message);
return w.ToArray();
}
public static ErrorMsg Decode(byte[] data)
{
var r = new PacketReader(data);
return new ErrorMsg { Code = r.ReadVarInt(), Message = r.ReadString() };
}
}
}
```
- [ ] **Step 4: 运行全量测试,确认绿**
Run: `dotnet test Server/Server.sln`
Expected: 全部测试 PASS(四个测试类)。
- [ ] **Step 5: 提交**
```bash
git add "Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs" Server/Framework.Shared.Tests/FrameworkMessagesTests.cs
git commit -m "feat(protocol): framework control messages (match/heartbeat/snapshot/roomend/error) with stable opcodes"
```
---
## Task 8: Unity 程序集集成与跨端编译核对
**Files:**
- Create: `Client/Assets/Framework/Shared/Framework.Shared.asmdef`
- Verify: 共享层在 Unity 与 .NET **两端都能编**。
- [ ] **Step 1: 写 `Framework.Shared.asmdef`**
```json
{
"name": "XWorld.Framework.Shared",
"rootNamespace": "XWorld.Framework",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
```
> `noEngineReferences: true` 在 Unity 编译期强制本程序集不引用 `UnityEngine`/`UnityEditor`,正是设计 §2.1「Core 绝不引用 UnityEngine」的机器级护栏。**不要**把本程序集加入 `HybridCLRSettings.asset` 的 `hotUpdateAssemblies` —— 框架接口常驻、不热更(§4.2)。
- [ ] **Step 2: .NET 侧再跑一次全量测试(确保 asmdef 的加入没破坏链接 glob)**
Run: `dotnet test Server/Server.sln`
Expected: 全绿(asmdef 是 `.asmdef`,不在 `**\*.cs` glob 内,应无影响)。
- [ ] **Step 3: Unity 侧编译核对(人工检查点)**
打开 Unity 工程 `Client/`(菜单或命令行均可),等待编译:
- 在 Unity 终端可执行(路径按本机 Unity 安装调整):
`"/Unity" -batchmode -quit -projectPath "D:/UD/AI/AIC#Project/Client" -logFile -`
- 期望:日志无编译错误;`Library/ScriptAssemblies/XWorld.Framework.Shared.dll` 生成。
- 若报 `UnityEngine` 相关错误,说明某文件误用了平台 API(应为 0,源码已全部纯 C#)。
> 此步无法在本机自动化(需 Unity Editor)。若当前环境无 Unity,则**标注为待人工核对**并继续;.NET 侧 netstandard2.1 编译通过已能高置信度保证 Unity 也能编(API 面同为 netstandard2.1,语言级别同为 C# 9)。
- [ ] **Step 4: 提交**
```bash
git add "Client/Assets/Framework/Shared/Framework.Shared.asmdef"
git commit -m "feat(framework): Unity asmdef for shared layer (noEngineReferences, resident/non-hotupdate)"
```
---
## Task 9: 更新过时规则文档(收尾)
**Files:**
- Modify: `Doc/Rule/UnityProject.md`(若存在「小游戏用 Lua」条目)
对应设计 §10 风险清单:现有「小游戏逻辑用 Lua、不用 C#」规则需更新为 C# 热更路线。
- [ ] **Step 1: 查找过时规则**
Run: `grep -n -i "lua" "D:/UD/AI/AIC#Project/Doc/Rule/UnityProject.md"`
Expected: 列出涉及「小游戏用 Lua」的行。若文件不存在或无相关条目,跳过本任务并在提交说明里注明。
- [ ] **Step 2: 在该规则处追加更正说明**
在相关条目下方插入(保留原文,追加批注,便于追溯):
```markdown
> ⚠️ 更新(2026-06-18):小游戏逻辑已切换为 **C# 热更**(客户端 HybridCLR + 服务端 ALC),
> 弃用 Lua 小游戏路线。详见架构设计
> `docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md`
> 与共享层实现 `Client/Assets/Framework/Shared/`。
```
- [ ] **Step 3: 提交**
```bash
git add "Doc/Rule/UnityProject.md"
git commit -m "docs(rule): mark Lua-minigame rule superseded by C# hot-update path"
```
---
## 完成判据(Definition of Done)
- [ ] `dotnet test Server/Server.sln` 全绿(四个测试类:Packet/Frame/Random/FrameworkMessages 往返与边界全部通过)。
- [ ] `dotnet build Server/Server.sln` 0 error/0 warning。
- [ ] 规范源码仅一份(`Client/Assets/Framework/Shared/`),.NET 侧靠 `` 链接,无任何拷贝(A1 满足)。
- [ ] 共享层零 `UnityEngine`/`System.IO`/socket 依赖(asmdef `noEngineReferences` + netstandard2.1 双重保证)。
- [ ] 本程序集未进入 HybridCLR `hotUpdateAssemblies`(常驻不热更)。
- [ ] §5 全部接口(IGameLogic / IRandom / ITimer / ILogger / IStorage / IGameClient / IGameClientCtx / IGameServerRoom / IRoomCtx)与基础类型(RoomConfig / PlayerInfo / NetMessage / StepResult)均已落地。
- [ ] 框架协议编解码(PacketWriter/Reader + Frame + 7 个控制消息 + 稳定 opcode)已落地且 DTO↔Frame↔wire 端到端往返通过。
- [ ] Unity 侧编译核对:通过,或在无 Unity 环境下明确标注为待人工核对。
## 不在本子项目范围(交给后续 §9 步骤)
- 服务端宿主(Kestrel WS 网关 / Session / 匹配 / RoomHost / ALC 加载器)—— §9 第 2 步。
- 客户端宿主(扩展 LoadDll 的小游戏 manifest 维度 / HybridCLR 加载小游戏 DLL / 生命周期接管)—— §9 第 3 步。
- 构建/发布工具链(双编译 + manifest + 签名 + 上传)—— §9 第 4 步。
- 用 C# 重写 RockPaperScissors —— §9 第 5 步(届时基于本层的 `IGameLogic` 写 RPS.Core)。
- 任何 `game.json` 解析、签名校验、CDN/版本库 —— 后续子项目。