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

1702 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 服务端房间宿主 + 可卸载 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.Shared`netstandard2.1,常驻默认上下文)。每个小游戏版本载入一个 collectible ALCALC 的 `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<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.Shared`Private=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.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
<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>` 前加入:
```xml
<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`**
```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<InvalidDataException>(() => 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<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`**
```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<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`**
```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<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`**
```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<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`**
```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 后据此结束。
// 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`**
```xml
<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`**
```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<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`**
```xml
<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`**
```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<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`**
```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" <<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.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` 加入内容拷贝**
`</Project>` 前加入:
```xml
<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.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<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`**
```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 只能依赖 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`**
```csharp
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`**
```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<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`**
```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<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`**
```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<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`**
```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<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`**
```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<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`**
```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<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
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/<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`、不被宿主/测试项目引用(仅动态加载)。
## 不在本子项目范围(交给后续)
- **子项目 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)。