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

50 KiB
Raw Blame History

框架接口与协议层(共享层)实现计划

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.1API 面对齐 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.LinkClient/ProjectSettings/HybridCLRSettings.assethotUpdateAssemblies)。本共享层不加入该列表(常驻不热更)。
  • Server/ 目录目前为空,本计划在其中新建首批 .NET 工程。
  • RPS 现为 LuaClient/Assets/RockPaperScissors/script/),本步骤不改动它;只为后续 §9 第 5 步用 C# 重写打地基。
  • 已确认 dotnet 可用:10.0.201SDK 9 与 10 均在)。

命名与依赖约定(贯穿全计划,务必一致)

  • 命名空间:SDK 契约与基础类型 → XWorld.Framework;协议编解码 → XWorld.Framework.Protocol
  • 程序集名:XWorld.Framework.SharedUnity asmdef 名与 .NET AssemblyName 一致)。
  • 共享库只允许 netstandard2.1 API + C# 9 语法。禁止:UnityEngineSystem.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/FrameCodecFramework 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 执行:

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 为「链接规范源码」

完整替换文件内容:

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

  <ItemGroup>
    <ProjectReference Include="..\Framework.Shared\Framework.Shared.csproj" />
  </ItemGroup>
  • Step 4: 放一个临时探针源码文件,验证链接链路通

创建 Client/Assets/Framework/Shared/_Probe.cs

namespace XWorld.Framework
{
    internal static class _Probe
    {
        public const int Value = 42;
    }
}
  • Step 5: 还原 + 构建,确认两端工程都能编

Run: dotnet build Server/Server.sln -v minimal Expected: Build succeeded0 error。若报 netstandard2.1 目标包缺失,先确认 SDK 自带(10.x 自带 netstandard2.1 参考包,正常无需额外安装)。

  • Step 6: 删除探针并提交骨架
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
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
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 succeeded0 error/warning。

  • Step 4: 提交
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.csCore 纯逻辑契约)

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(客户端薄层入口)
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(服务端薄层入口)
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 succeeded0 error。

  • Step 5: 提交
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

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
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
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: 提交
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
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
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: 提交
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
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
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: 提交
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
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
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: 提交
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

{
    "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.assethotUpdateAssemblies —— 框架接口常驻、不热更(§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: 提交
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: 在该规则处追加更正说明

在相关条目下方插入(保留原文,追加批注,便于追溯):

> ⚠️ 更新(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: 提交
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/版本库 —— 后续子项目。