Files
AIC-Project/docs/superpowers/plans/2026-06-18-server-roomhost-alc.md
2026-07-10 10:24:29 +08:00

62 KiB
Raw Permalink Blame History

服务端房间宿主 + 可卸载 ALC 加载器(子项目 2a)实现计划

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: 实现服务端运行时的「热更核心」——用可卸载 AssemblyLoadContext(gameId,version) 从本地 games/<id>/<ver>/ 动态加载小游戏服务端 DLL,驱动房间的权威 Tick 循环,并在版本淘汰且无引用时干净卸载(版本共存 + 内存回收),用一个 Hello 游戏跑通集成验证。本子项目不含网络

Architecture: 新增 .NET 8+ 宿主类库 XWorld.Server.Host(net10.0),引用子项目 1 的 Framework.Sharednetstandard2.1,常驻默认上下文)。每个小游戏版本载入一个 collectible ALCALC 的 LoadXWorld.Framework.Shared 返回 null(交默认上下文解析,跨界类型一致),只把小游戏私有 DLL(Hello.Core.dll/Hello.Server.dll)载入本上下文。RoomHostGameModuleRegistry 引用计数地获取/释放模块,房间结束、旧版本被新版本淘汰且引用归零时触发 ALC.Unload()。Tick 用手动时钟驱动,集成测试确定性地验证「加载→并存→卸载→内存回收」。

Tech Stack: C# / net10.0 宿主 · System.Runtime.Loader.AssemblyLoadContextcollectible)· System.Text.Json 解析 game.json · xUnit · Hello 测试夹具游戏(netstandard2.1,编译期 HELLO_V2 注入不同计数基值以产出真实不同 IL)。


背景与约定(务必遵守)

  • 上游设计:docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md,本计划落地其 §9 第 2 步的「RoomHost + ALC 加载器(先加载一个 hello 房间验证热更/卸载闭环)」部分,并重点验证 §10 风险清单的「ALC 卸载内存泄漏边界」。
  • 子项目 1 已完成:Server/Framework.Shared/Framework.Shared.csproj(程序集名 XWorld.Framework.Sharednetstandard2.1),可用契约:IGameServerRoomIRoomCtxIRandom/ITimer/ILogger/IStorageIGameLogic<,,>RoomConfigPlayerInfoNetMessageStepResult<,>DeterministicRandomProtocol.PacketWriter/PacketReader。命名空间 XWorld.FrameworkXWorld.Framework.Protocol
  • 无 git:本工程未用版本控制。跳过所有 git 步骤,其余照常。
  • 工作目录 D:/UD/AI/AIC#Projectbash on Windows(正斜杠、/dev/null)。dotnet SDK 10.0.201。
  • 网络(Kestrel WS 网关 / Session / 匹配 / 路由)属于子项目 2b,本计划不做。

命名与依赖

  • 宿主命名空间 XWorld.Server.Host,程序集名同名,TFM net10.0,引用 Framework.Shared
  • Hello 夹具游戏:Hello.Corenetstandard2.1)、Hello.Servernetstandard2.1),不进 Server.sln,只由脚本构建到 games 夹具目录;宿主/测试绝不项目引用它们(必须动态加载)。
  • ALC 铁律collectible ALC 的 Load(AssemblyName)XWorld.Framework.Shared 返回 null(默认上下文解析);小游戏私有 DLL 从版本目录 LoadFromAssemblyPath 进本上下文。小游戏 DLL 不得静态持有跨房间/跨版本全局状态(阻碍卸载)。

文件结构

宿主库 Server/Server.Host/XWorld.Server.Host):

文件 职责
Server.Host.csproj net10.0 类库,引用 Framework.Shared
GameManifest.cs game.json POCO + Parse/Load/校验
Capabilities/ConsoleLogger.cs ILogger 控制台实现
Capabilities/InMemoryStorage.cs IStorage 内存实现(本阶段免 DB
Capabilities/ManualClock.cs ITimer 手动时钟(测试可推进)
IRoomOutput.cs 房间出站消息汇(本阶段替代网络,捕获 Broadcast/SendTo
RoomCtx.cs IRoomCtx 实现:注入随机/时钟/日志/存储 + 出站 + EndRoom 回调
Room.cs 单房间生命周期 + 异常隔离(故障安全结束,不拖垮宿主)
GameModuleAlc.cs collectible AssemblyLoadContext(框架走默认、私有走本地)
LoadedGameModule.cs 已加载模块句柄:工厂 Func<IGameServerRoom> + Unload()→弱引用
GameModuleLoader.cs (gamesRoot,gameId,version) 读 manifest、建 ALC、反射入口、产工厂
GameModuleRegistry.cs (gameId,version)→模块;引用计数、版本淘汰、空闲卸载
RoomHost.cs 建房(取模块→建房间→Start)、TickAll、投递、结束→释放

Hello 夹具游戏 Server/games-src/Hello/(不入 sln):

文件 职责
Hello.Core/Hello.Core.csproj netstandard2.1,引用 Framework.SharedPrivate=false
Hello.Core/HelloState.cs 状态:Tick + Counter
Hello.Core/HelloLogic.cs IGameLogic<HelloState,int,string>,起始计数 v1=0 / v2=100#if HELLO_V2
Hello.Server/Hello.Server.csproj netstandard2.1,引用 Framework.SharedPrivate=false+ Hello.Core
Hello.Server/HelloServerRoom.cs IGameServerRoom:每 tick 推进并广播快照,第 3 tick 自结束

构建/测试:

文件 职责
Server/build-test-games.sh 构建 Hello v1/v2 到 Server/Server.Host.Tests/TestGames/hello/{1,2}/
Server/Server.Host.Tests/Server.Host.Tests.csproj net10.0 + xUnit,引用 Host,拷贝 TestGames/** 到输出
Server/Server.Host.Tests/Support/CapturingRoomOutput.cs 测试用出站捕获
Server/Server.Host.Tests/Support/TestGames.cs 定位输出目录里的 TestGames 根
Server/Server.Host.Tests/*Tests.cs 见各任务

Task 1: 搭建宿主库与测试工程

Files:

  • Create: Server/Server.Host/Server.Host.csproj

  • Create: Server/Server.Host.Tests/Server.Host.Tests.csproj

  • Modify: Server/Server.sln

  • Step 1: 生成工程并并入解决方案

D:/UD/AI/AIC#Project 执行:

dotnet new classlib -n Server.Host -o Server/Server.Host -f net10.0
dotnet new xunit    -n Server.Host.Tests -o Server/Server.Host.Tests
rm -f Server/Server.Host/Class1.cs Server/Server.Host.Tests/UnitTest1.cs
dotnet sln Server/Server.sln add Server/Server.Host/Server.Host.csproj
dotnet sln Server/Server.sln add Server/Server.Host.Tests/Server.Host.Tests.csproj
mkdir -p Server/Server.Host/Capabilities Server/Server.Host.Tests/Support
  • Step 2: 写 Server/Server.Host/Server.Host.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>disable</Nullable>
    <ImplicitUsings>disable</ImplicitUsings>
    <AssemblyName>XWorld.Server.Host</AssemblyName>
    <RootNamespace>XWorld.Server.Host</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\Framework.Shared\Framework.Shared.csproj" />
  </ItemGroup>

</Project>
  • Step 3: 让测试工程引用宿主

Server/Server.Host.Tests/Server.Host.Tests.csproj</Project> 前加入:

  <ItemGroup>
    <ProjectReference Include="..\Server.Host\Server.Host.csproj" />
  </ItemGroup>
  • Step 4: 构建确认

Run: dotnet build Server/Server.sln -v minimal Expected: Build succeeded,0 error。(此时无业务代码,测试工程仅含默认空测试已删,应能编过。)


Task 2: game.json 清单解析 GameManifestTDD

Files:

  • Create: Server/Server.Host/GameManifest.cs

  • Test: Server/Server.Host.Tests/GameManifestTests.cs

  • Step 1: 写失败测试 GameManifestTests.cs

using System.IO;
using Xunit;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class GameManifestTests
    {
        private const string Valid = @"{
            ""gameId"": ""hello"",
            ""version"": 2,
            ""serverAssembly"": ""Hello.Server.dll"",
            ""serverEntryType"": ""Hello.Server.HelloServerRoom"",
            ""minFrameworkVersion"": 1,
            ""playerCount"": 1,
            ""tickRateHz"": 10
        }";

        [Fact]
        public void Parse_Valid_ReadsAllFields()
        {
            var m = GameManifest.Parse(Valid);
            Assert.Equal("hello", m.GameId);
            Assert.Equal(2, m.Version);
            Assert.Equal("Hello.Server.dll", m.ServerAssembly);
            Assert.Equal("Hello.Server.HelloServerRoom", m.ServerEntryType);
            Assert.Equal(1, m.MinFrameworkVersion);
            Assert.Equal(1, m.PlayerCount);
            Assert.Equal(10, m.TickRateHz);
        }

        [Fact]
        public void Parse_CaseInsensitive_FieldNames()
        {
            var m = GameManifest.Parse(@"{""GameId"":""x"",""Version"":1,""ServerAssembly"":""X.dll"",""ServerEntryType"":""X.Y""}");
            Assert.Equal("x", m.GameId);
            Assert.Equal(1, m.Version);
        }

        [Theory]
        [InlineData(@"{""version"":1,""serverEntryType"":""a"",""serverAssembly"":""a.dll""}")] // 缺 gameId
        [InlineData(@"{""gameId"":""x"",""version"":1,""serverAssembly"":""a.dll""}")]            // 缺 entryType
        [InlineData(@"{""gameId"":""x"",""version"":1,""serverEntryType"":""a""}")]                // 缺 serverAssembly
        [InlineData(@"{""gameId"":""x"",""version"":0,""serverEntryType"":""a"",""serverAssembly"":""a.dll""}")] // version 非正
        public void Parse_Invalid_Throws(string json)
        {
            Assert.Throws<InvalidDataException>(() => GameManifest.Parse(json));
        }
    }
}
  • Step 2: 运行,确认红

Run: dotnet test Server/Server.sln --filter GameManifestTests Expected: 编译失败(GameManifest 不存在)。

  • Step 3: 写 GameManifest.cs
using System.IO;
using System.Text.Json;

namespace XWorld.Server.Host
{
    public sealed class GameManifest
    {
        public string GameId { get; set; }
        public int Version { get; set; }
        public string ServerAssembly { get; set; }   // 例如 "Hello.Server.dll"
        public string ServerEntryType { get; set; }  // 例如 "Hello.Server.HelloServerRoom"
        public int MinFrameworkVersion { get; set; }
        public int PlayerCount { get; set; }
        public int TickRateHz { get; set; }

        public static GameManifest Parse(string json)
        {
            var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
            GameManifest m;
            try { m = JsonSerializer.Deserialize<GameManifest>(json, opts); }
            catch (JsonException ex) { throw new InvalidDataException("game.json: 无法解析", ex); }

            if (m == null) throw new InvalidDataException("game.json: 内容为空");
            if (string.IsNullOrEmpty(m.GameId)) throw new InvalidDataException("game.json: 缺少 gameId");
            if (string.IsNullOrEmpty(m.ServerEntryType)) throw new InvalidDataException("game.json: 缺少 serverEntryType");
            if (string.IsNullOrEmpty(m.ServerAssembly)) throw new InvalidDataException("game.json: 缺少 serverAssembly");
            if (m.Version <= 0) throw new InvalidDataException("game.json: version 必须为正");
            return m;
        }

        public static GameManifest Load(string path) => Parse(File.ReadAllText(path));
    }
}
  • Step 4: 运行,确认绿

Run: dotnet test Server/Server.sln --filter GameManifestTests Expected: PASS。


Task 3: 能力实现与房间上下文(TDD)

Files:

  • Create: Server/Server.Host/Capabilities/ConsoleLogger.cs

  • Create: Server/Server.Host/Capabilities/InMemoryStorage.cs

  • Create: Server/Server.Host/Capabilities/ManualClock.cs

  • Create: Server/Server.Host/IRoomOutput.cs

  • Create: Server/Server.Host/RoomCtx.cs

  • Test: Server/Server.Host.Tests/RoomCtxTests.cs

  • Step 1: 写失败测试 RoomCtxTests.cs

using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class RoomCtxTests
    {
        private sealed class FakeOutput : IRoomOutput
        {
            public List<NetMessage> Broadcasts = new List<NetMessage>();
            public List<(int, NetMessage)> Sends = new List<(int, NetMessage)>();
            public void Broadcast(NetMessage m) => Broadcasts.Add(m);
            public void SendTo(int playerId, NetMessage m) => Sends.Add((playerId, m));
        }

        [Fact]
        public void Broadcast_And_SendTo_RouteToOutput()
        {
            var output = new FakeOutput();
            var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
                new ConsoleLogger(), new InMemoryStorage(), output, () => { });

            ctx.Broadcast(new NetMessage(7, new byte[] { 1 }));
            ctx.SendTo(42, new NetMessage(8, new byte[] { 2 }));

            Assert.Single(output.Broadcasts);
            Assert.Equal((ushort)7, output.Broadcasts[0].Opcode);
            Assert.Single(output.Sends);
            Assert.Equal(42, output.Sends[0].Item1);
        }

        [Fact]
        public void EndRoom_InvokesCallback()
        {
            bool ended = false;
            var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
                new ConsoleLogger(), new InMemoryStorage(), new FakeOutput(), () => ended = true);
            ctx.EndRoom();
            Assert.True(ended);
        }

        [Fact]
        public void ManualClock_Advances()
        {
            var clock = new ManualClock();
            Assert.Equal(0f, clock.Now);
            clock.Advance(1.5f);
            clock.Advance(0.5f);
            Assert.Equal(2f, clock.Now);
        }

        [Fact]
        public void InMemoryStorage_SaveLoad_RoundTrips_AndMissingReturnsNull()
        {
            var s = new InMemoryStorage();
            Assert.Null(s.Load("k"));
            s.Save("k", new byte[] { 9 });
            Assert.Equal(new byte[] { 9 }, s.Load("k"));
        }
    }
}
  • Step 2: 运行,确认红

Run: dotnet test Server/Server.sln --filter RoomCtxTests Expected: 编译失败(相关类型不存在)。

  • Step 3: 写 Capabilities/ConsoleLogger.cs
using System;
using XWorld.Framework;

namespace XWorld.Server.Host
{
    public sealed class ConsoleLogger : ILogger
    {
        private readonly string _prefix;
        public ConsoleLogger(string prefix = "") { _prefix = prefix; }
        public void Info(string message)  => Console.WriteLine($"[INFO]{_prefix} {message}");
        public void Warn(string message)  => Console.WriteLine($"[WARN]{_prefix} {message}");
        public void Error(string message) => Console.WriteLine($"[ERROR]{_prefix} {message}");
    }
}
  • Step 4: 写 Capabilities/InMemoryStorage.cs
using System.Collections.Generic;
using XWorld.Framework;

namespace XWorld.Server.Host
{
    public sealed class InMemoryStorage : IStorage
    {
        private readonly Dictionary<string, byte[]> _data = new Dictionary<string, byte[]>();
        public byte[] Load(string key) => _data.TryGetValue(key, out var v) ? v : null;
        public void Save(string key, byte[] data) => _data[key] = data;
    }
}
  • Step 5: 写 Capabilities/ManualClock.cs
using XWorld.Framework;

namespace XWorld.Server.Host
{
    // 手动推进的时钟;真实运行时改用墙钟实现(子项目 2b/后续)
    public sealed class ManualClock : ITimer
    {
        public float Now { get; private set; }
        public void Advance(float dt) { Now += dt; }
    }
}
  • Step 6: 写 IRoomOutput.cs
using XWorld.Framework;

namespace XWorld.Server.Host
{
    // 房间出站消息的去向;本子项目无网络,用它对接测试捕获,2b 接 WS 会话
    public interface IRoomOutput
    {
        void Broadcast(NetMessage message);
        void SendTo(int playerId, NetMessage message);
    }
}
  • Step 7: 写 RoomCtx.cs
using System;
using XWorld.Framework;

namespace XWorld.Server.Host
{
    public sealed class RoomCtx : IRoomCtx
    {
        private readonly IRoomOutput _output;
        private readonly Action _endRoom;

        public IRandom Random { get; }
        public ITimer Timer { get; }
        public ILogger Logger { get; }
        public IStorage Storage { get; }

        public RoomCtx(IRandom random, ITimer timer, ILogger logger, IStorage storage,
                       IRoomOutput output, Action endRoom)
        {
            Random = random; Timer = timer; Logger = logger; Storage = storage;
            _output = output; _endRoom = endRoom;
        }

        public void Broadcast(NetMessage message) => _output.Broadcast(message);
        public void SendTo(int playerId, NetMessage message) => _output.SendTo(playerId, message);
        public void EndRoom() => _endRoom();
    }
}
  • Step 8: 运行,确认绿

Run: dotnet test Server/Server.sln --filter RoomCtxTests Expected: PASS。


Task 4: 房间生命周期与异常隔离 Room(TDD)

Files:

  • Create: Server/Server.Host/Room.cs
  • Test: Server/Server.Host.Tests/RoomTests.cs

设计 §6:小游戏 Server 逻辑抛异常时房间隔离——捕获、安全结束本房间、记录日志,绝不拖垮宿主。

  • Step 1: 写失败测试 RoomTests.cs
using System;
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class RoomTests
    {
        private sealed class FakeOutput : IRoomOutput
        {
            public int Broadcasts;
            public void Broadcast(NetMessage m) => Broadcasts++;
            public void SendTo(int playerId, NetMessage m) { }
        }

        // 正常房间:第 N 次 tick 时通过 ctx.EndRoom 请求结束
        private sealed class CountingRoom : IGameServerRoom
        {
            private IRoomCtx _ctx;
            private int _ticks;
            public int EndCalls;
            public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => _ctx = ctx;
            public void OnMessage(int playerId, NetMessage message) { }
            public void OnTick(float dt)
            {
                _ticks++;
                _ctx.Broadcast(new NetMessage(1, new byte[0]));
                if (_ticks >= 2) _ctx.EndRoom();
            }
            public void OnRoomEnd() => EndCalls++;
        }

        // 故障房间:OnTick 抛异常
        private sealed class ThrowingRoom : IGameServerRoom
        {
            public int EndCalls;
            public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) { }
            public void OnMessage(int playerId, NetMessage message) { }
            public void OnTick(float dt) => throw new InvalidOperationException("boom");
            public void OnRoomEnd() => EndCalls++;
        }

        private static (Room room, RoomCtx ctx, T logic) Build<T>(string id, T logic) where T : IGameServerRoom
        {
            var clock = new ManualClock();
            Room room = null;
            var ctx = new RoomCtx(new DeterministicRandom(1), clock, new ConsoleLogger(),
                new InMemoryStorage(), new FakeOutput(), () => room.RequestEnd());
            room = new Room(id, logic, clock, ctx, new ConsoleLogger());
            return (room, ctx, logic);
        }

        private static readonly IReadOnlyList<PlayerInfo> Players =
            new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } };

        [Fact]
        public void NormalRoom_EndsAfterRequest_AndCallsOnRoomEndOnce()
        {
            var (room, _, logic) = Build("r1", new CountingRoom());
            room.Start(Players);
            Assert.True(room.IsActive);

            room.Tick(1f);                 // _ticks=1
            Assert.True(room.IsActive);
            room.Tick(1f);                 // _ticks=2 → EndRoom 请求 → 本 tick 后结束
            Assert.False(room.IsActive);
            Assert.Equal(1, logic.EndCalls);

            room.Tick(1f);                 // 已结束,再 tick 无效
            Assert.Equal(1, logic.EndCalls);
        }

        [Fact]
        public void ThrowingRoom_FaultsOut_Safely_WithoutPropagating()
        {
            var (room, _, logic) = Build("r2", new ThrowingRoom());
            room.Start(Players);

            var ex = Record.Exception(() => room.Tick(1f)); // 不应向外抛
            Assert.Null(ex);
            Assert.False(room.IsActive);
            Assert.True(room.FaultedOut);
            Assert.Equal(1, logic.EndCalls); // 故障也走一次安全结束
        }

        [Fact]
        public void ClockAdvances_BeforeOnTick()
        {
            float seen = -1f;
            var capture = new ClockProbeRoom(now => seen = now);
            var (room, _, _) = Build("r3", capture);
            room.Start(Players);
            room.Tick(2f);
            Assert.Equal(2f, seen); // OnTick 内可见已推进的时钟
        }

        private sealed class ClockProbeRoom : IGameServerRoom
        {
            private readonly Action<float> _probe;
            private IRoomCtx _ctx;
            public ClockProbeRoom(Action<float> probe) { _probe = probe; }
            public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => _ctx = ctx;
            public void OnMessage(int playerId, NetMessage message) { }
            public void OnTick(float dt) => _probe(_ctx.Timer.Now);
            public void OnRoomEnd() { }
        }

        // 在 OnRoomStart / OnMessage 抛异常的故障房间(验证四个入口都被隔离)
        private sealed class ThrowOnStartRoom : IGameServerRoom
        {
            public int EndCalls;
            public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => throw new InvalidOperationException("start boom");
            public void OnMessage(int playerId, NetMessage message) { }
            public void OnTick(float dt) { }
            public void OnRoomEnd() => EndCalls++;
        }

        private sealed class ThrowOnMessageRoom : IGameServerRoom
        {
            public int EndCalls;
            public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) { }
            public void OnMessage(int playerId, NetMessage message) => throw new InvalidOperationException("msg boom");
            public void OnTick(float dt) { }
            public void OnRoomEnd() => EndCalls++;
        }

        [Fact]
        public void ThrowOnStart_FaultsOut_Safely()
        {
            var (room, _, logic) = Build("rs", new ThrowOnStartRoom());
            var ex = Record.Exception(() => room.Start(Players));
            Assert.Null(ex);                 // 不向宿主外抛
            Assert.False(room.IsActive);
            Assert.True(room.FaultedOut);
            Assert.Equal(1, logic.EndCalls); // 安全结束一次
        }

        [Fact]
        public void ThrowOnMessage_FaultsOut_Safely()
        {
            var (room, _, logic) = Build("rm", new ThrowOnMessageRoom());
            room.Start(Players);
            Assert.True(room.IsActive);
            var ex = Record.Exception(() => room.Deliver(1, new NetMessage(0, new byte[0])));
            Assert.Null(ex);
            Assert.False(room.IsActive);
            Assert.True(room.FaultedOut);
            Assert.Equal(1, logic.EndCalls);
        }
    }
}
  • Step 2: 运行,确认红

Run: dotnet test Server/Server.sln --filter RoomTests Expected: 编译失败(Room 不存在)。

  • Step 3: 写 Room.cs
using System;
using System.Collections.Generic;
using XWorld.Framework;

namespace XWorld.Server.Host
{
    public sealed class Room
    {
        public string RoomId { get; }
        public bool IsActive { get; private set; }
        public bool FaultedOut { get; private set; }

        private readonly IGameServerRoom _logic;
        private readonly ManualClock _clock;
        private readonly RoomCtx _ctx;
        private readonly ILogger _logger;
        private bool _endRequested;

        public Room(string roomId, IGameServerRoom logic, ManualClock clock, RoomCtx ctx, ILogger logger)
        {
            RoomId = roomId; _logic = logic; _clock = clock; _ctx = ctx; _logger = logger;
        }

        // 由 RoomCtx.EndRoom 回调(或宿主)触发;下一次 tick 末尾或 Start 后据此结束。
        // publicRoomHost 与测试在装配 RoomCtx 的 endRoom 回调时需引用它(跨程序集)。
        public void RequestEnd() { _endRequested = true; }

        public void Start(IReadOnlyList<PlayerInfo> players)
        {
            try
            {
                _logic.OnRoomStart(players, _ctx);
                IsActive = true;
                if (_endRequested) End(); // 允许在 OnRoomStart 内即请求结束
            }
            catch (Exception ex)
            {
                Fault("OnRoomStart", ex);
            }
        }

        public void Deliver(int playerId, NetMessage message)
        {
            if (!IsActive) return;
            try { _logic.OnMessage(playerId, message); }
            catch (Exception ex) { Fault("OnMessage", ex); }
        }

        public void Tick(float dt)
        {
            if (!IsActive) return;
            _clock.Advance(dt);
            try { _logic.OnTick(dt); }
            catch (Exception ex) { Fault("OnTick", ex); return; }
            if (_endRequested) End();
        }

        public void End()
        {
            if (!IsActive) return;
            IsActive = false;
            try { _logic.OnRoomEnd(); }
            catch (Exception ex) { _logger.Error($"room {RoomId} OnRoomEnd 抛异常: {ex.Message}"); }
        }

        private void Fault(string where, Exception ex)
        {
            FaultedOut = true;
            _logger.Error($"room {RoomId} 在 {where} 故障: {ex}");
            IsActive = false;
            try { _logic.OnRoomEnd(); } catch { /* 异常路径,吞掉二次异常 */ }
        }
    }
}
  • Step 4: 运行,确认绿

Run: dotnet test Server/Server.sln --filter RoomTests Expected: PASS。


Task 5: Hello 夹具游戏(Core + Server

Files:

  • Create: Server/games-src/Hello/Hello.Core/Hello.Core.csproj
  • Create: Server/games-src/Hello/Hello.Core/HelloState.cs
  • Create: Server/games-src/Hello/Hello.Core/HelloLogic.cs
  • Create: Server/games-src/Hello/Hello.Server/Hello.Server.csproj
  • Create: Server/games-src/Hello/Hello.Server/HelloServerRoom.cs

这两个工程是测试夹具,不进 Server.sln,由 Task 6 脚本构建。它们用编译期符号 HELLO_V2 让起始计数 v1=0 / v2=100,从而产出真实不同的 IL,以验证代码级热更与版本共存。

  • Step 1: 写 Hello.Core/Hello.Core.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.1</TargetFramework>
    <LangVersion>9.0</LangVersion>
    <Nullable>disable</Nullable>
    <AssemblyName>Hello.Core</AssemblyName>
    <RootNamespace>Hello.Core</RootNamespace>
    <!-- 由 build-test-games.sh 传入 -p:HelloVariant=HELLO_V2 时追加该符号 -->
    <DefineConstants>$(DefineConstants);$(HelloVariant)</DefineConstants>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
      <Private>false</Private> <!-- 不把 Framework.Shared.dll 拷进游戏目录:ALC 从默认上下文解析 -->
    </ProjectReference>
  </ItemGroup>

</Project>
  • Step 2: 写 Hello.Core/HelloState.cs
namespace Hello.Core
{
    public sealed class HelloState
    {
        public int Tick;
        public int Counter;
    }
}
  • Step 3: 写 Hello.Core/HelloLogic.cs
using XWorld.Framework;
using XWorld.Framework.Protocol;

namespace Hello.Core
{
    // input: 每 tick 的计数增量;event: 文字日志
    public sealed class HelloLogic : IGameLogic<HelloState, int, string>
    {
        // v1 起始 0;v2 编译期注入 100 —— 不同 IL,证明代码级热更
        private static int StartCounter =>
#if HELLO_V2
            100;
#else
            0;
#endif

        public HelloState CreateInitial(RoomConfig config, IRandom random)
            => new HelloState { Tick = 0, Counter = StartCounter };

        public StepResult<HelloState, string> Step(HelloState state, int input, IRandom random)
        {
            var next = new HelloState { Tick = state.Tick + 1, Counter = state.Counter + input };
            var events = new[] { $"tick {next.Tick} -> {next.Counter}" };
            return new StepResult<HelloState, string>(next, events);
        }

        public byte[] Encode(HelloState state)
        {
            var w = new PacketWriter();
            w.WriteVarInt(state.Tick);
            w.WriteVarInt(state.Counter);
            return w.ToArray();
        }

        public HelloState Decode(byte[] data)
        {
            var r = new PacketReader(data);
            return new HelloState { Tick = r.ReadVarInt(), Counter = r.ReadVarInt() };
        }
    }
}
  • Step 4: 写 Hello.Server/Hello.Server.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.1</TargetFramework>
    <LangVersion>9.0</LangVersion>
    <Nullable>disable</Nullable>
    <AssemblyName>Hello.Server</AssemblyName>
    <RootNamespace>Hello.Server</RootNamespace>
    <DefineConstants>$(DefineConstants);$(HelloVariant)</DefineConstants>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
      <Private>false</Private>
    </ProjectReference>
    <ProjectReference Include="..\Hello.Core\Hello.Core.csproj" />
  </ItemGroup>

</Project>
  • Step 5: 写 Hello.Server/HelloServerRoom.cs
using System.Collections.Generic;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using Hello.Core;

namespace Hello.Server
{
    public sealed class HelloServerRoom : IGameServerRoom
    {
        public const ushort SnapshotOpcode = 1;
        private const int EndAfterTicks = 3;

        private readonly HelloLogic _logic = new HelloLogic();
        private IRoomCtx _ctx;
        private HelloState _state;
        private int _pendingInput;

        public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx)
        {
            _ctx = ctx;
            // Version 占位:当前 IGameServerRoom 契约不传版本,HelloLogic 不读此字段(2b 再补版本管线)
            _state = _logic.CreateInitial(
                new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = players.Count },
                ctx.Random);
            _ctx.Logger.Info($"hello room start, players={players.Count}, counter={_state.Counter}");
        }

        public void OnMessage(int playerId, NetMessage message)
        {
            var r = new PacketReader(message.Payload);
            _pendingInput += r.ReadVarInt();
        }

        public void OnTick(float deltaTime)
        {
            int input = _pendingInput == 0 ? 1 : _pendingInput; // 无输入时默认 +1,保证推进
            _pendingInput = 0;

            var result = _logic.Step(_state, input, _ctx.Random);
            _state = result.State;

            // 复用 Core 的编码器,广播快照永不与 HelloLogic.Encode 漂移
            _ctx.Broadcast(new NetMessage(SnapshotOpcode, _logic.Encode(_state)));

            if (_state.Tick >= EndAfterTicks)
            {
                _ctx.Logger.Info($"hello room end at tick {_state.Tick}, counter={_state.Counter}");
                _ctx.EndRoom();
            }
        }

        public void OnRoomEnd() => _ctx.Logger.Info("hello room OnRoomEnd");
    }
}
  • Step 6: 编译性自检(独立构建一次,确认夹具能编)

Run: dotnet build "Server/games-src/Hello/Hello.Server/Hello.Server.csproj" -v minimal Expected: Build succeeded。(仅验证可编译;正式产物由 Task 6 脚本产出。)


Task 6: 构建测试夹具游戏(v1/v2)+ 测试内容装配

Files:

  • Create: Server/build-test-games.sh

  • Create: Server/Server.Host.Tests/Support/TestGames.cs

  • Modify: Server/Server.Host.Tests/Server.Host.Tests.csproj(拷贝 TestGames 到输出)

  • Step 1: 写 Server/build-test-games.sh

#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/Server/games-src/Hello"
TG="$ROOT/Server/Server.Host.Tests/TestGames/hello"

build_variant () {
  local ver="$1"; local variant="$2"
  local out="$TG/$ver"
  # 注意:不要用 -o/--output —— 它会无视 Private=false 把 Framework.Shared.dll 一并拷出(已知 SDK 行为)。
  # 直接读 MSBuild 自然输出目录,Private=false 在该路径下正常生效。
  local bin="$SRC/Hello.Server/bin/Release/netstandard2.1"
  rm -rf "$out"; mkdir -p "$out"
  # 传 HelloVariant 让 v2 走 HELLO_V2 分支,产生真实不同的 IL--no-incremental 确保符号变化必重编
  dotnet build "$SRC/Hello.Server/Hello.Server.csproj" -c Release --no-incremental \
    -p:HelloVariant="$variant" >/dev/null
  # 断言框架程序集未进入构建输出(Private=false 回归的真正保险)
  if [ -f "$bin/XWorld.Framework.Shared.dll" ]; then
    echo "ERROR: Framework.Shared.dll 出现在构建输出 $bin —— Private=false 可能被破坏" >&2
    exit 1
  fi
  # 拷贝前确认两个游戏 DLL 确实产出
  for dll in Hello.Core.dll Hello.Server.dll; do
    if [ ! -f "$bin/$dll" ]; then
      echo "ERROR: 构建后缺少 $dll (ver=$ver variant=$variant)" >&2
      exit 1
    fi
  done
  cp "$bin/Hello.Core.dll"   "$out/Hello.Core.dll"
  cp "$bin/Hello.Server.dll" "$out/Hello.Server.dll"
  cat > "$out/game.json" <<EOF
{
  "gameId": "hello",
  "version": $ver,
  "serverAssembly": "Hello.Server.dll",
  "serverEntryType": "Hello.Server.HelloServerRoom",
  "minFrameworkVersion": 1,
  "playerCount": 1,
  "tickRateHz": 10
}
EOF
  echo "built hello v$ver -> $out"
}

build_variant 1 ""
build_variant 2 "HELLO_V2"
echo "TestGames ready."
  • Step 2: 运行脚本产出夹具

Run: bash Server/build-test-games.sh Expected: 打印 built hello v1 / built hello v2 / TestGames ready.;目录 Server/Server.Host.Tests/TestGames/hello/1//2/ 各含 Hello.Core.dllHello.Server.dllgame.json,且不含 XWorld.Framework.Shared.dll

  • Step 3: 写 Server/Server.Host.Tests/Support/TestGames.cs
using System;
using System.IO;

namespace XWorld.Server.Host.Tests
{
    internal static class TestGames
    {
        // 测试工程把 TestGames/** 拷到输出目录;运行时从这里取 games 根
        public static string Root => Path.Combine(AppContext.BaseDirectory, "TestGames");
    }
}
  • Step 4: 在 Server.Host.Tests.csproj 加入内容拷贝

</Project> 前加入:

  <ItemGroup>
    <Content Include="TestGames\**\*">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <Link>TestGames\%(RecursiveDir)%(Filename)%(Extension)</Link>
    </Content>
  </ItemGroup>
  • Step 5: 验证夹具被拷到测试输出

Run: dotnet build Server/Server.Host.Tests/Server.Host.Tests.csproj -v minimal 然后:ls "Server/Server.Host.Tests/bin/Debug/net10.0/TestGames/hello/1" Expected: 列出 Hello.Core.dllHello.Server.dllgame.json

执行顺序约束:bash Server/build-test-games.sh 生成 TestGames,再 dotnet build/dotnet testcontent glob 在构建时求值)。后续每个跑集成测试的任务都默认夹具已生成。


Task 7: ALC 加载器 GameModuleAlc / LoadedGameModule / GameModuleLoader(集成 TDD

Files:

  • Create: Server/Server.Host/GameModuleAlc.cs

  • Create: Server/Server.Host/LoadedGameModule.cs

  • Create: Server/Server.Host/GameModuleLoader.cs

  • Test: Server/Server.Host.Tests/GameModuleLoaderTests.cs

  • Step 1: 写失败测试 GameModuleLoaderTests.cs

using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class GameModuleLoaderTests
    {
        private sealed class NullOutput : IRoomOutput
        {
            public int Broadcasts;
            public void Broadcast(NetMessage m) => Broadcasts++;
            public void SendTo(int playerId, NetMessage m) { }
        }

        private static readonly IReadOnlyList<PlayerInfo> Players =
            new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } };

        [Fact]
        public void Load_Hello_v1_ProducesRoom_ThatImplementsSharedInterface()
        {
            var module = new GameModuleLoader().Load(TestGames.Root, "hello", 1);
            Assert.Equal("hello", module.GameId);
            Assert.Equal(1, module.Version);

            IGameServerRoom room = module.CreateRoom(); // 跨界类型:返回值是默认上下文里的接口
            Assert.NotNull(room);

            // 跑一次 tick,确认 ALC 内代码真的执行并经默认上下文接口回调出站
            var output = new NullOutput();
            var clock = new ManualClock();
            var ctx = new RoomCtx(new DeterministicRandom(1), clock, new ConsoleLogger(),
                new InMemoryStorage(), output, () => { });
            room.OnRoomStart(Players, ctx);
            room.OnTick(1f);
            Assert.True(output.Broadcasts >= 1);

            // 实现类型在游戏 ALC 内,而接口在默认上下文 —— 二者程序集不同,证明双上下文隔离生效
            Assert.NotEqual(typeof(IGameServerRoom).Assembly, room.GetType().Assembly);
        }

        [Fact]
        public void Load_MissingVersion_Throws()
        {
            Assert.ThrowsAny<System.Exception>(
                () => new GameModuleLoader().Load(TestGames.Root, "hello", 99));
        }
    }
}
  • Step 2: 运行,确认红

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter GameModuleLoaderTests Expected: 编译失败(加载器类型不存在)。

  • Step 3: 写 GameModuleAlc.cs
using System.IO;
using System.Reflection;
using System.Runtime.Loader;

namespace XWorld.Server.Host
{
    // 可卸载上下文:框架契约走默认上下文(类型标识跨界一致),小游戏私有 DLL 载入本上下文
    internal sealed class GameModuleAlc : AssemblyLoadContext
    {
        private readonly string _moduleDir;

        public GameModuleAlc(string name, string moduleDir) : base(name, isCollectible: true)
        {
            _moduleDir = moduleDir;
        }

        protected override Assembly Load(AssemblyName assemblyName)
        {
            // 设计 §4.2:框架契约类型必须在默认 ALC,不随小游戏卸载。
            // 约束:XWorld.Framework.Shared 只能依赖 BCLSystem.*);若将来加第三方依赖,需同步扩展下面白名单。
            if (assemblyName.Name == "XWorld.Framework.Shared")
                return null;

            // BCL / 运行时程序集始终走默认上下文,防止游戏目录里同名文件遮蔽(类型标识错乱)
            string n = assemblyName.Name;
            if (n != null && (n.StartsWith("System.", System.StringComparison.Ordinal) ||
                              n.StartsWith("Microsoft.", System.StringComparison.Ordinal) ||
                              n == "System" || n == "netstandard" || n == "mscorlib"))
                return null;

            string candidate = Path.Combine(_moduleDir, assemblyName.Name + ".dll");
            if (File.Exists(candidate))
                return LoadFromAssemblyPath(candidate);

            return null; // 其余交默认上下文
        }

        public Assembly LoadEntry(string serverDllPath) => LoadFromAssemblyPath(serverDllPath);
    }
}
  • Step 4: 写 LoadedGameModule.cs
using System;
using XWorld.Framework;

namespace XWorld.Server.Host
{
    public sealed class LoadedGameModule
    {
        public string GameId { get; }
        public int Version { get; }
        public Func<IGameServerRoom> CreateRoom { get; private set; } // Unload 时改写为抛错委托

        private GameModuleAlc _alc; // 强引用;Unload 时置空

        internal LoadedGameModule(string gameId, int version, GameModuleAlc alc, Func<IGameServerRoom> factory)
        {
            GameId = gameId; Version = version; _alc = alc; CreateRoom = factory;
        }

        // 卸载并返回 ALC 弱引用,便于上层做 GC 内存回收断言
        public WeakReference Unload()
        {
            var alc = _alc;
            _alc = null;
            // 切断工厂闭包对 entryType→Assembly→ALC 的引用链;卸载后再调用即抛错
            CreateRoom = () => throw new ObjectDisposedException($"GameModule {GameId}@{Version} 已卸载");
            var weak = new WeakReference(alc);
            alc.Unload();
            return weak;
        }
    }
}
  • Step 5: 写 GameModuleLoader.cs
using System;
using System.IO;
using System.Reflection;
using XWorld.Framework;

namespace XWorld.Server.Host
{
    public sealed class GameModuleLoader
    {
        public LoadedGameModule Load(string gamesRoot, string gameId, int version)
        {
            string moduleDir = Path.Combine(gamesRoot, gameId, version.ToString());
            if (!Directory.Exists(moduleDir))
                throw new DirectoryNotFoundException($"游戏模块目录不存在: {moduleDir}");

            var manifest = GameManifest.Load(Path.Combine(moduleDir, "game.json"));
            if (manifest.GameId != gameId || manifest.Version != version)
                throw new InvalidDataException(
                    $"game.json 与目录不符: 期望 {gameId} v{version}, 实际 {manifest.GameId} v{manifest.Version}");

            string serverDll = Path.Combine(moduleDir, manifest.ServerAssembly);
            if (!File.Exists(serverDll))
                throw new FileNotFoundException($"服务端入口 DLL 不存在: {serverDll}");

            var alc = new GameModuleAlc($"{gameId}@{version}", moduleDir);
            try
            {
                Assembly entryAsm = alc.LoadEntry(serverDll);
                Type entryType = entryAsm.GetType(manifest.ServerEntryType);
                if (entryType == null)
                    throw new InvalidOperationException(
                        $"在 {serverDll} 中找不到入口类型 {manifest.ServerEntryType}");
                if (!typeof(IGameServerRoom).IsAssignableFrom(entryType))
                    throw new InvalidOperationException(
                        $"{manifest.ServerEntryType} 未实现 IGameServerRoom");

                Func<IGameServerRoom> factory = () => (IGameServerRoom)Activator.CreateInstance(entryType);
                return new LoadedGameModule(gameId, version, alc, factory);
            }
            catch
            {
                alc.Unload(); // 失败路径:立刻卸载,避免遗留锁住 DLL 的 collectible ALC
                throw;
            }
        }
    }
}
  • Step 6: 运行,确认绿

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter GameModuleLoaderTests Expected: PASS(跨界接口实例化与 tick 出站均成功)。


Task 8: ALC 卸载与内存回收(集成 TDD,§10 重点验证项)

Files:

  • Test: Server/Server.Host.Tests/AlcUnloadTests.cs

  • Step 1: 写测试 AlcUnloadTests.cs

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class AlcUnloadTests
    {
        private sealed class NullOutput : IRoomOutput
        {
            public void Broadcast(NetMessage m) { }
            public void SendTo(int playerId, NetMessage m) { }
        }

        // 在独立方法里加载/运行/卸载,确保局部强引用随方法返回而消失
        [MethodImpl(MethodImplOptions.NoInlining)]
        private static (WeakReference alc, WeakReference asm) LoadRunAndUnload()
        {
            var module = new GameModuleLoader().Load(TestGames.Root, "hello", 1);
            IGameServerRoom room = module.CreateRoom();
            room.OnRoomStart(
                new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } },
                new RoomCtx(new DeterministicRandom(1), new ManualClock(), new ConsoleLogger(),
                    new InMemoryStorage(), new NullOutput(), () => { }));
            room.OnTick(1f);
            var asmWeak = new WeakReference(room.GetType().Assembly); // 游戏 ALC 内加载的程序集
            room = null;                 // 丢掉房间实例(其类型在 ALC 内)
            return (module.Unload(), asmWeak); // 丢掉模块强引用 + Unload,返回 ALC 与程序集弱引用
        }

        [Fact]
        public void GameModule_Unload_ReclaimsMemory()
        {
            var (alcWeak, asmWeak) = LoadRunAndUnload();
            for (int i = 0; i < 12 && (alcWeak.IsAlive || asmWeak.IsAlive); i++)
            {
                GC.Collect();
                GC.WaitForPendingFinalizers();
            }
            Assert.False(alcWeak.IsAlive, "游戏模块 ALC 未被回收 —— 存在泄漏引用");
            Assert.False(asmWeak.IsAlive, "游戏程序集未被回收 —— 存在泄漏引用");
        }
    }
}
  • Step 2: 运行,确认绿

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter AlcUnloadTests Expected: PASS(弱引用在数轮 GC 后失活)。

若此测试不稳定(个别运行 IsAlive 仍为真),多为有强引用残留:核对 LoadRunAndUnload 内没有把 module/room/工厂返回或被字段持有;NoInlining 必须保留。这是设计 §10 要求重点盯防的泄漏边界。


Task 9: 模块注册表 GameModuleRegistry(集成 TDD

Files:

  • Create: Server/Server.Host/GameModuleRegistry.cs

  • Test: Server/Server.Host.Tests/GameModuleRegistryTests.cs

  • Step 1: 写失败测试 GameModuleRegistryTests.cs

using Xunit;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class GameModuleRegistryTests
    {
        private static GameModuleRegistry NewRegistry()
            => new GameModuleRegistry(new GameModuleLoader(), TestGames.Root);

        [Fact]
        public void Acquire_SameVersionTwice_LoadsOnce_RefCounts()
        {
            var reg = NewRegistry();
            var a = reg.Acquire("hello", 1);
            var b = reg.Acquire("hello", 1);
            Assert.Same(a, b);                       // 复用同一模块
            Assert.Equal(2, reg.RefCount("hello", 1));
        }

        [Fact]
        public void Release_ToZero_WithoutSupersede_KeepsLoaded()
        {
            var reg = NewRegistry();
            var m = reg.Acquire("hello", 1);
            reg.Release(m);
            Assert.Equal(0, reg.RefCount("hello", 1));
            Assert.True(reg.IsLoaded("hello", 1));   // 未被淘汰则保留以复用
        }

        [Fact]
        public void NewVersion_Supersedes_Old_And_OldUnloadsWhenRefZero()
        {
            var reg = NewRegistry();
            var v1 = reg.Acquire("hello", 1);        // v1 引用=1
            var v2 = reg.Acquire("hello", 2);        // 取 v2 → v1 被标记淘汰
            Assert.True(reg.IsLoaded("hello", 1));
            Assert.True(reg.IsLoaded("hello", 2));

            reg.Release(v1);                          // v1 引用归零且被淘汰 → 卸载并移除
            Assert.False(reg.IsLoaded("hello", 1));
            Assert.True(reg.IsLoaded("hello", 2));    // v2 仍在
        }

        [Fact]
        public void Acquire_FailedNewVersionLoad_DoesNotSupersedeExisting()
        {
            var reg = NewRegistry();
            var v1 = reg.Acquire("hello", 1);
            Assert.ThrowsAny<System.Exception>(() => reg.Acquire("hello", 99)); // v99 不存在,加载抛出
            reg.Release(v1);                       // v1 引用归零
            Assert.True(reg.IsLoaded("hello", 1)); // 未被错误淘汰,仍保留
        }

        [Fact]
        public void Release_MoreThanAcquired_Throws()
        {
            var reg = NewRegistry();
            var m = reg.Acquire("hello", 1);
            reg.Release(m);
            Assert.Throws<System.InvalidOperationException>(() => reg.Release(m));
        }
    }
}
  • Step 2: 运行,确认红

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter GameModuleRegistryTests Expected: 编译失败(GameModuleRegistry 不存在)。

  • Step 3: 写 GameModuleRegistry.cs
using System;
using System.Collections.Generic;

namespace XWorld.Server.Host
{
    // (gameId,version) -> 已加载模块;按引用计数获取/释放,旧版本被新版本淘汰且引用归零时卸载
    public sealed class GameModuleRegistry
    {
        private sealed class Entry
        {
            public LoadedGameModule Module;
            public int RefCount;
            public bool Superseded;
        }

        private readonly GameModuleLoader _loader;
        private readonly string _gamesRoot;
        private readonly Dictionary<string, Entry> _entries = new Dictionary<string, Entry>();

        public GameModuleRegistry(GameModuleLoader loader, string gamesRoot)
        {
            _loader = loader; _gamesRoot = gamesRoot;
        }

        private static string Key(string gameId, int version) => $"{gameId}@{version}";

        public LoadedGameModule Acquire(string gameId, int version)
        {
            string key = Key(gameId, version);
            if (!_entries.TryGetValue(key, out var e))
            {
                // 先加载(失败则直接抛出,不污染任何状态:旧版本不会被错误淘汰)
                var loaded = _loader.Load(_gamesRoot, gameId, version);
                e = new Entry { Module = loaded, RefCount = 0, Superseded = false };
                _entries[key] = e;
                // 加载成功后,再把同 gameId 的更旧版本标记淘汰(引用归零即卸载)
                foreach (var kv in _entries)
                {
                    if (kv.Value.Module.GameId == gameId && kv.Value.Module.Version < version)
                        kv.Value.Superseded = true;
                }
            }
            e.RefCount++;
            return e.Module;
        }

        public void Release(LoadedGameModule module)
        {
            string key = Key(module.GameId, module.Version);
            if (!_entries.TryGetValue(key, out var e)) return;
            if (e.RefCount <= 0)
                throw new InvalidOperationException($"重复释放模块 {module.GameId}@{module.Version}");
            e.RefCount--;
            if (e.RefCount <= 0 && e.Superseded)
            {
                _entries.Remove(key);
                module.Unload(); // 卸载;GC 回收交由运行时(内存回收已在 Task 8 验证)
            }
        }

        public int RefCount(string gameId, int version)
            => _entries.TryGetValue(Key(gameId, version), out var e) ? e.RefCount : 0;

        public bool IsLoaded(string gameId, int version) => _entries.ContainsKey(Key(gameId, version));
    }
}
  • Step 4: 运行,确认绿

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter GameModuleRegistryTests Expected: PASS。


Task 10: 房间宿主 RoomHost(集成 TDD

Files:

  • Create: Server/Server.Host/RoomHost.cs

  • Test: Server/Server.Host.Tests/RoomHostTests.cs

  • Step 1: 写失败测试 RoomHostTests.cs

using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class RoomHostTests
    {
        private sealed class Capture : IRoomOutput
        {
            public List<NetMessage> Broadcasts = new List<NetMessage>();
            public void Broadcast(NetMessage m) => Broadcasts.Add(m);
            public void SendTo(int playerId, NetMessage m) { }
        }

        private static readonly IReadOnlyList<PlayerInfo> Players =
            new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } };

        private static int LastCounter(Capture cap)
        {
            var last = cap.Broadcasts[cap.Broadcasts.Count - 1];
            var r = new XWorld.Framework.Protocol.PacketReader(last.Payload);
            r.ReadVarInt();             // tick
            return r.ReadVarInt();      // counter
        }

        private static RoomHost NewHost()
            => new RoomHost(new GameModuleRegistry(new GameModuleLoader(), TestGames.Root), new ConsoleLogger());

        [Fact]
        public void CreateRoom_TickToEnd_ReleasesModule()
        {
            var host = NewHost();
            var cap = new Capture();
            var cfg = new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = 1 };

            var room = host.CreateRoom("A", "hello", 1, cfg, Players, cap);
            Assert.Equal(1, host.ActiveRoomCount);

            for (int i = 0; i < 3; i++) host.TickAll(1f); // Hello 第 3 tick 自结束
            Assert.Equal(0, host.ActiveRoomCount);
            Assert.False(room.IsActive);
            Assert.Equal(3, LastCounter(cap));            // v1 起始 0 → 3 次 +1 = 3
        }

        [Fact]
        public void Deliver_AddsInput_AffectsCounter()
        {
            var host = NewHost();
            var cap = new Capture();
            var cfg = new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = 1 };
            host.CreateRoom("A", "hello", 1, cfg, Players, cap);

            var w = new XWorld.Framework.Protocol.PacketWriter();
            w.WriteVarInt(5);
            host.DeliverTo("A", 1, new NetMessage(0, w.ToArray())); // 本 tick 增量 = 5
            host.TickAll(1f);                                       // counter: 0 + 5 = 5

            Assert.Equal(5, LastCounter(cap));
        }

        [Fact]
        public void CreateRoom_DuplicateRoomId_Throws()
        {
            var host = NewHost();
            var cfg = new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = 1 };
            host.CreateRoom("dup", "hello", 1, cfg, Players, new Capture());
            Assert.Throws<System.InvalidOperationException>(
                () => host.CreateRoom("dup", "hello", 1, cfg, Players, new Capture()));
        }
    }
}
  • Step 2: 运行,确认红

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RoomHostTests Expected: 编译失败(RoomHost 不存在)。

  • Step 3: 写 RoomHost.cs
using System;
using System.Collections.Generic;
using XWorld.Framework;

namespace XWorld.Server.Host
{
    public sealed class RoomHost
    {
        private readonly GameModuleRegistry _registry;
        private readonly ILogger _logger;
        private readonly Dictionary<string, RoomEntry> _rooms = new Dictionary<string, RoomEntry>();

        private struct RoomEntry { public Room Room; public LoadedGameModule Module; }

        public RoomHost(GameModuleRegistry registry, ILogger logger)
        {
            _registry = registry; _logger = logger;
        }

        public int ActiveRoomCount
        {
            get { int n = 0; foreach (var kv in _rooms) if (kv.Value.Room.IsActive) n++; return n; }
        }

        public Room CreateRoom(string roomId, string gameId, int version, RoomConfig config,
                               IReadOnlyList<PlayerInfo> players, IRoomOutput output)
        {
            if (_rooms.ContainsKey(roomId))
                throw new InvalidOperationException($"房间 ID 已存在: {roomId}");

            var module = _registry.Acquire(gameId, version);
            try
            {
                IGameServerRoom logic = module.CreateRoom();
                var clock = new ManualClock();

                Room room = null;
                var ctx = new RoomCtx(
                    new DeterministicRandom(config.Seed),
                    clock, _logger, new InMemoryStorage(),
                    output, () => room.RequestEnd());
                room = new Room(roomId, logic, clock, ctx, _logger);

                _rooms[roomId] = new RoomEntry { Room = room, Module = module };
                room.Start(players);
                if (!room.IsActive) ReleaseRoom(roomId); // Start 内即结束/故障则立即回收
                return room;
            }
            catch
            {
                // 构建失败(如游戏构造函数抛异常):释放已 Acquire 的模块,避免引用泄漏
                if (_rooms.ContainsKey(roomId)) ReleaseRoom(roomId);
                else _registry.Release(module);
                throw;
            }
        }

        public void TickAll(float dt)
        {
            List<string> ended = null;
            foreach (var kv in _rooms)
            {
                var room = kv.Value.Room;
                if (!room.IsActive) { (ended ??= new List<string>()).Add(kv.Key); continue; }
                room.Tick(dt);
                if (!room.IsActive) (ended ??= new List<string>()).Add(kv.Key);
            }
            if (ended != null)
                foreach (var id in ended) ReleaseRoom(id);
        }

        public void DeliverTo(string roomId, int playerId, NetMessage message)
        {
            if (_rooms.TryGetValue(roomId, out var entry))
                entry.Room.Deliver(playerId, message);
        }

        private void ReleaseRoom(string roomId)
        {
            if (!_rooms.TryGetValue(roomId, out var entry)) return;
            _rooms.Remove(roomId);
            _registry.Release(entry.Module);
        }
    }
}
  • Step 4: 运行,确认绿

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RoomHostTests Expected: PASS。


Task 11: 端到端热更/版本共存集成测试(验收)

Files:

  • Test: Server/Server.Host.Tests/HotReloadEndToEndTests.cs

  • Step 1: 写测试 HotReloadEndToEndTests.cs

using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class HotReloadEndToEndTests
    {
        private sealed class Capture : IRoomOutput
        {
            public List<NetMessage> Broadcasts = new List<NetMessage>();
            public void Broadcast(NetMessage m) => Broadcasts.Add(m);
            public void SendTo(int playerId, NetMessage m) { }
        }

        private static readonly IReadOnlyList<PlayerInfo> Players =
            new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } };

        private static int LastCounter(Capture cap)
        {
            var last = cap.Broadcasts[cap.Broadcasts.Count - 1];
            var r = new PacketReader(last.Payload);
            r.ReadVarInt();
            return r.ReadVarInt();
        }

        [Fact]
        public void V1AndV2_Coexist_RunDifferentCode_ThenV1UnloadsAfterRoomEnds()
        {
            var registry = new GameModuleRegistry(new GameModuleLoader(), TestGames.Root);
            var host = new RoomHost(registry, new ConsoleLogger());

            var capA = new Capture();
            var capB = new Capture();
            var cfg1 = new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = 1 };
            var cfg2 = new RoomConfig { GameId = "hello", Version = 2, Seed = 1, PlayerCount = 1 };

            host.CreateRoom("A", "hello", 1, cfg1, Players, capA);  // 老房间用 v1
            host.CreateRoom("B", "hello", 2, cfg2, Players, capB);  // 新房间用 v2v1 被淘汰)

            Assert.True(registry.IsLoaded("hello", 1));
            Assert.True(registry.IsLoaded("hello", 2));

            for (int i = 0; i < 3; i++) host.TickAll(1f);           // 两房各 3 tick 后自结束

            // v1 起始 0 → 3v2 起始 100 → 103:两份不同代码并发运行
            Assert.Equal(3, LastCounter(capA));
            Assert.Equal(103, LastCounter(capB));

            // 老房间结束 → v1 引用归零且被淘汰 → ALC 卸载并移除;v2 保留
            Assert.False(registry.IsLoaded("hello", 1));
            Assert.True(registry.IsLoaded("hello", 2));
            Assert.Equal(0, host.ActiveRoomCount);
        }
    }
}
  • Step 2: 运行,确认绿

Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter HotReloadEndToEndTests Expected: PASS —— 验证「版本共存(不同 IL 并发)→ 老房间跑完老逻辑 → 老版本卸载移除 → 新版本保留」闭环(设计 §4.2 / §9 第 2 步验收)。


Task 12: 收尾——全量验证

Files:

  • 无新增;运行完整套件。

  • Step 1: 生成夹具并跑全量测试

Run:

bash Server/build-test-games.sh
dotnet test Server/Server.sln

Expected: 全绿。包含子项目 1 的 46 个测试(PacketCodec/FrameCodec/DeterministicRandom/FrameworkMessages)与本子项目新增的 GameManifest/RoomCtx/Room/GameModuleLoader/AlcUnload/GameModuleRegistry/RoomHost/HotReloadEndToEnd 各测试,失败 0。

  • Step 2: 构建零警告确认

Run: dotnet build Server/Server.sln -v minimal Expected: Build succeeded0 error / 0 warning。


完成判据(Definition of Done

  • bash Server/build-test-games.sh && dotnet test Server/Server.sln 全绿。
  • dotnet build Server/Server.sln 0 error / 0 warning。
  • ALC 加载:能按 (gameId,version)games/<id>/<ver>/ 加载 Hello.Server.dll,反射 IGameServerRoom 入口并跨界调用(接口类型来自默认上下文,游戏目录里没有 Framework.Shared.dll)。
  • 卸载与内存回收:弱引用在数轮 GC 后失活(Task 8)。
  • 版本共存 + 热更闭环:v1/v2 不同 IL 并发运行(计数 3 vs 103),老房间结束后 v1 卸载移除、v2 保留(Task 11)。
  • 异常隔离:故障房间安全结束、不向宿主外抛(Task 4)。
  • 引用计数:同版本复用一次加载、释放到零且被淘汰才卸载(Task 9)。
  • 共享层零改动;Hello 夹具不进 Server.sln、不被宿主/测试项目引用(仅动态加载)。

不在本子项目范围(交给后续)

  • 子项目 2bKestrel WebSocket 网关 + Session 管理 + 断线重连 + 匹配(按 (gameId,version) 分桶、15s 超时 AI 兜底)+ 消息路由(框架帧 vs 小游戏帧)+ 用 FrameCodec/FrameworkMessages 对接 IRoomOutput。届时把本子项目的 RoomHost 接到真实 WS 会话与真实墙钟 Tick 循环。
  • 持久化升级(SQLite/MySQL/Redis 替换 InMemoryStorage)、发布通知订阅、监控告警。
  • IGameLogicTInput/TEvent 统一编解码约定、业务输入走 Channel.Game 还是 GameInputMsg 的最终选择(子项目 1 遗留待决项,在 2b 定)。
  • 子进程隔离(设计 B2)。