Files
AIC-Project/docs/superpowers/plans/2026-06-18-framework-shared-layer.md
T
2026-07-10 10:24:29 +08:00

1480 lines
50 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.
# 框架接口与协议层(共享层)实现计划
> **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 工程通过 `<Compile Include="..\..\Client\Assets\Framework\Shared\**\*.cs" />` **链接同一批 .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<TState,TInput,TEvent>` |
| `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` 类库,`<Compile Include>` 链接规范源码 |
| `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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Framework.Shared</AssemblyName>
<RootNamespace>XWorld.Framework</RootNamespace>
<!-- 不编译本目录下的 .cs,规范源码全部来自 Client/Assets/Framework/Shared -->
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\Client\Assets\Framework\Shared\**\*.cs" />
</ItemGroup>
</Project>
```
- [ ] **Step 3: 让测试工程引用库**
`Server/Framework.Shared.Tests/Framework.Shared.Tests.csproj``</Project>` 之前加入(模板已含 xUnit 包引用,只补 ProjectReference):
```xml
<ItemGroup>
<ProjectReference Include="..\Framework.Shared\Framework.Shared.csproj" />
</ItemGroup>
```
- [ ] **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<string, string> 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<byte>();
}
}
// Core.Step 的纯函数式返回:新状态 + 本步事件
public readonly struct StepResult<TState, TEvent>
{
public readonly TState State;
public readonly IReadOnlyList<TEvent> Events;
public StepResult(TState state, IReadOnlyList<TEvent> events)
{
State = state;
Events = events ?? Array.Empty<TEvent>();
}
}
}
```
- [ ] **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, TInput, TEvent>
{
TState CreateInitial(RoomConfig config, IRandom random);
StepResult<TState, TEvent> 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<PlayerInfo> 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 / PacketReaderTDD 核心)
**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<byte>());
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<byte>());
Assert.Throws<FormatException>(() => r.ReadByte());
}
[Fact]
public void ReadBytes_WithBogusLength_Throws()
{
// 长度前缀声称 10 字节,实际无数据
var w = new PacketWriter();
w.WriteVarUInt(10);
var r = new PacketReader(w.ToArray());
Assert.Throws<FormatException>(() => 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<FormatException>(() => 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<FormatException>(() => 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<FormatException>(() => 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<FormatException>(() => 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<byte> _buf;
public PacketWriter(int capacity = 64)
{
_buf = new List<byte>(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<byte>();
_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<byte>();
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: 帧信封 FrameCodecTDD
**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<FormatException>(() => 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<byte>();
}
}
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: 确定性随机 DeterministicRandomTDD
**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<ArgumentOutOfRangeException>(() => 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 与 opcodeTDD
**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<PlayerInfo> Players = new List<PlayerInfo>();
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 安装调整):
`"<UnityEditor路径>/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 侧靠 `<Compile Include>` 链接,无任何拷贝(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/版本库 —— 后续子项目。