# 服务端房间宿主 + 可卸载 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///` 动态加载小游戏服务端 DLL,驱动房间的权威 Tick 循环,并在版本淘汰且无引用时干净卸载(版本共存 + 内存回收),用一个 Hello 游戏跑通集成验证。**本子项目不含网络**。 **Architecture:** 新增 .NET 8+ 宿主类库 `XWorld.Server.Host`(net10.0),引用子项目 1 的 `Framework.Shared`(netstandard2.1,常驻默认上下文)。每个小游戏版本载入一个 collectible ALC,ALC 的 `Load` 对 `XWorld.Framework.Shared` 返回 null(交默认上下文解析,跨界类型一致),只把小游戏私有 DLL(`Hello.Core.dll`/`Hello.Server.dll`)载入本上下文。`RoomHost` 经 `GameModuleRegistry` 引用计数地获取/释放模块,房间结束、旧版本被新版本淘汰且引用归零时触发 `ALC.Unload()`。Tick 用手动时钟驱动,集成测试确定性地验证「加载→并存→卸载→内存回收」。 **Tech Stack:** C# / net10.0 宿主 · `System.Runtime.Loader.AssemblyLoadContext`(collectible)· `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.Shared`,netstandard2.1),可用契约:`IGameServerRoom`、`IRoomCtx`、`IRandom`/`ITimer`/`ILogger`/`IStorage`、`IGameLogic<,,>`、`RoomConfig`、`PlayerInfo`、`NetMessage`、`StepResult<,>`、`DeterministicRandom`、`Protocol.PacketWriter/PacketReader`。命名空间 `XWorld.Framework` 与 `XWorld.Framework.Protocol`。 - **无 git**:本工程未用版本控制。**跳过所有 git 步骤**,其余照常。 - 工作目录 `D:/UD/AI/AIC#Project`,bash on Windows(正斜杠、`/dev/null`)。`dotnet` SDK 10.0.201。 - 网络(Kestrel WS 网关 / Session / 匹配 / 路由)属于**子项目 2b**,本计划不做。 ## 命名与依赖 - 宿主命名空间 `XWorld.Server.Host`,程序集名同名,TFM `net10.0`,引用 `Framework.Shared`。 - Hello 夹具游戏:`Hello.Core`(netstandard2.1)、`Hello.Server`(netstandard2.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` + `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.Shared(`Private=false`) | | `Hello.Core/HelloState.cs` | 状态:Tick + Counter | | `Hello.Core/HelloLogic.cs` | `IGameLogic`,起始计数 v1=0 / v2=100(`#if HELLO_V2`) | | `Hello.Server/Hello.Server.csproj` | netstandard2.1,引用 Framework.Shared(`Private=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` 执行: ```bash 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`** ```xml net10.0 disable disable XWorld.Server.Host XWorld.Server.Host ``` - [ ] **Step 3: 让测试工程引用宿主** 在 `Server/Server.Host.Tests/Server.Host.Tests.csproj` 的 `` 前加入: ```xml ``` - [ ] **Step 4: 构建确认** Run: `dotnet build Server/Server.sln -v minimal` Expected: `Build succeeded`,0 error。(此时无业务代码,测试工程仅含默认空测试已删,应能编过。) --- ## Task 2: game.json 清单解析 GameManifest(TDD) **Files:** - Create: `Server/Server.Host/GameManifest.cs` - Test: `Server/Server.Host.Tests/GameManifestTests.cs` - [ ] **Step 1: 写失败测试 `GameManifestTests.cs`** ```csharp 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(() => GameManifest.Parse(json)); } } } ``` - [ ] **Step 2: 运行,确认红** Run: `dotnet test Server/Server.sln --filter GameManifestTests` Expected: 编译失败(`GameManifest` 不存在)。 - [ ] **Step 3: 写 `GameManifest.cs`** ```csharp 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(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`** ```csharp 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 Broadcasts = new List(); 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`** ```csharp 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`** ```csharp using System.Collections.Generic; using XWorld.Framework; namespace XWorld.Server.Host { public sealed class InMemoryStorage : IStorage { private readonly Dictionary _data = new Dictionary(); 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`** ```csharp 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`** ```csharp 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`** ```csharp 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`** ```csharp 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 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 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(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 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 _probe; private IRoomCtx _ctx; public ClockProbeRoom(Action probe) { _probe = probe; } public void OnRoomStart(IReadOnlyList 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 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 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`** ```csharp 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 后据此结束。 // public:RoomHost 与测试在装配 RoomCtx 的 endRoom 回调时需引用它(跨程序集)。 public void RequestEnd() { _endRequested = true; } public void Start(IReadOnlyList 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`** ```xml netstandard2.1 9.0 disable Hello.Core Hello.Core $(DefineConstants);$(HelloVariant) false ``` - [ ] **Step 2: 写 `Hello.Core/HelloState.cs`** ```csharp namespace Hello.Core { public sealed class HelloState { public int Tick; public int Counter; } } ``` - [ ] **Step 3: 写 `Hello.Core/HelloLogic.cs`** ```csharp using XWorld.Framework; using XWorld.Framework.Protocol; namespace Hello.Core { // input: 每 tick 的计数增量;event: 文字日志 public sealed class HelloLogic : IGameLogic { // 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 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(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`** ```xml netstandard2.1 9.0 disable Hello.Server Hello.Server $(DefineConstants);$(HelloVariant) false ``` - [ ] **Step 5: 写 `Hello.Server/HelloServerRoom.cs`** ```csharp 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 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`** ```bash #!/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" < $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.dll`、`Hello.Server.dll`、`game.json`,且**不含** `XWorld.Framework.Shared.dll`。 - [ ] **Step 3: 写 `Server/Server.Host.Tests/Support/TestGames.cs`** ```csharp 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` 加入内容拷贝** 在 `` 前加入: ```xml PreserveNewest TestGames\%(RecursiveDir)%(Filename)%(Extension) ``` - [ ] **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.dll`、`Hello.Server.dll`、`game.json`。 > 执行顺序约束:**先 `bash Server/build-test-games.sh` 生成 TestGames,再 `dotnet build`/`dotnet test`**(content 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`** ```csharp 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 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( () => 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`** ```csharp 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 只能依赖 BCL(System.*);若将来加第三方依赖,需同步扩展下面白名单。 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`** ```csharp using System; using XWorld.Framework; namespace XWorld.Server.Host { public sealed class LoadedGameModule { public string GameId { get; } public int Version { get; } public Func CreateRoom { get; private set; } // Unload 时改写为抛错委托 private GameModuleAlc _alc; // 强引用;Unload 时置空 internal LoadedGameModule(string gameId, int version, GameModuleAlc alc, Func 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`** ```csharp 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 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`** ```csharp 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`** ```csharp using Xunit; using XWorld.Server.Host; namespace XWorld.Server.Host.Tests { public class GameModuleRegistryTests { private static GameModuleRegistry NewRegistry() => new GameModuleRegistry(new GameModuleLoader(), TestGames.Root); [Fact] public void Acquire_SameVersionTwice_LoadsOnce_RefCounts() { var reg = NewRegistry(); var a = reg.Acquire("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(() => 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(() => 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`** ```csharp 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 _entries = new Dictionary(); 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`** ```csharp 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 Broadcasts = new List(); public void Broadcast(NetMessage m) => Broadcasts.Add(m); public void SendTo(int playerId, NetMessage m) { } } private static readonly IReadOnlyList 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( () => 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`** ```csharp 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 _rooms = new Dictionary(); 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 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 ended = null; foreach (var kv in _rooms) { var room = kv.Value.Room; if (!room.IsActive) { (ended ??= new List()).Add(kv.Key); continue; } room.Tick(dt); if (!room.IsActive) (ended ??= new List()).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`** ```csharp 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 Broadcasts = new List(); public void Broadcast(NetMessage m) => Broadcasts.Add(m); public void SendTo(int playerId, NetMessage m) { } } private static readonly IReadOnlyList 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); // 新房间用 v2(v1 被淘汰) 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 → 3;v2 起始 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 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 succeeded`,0 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///` 加载 `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`、不被宿主/测试项目引用(仅动态加载)。 ## 不在本子项目范围(交给后续) - **子项目 2b**:Kestrel WebSocket 网关 + Session 管理 + 断线重连 + 匹配(按 `(gameId,version)` 分桶、15s 超时 AI 兜底)+ 消息路由(框架帧 vs 小游戏帧)+ 用 `FrameCodec`/`FrameworkMessages` 对接 `IRoomOutput`。届时把本子项目的 `RoomHost` 接到真实 WS 会话与真实墙钟 Tick 循环。 - 持久化升级(SQLite/MySQL/Redis 替换 `InMemoryStorage`)、发布通知订阅、监控告警。 - `IGameLogic` 的 `TInput`/`TEvent` 统一编解码约定、业务输入走 `Channel.Game` 还是 `GameInputMsg` 的最终选择(子项目 1 遗留待决项,在 2b 定)。 - 子进程隔离(设计 B2)。