62 KiB
服务端房间宿主 + 可卸载 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 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)。dotnetSDK 10.0.201。 - 网络(Kestrel WS 网关 / Session / 匹配 / 路由)属于子项目 2b,本计划不做。
命名与依赖
- 宿主命名空间
XWorld.Server.Host,程序集名同名,TFMnet10.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 执行:
dotnet new classlib -n Server.Host -o Server/Server.Host -f net10.0
dotnet new xunit -n Server.Host.Tests -o Server/Server.Host.Tests
rm -f Server/Server.Host/Class1.cs Server/Server.Host.Tests/UnitTest1.cs
dotnet sln Server/Server.sln add Server/Server.Host/Server.Host.csproj
dotnet sln Server/Server.sln add Server/Server.Host.Tests/Server.Host.Tests.csproj
mkdir -p Server/Server.Host/Capabilities Server/Server.Host.Tests/Support
- Step 2: 写
Server/Server.Host/Server.Host.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Server.Host</AssemblyName>
<RootNamespace>XWorld.Server.Host</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Framework.Shared\Framework.Shared.csproj" />
</ItemGroup>
</Project>
- Step 3: 让测试工程引用宿主
在 Server/Server.Host.Tests/Server.Host.Tests.csproj 的 </Project> 前加入:
<ItemGroup>
<ProjectReference Include="..\Server.Host\Server.Host.csproj" />
</ItemGroup>
- Step 4: 构建确认
Run: dotnet build Server/Server.sln -v minimal
Expected: Build succeeded,0 error。(此时无业务代码,测试工程仅含默认空测试已删,应能编过。)
Task 2: game.json 清单解析 GameManifest(TDD)
Files:
-
Create:
Server/Server.Host/GameManifest.cs -
Test:
Server/Server.Host.Tests/GameManifestTests.cs -
Step 1: 写失败测试
GameManifestTests.cs
using System.IO;
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class GameManifestTests
{
private const string Valid = @"{
""gameId"": ""hello"",
""version"": 2,
""serverAssembly"": ""Hello.Server.dll"",
""serverEntryType"": ""Hello.Server.HelloServerRoom"",
""minFrameworkVersion"": 1,
""playerCount"": 1,
""tickRateHz"": 10
}";
[Fact]
public void Parse_Valid_ReadsAllFields()
{
var m = GameManifest.Parse(Valid);
Assert.Equal("hello", m.GameId);
Assert.Equal(2, m.Version);
Assert.Equal("Hello.Server.dll", m.ServerAssembly);
Assert.Equal("Hello.Server.HelloServerRoom", m.ServerEntryType);
Assert.Equal(1, m.MinFrameworkVersion);
Assert.Equal(1, m.PlayerCount);
Assert.Equal(10, m.TickRateHz);
}
[Fact]
public void Parse_CaseInsensitive_FieldNames()
{
var m = GameManifest.Parse(@"{""GameId"":""x"",""Version"":1,""ServerAssembly"":""X.dll"",""ServerEntryType"":""X.Y""}");
Assert.Equal("x", m.GameId);
Assert.Equal(1, m.Version);
}
[Theory]
[InlineData(@"{""version"":1,""serverEntryType"":""a"",""serverAssembly"":""a.dll""}")] // 缺 gameId
[InlineData(@"{""gameId"":""x"",""version"":1,""serverAssembly"":""a.dll""}")] // 缺 entryType
[InlineData(@"{""gameId"":""x"",""version"":1,""serverEntryType"":""a""}")] // 缺 serverAssembly
[InlineData(@"{""gameId"":""x"",""version"":0,""serverEntryType"":""a"",""serverAssembly"":""a.dll""}")] // version 非正
public void Parse_Invalid_Throws(string json)
{
Assert.Throws<InvalidDataException>(() => GameManifest.Parse(json));
}
}
}
- Step 2: 运行,确认红
Run: dotnet test Server/Server.sln --filter GameManifestTests
Expected: 编译失败(GameManifest 不存在)。
- Step 3: 写
GameManifest.cs
using System.IO;
using System.Text.Json;
namespace XWorld.Server.Host
{
public sealed class GameManifest
{
public string GameId { get; set; }
public int Version { get; set; }
public string ServerAssembly { get; set; } // 例如 "Hello.Server.dll"
public string ServerEntryType { get; set; } // 例如 "Hello.Server.HelloServerRoom"
public int MinFrameworkVersion { get; set; }
public int PlayerCount { get; set; }
public int TickRateHz { get; set; }
public static GameManifest Parse(string json)
{
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
GameManifest m;
try { m = JsonSerializer.Deserialize<GameManifest>(json, opts); }
catch (JsonException ex) { throw new InvalidDataException("game.json: 无法解析", ex); }
if (m == null) throw new InvalidDataException("game.json: 内容为空");
if (string.IsNullOrEmpty(m.GameId)) throw new InvalidDataException("game.json: 缺少 gameId");
if (string.IsNullOrEmpty(m.ServerEntryType)) throw new InvalidDataException("game.json: 缺少 serverEntryType");
if (string.IsNullOrEmpty(m.ServerAssembly)) throw new InvalidDataException("game.json: 缺少 serverAssembly");
if (m.Version <= 0) throw new InvalidDataException("game.json: version 必须为正");
return m;
}
public static GameManifest Load(string path) => Parse(File.ReadAllText(path));
}
}
- Step 4: 运行,确认绿
Run: dotnet test Server/Server.sln --filter GameManifestTests
Expected: PASS。
Task 3: 能力实现与房间上下文(TDD)
Files:
-
Create:
Server/Server.Host/Capabilities/ConsoleLogger.cs -
Create:
Server/Server.Host/Capabilities/InMemoryStorage.cs -
Create:
Server/Server.Host/Capabilities/ManualClock.cs -
Create:
Server/Server.Host/IRoomOutput.cs -
Create:
Server/Server.Host/RoomCtx.cs -
Test:
Server/Server.Host.Tests/RoomCtxTests.cs -
Step 1: 写失败测试
RoomCtxTests.cs
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class RoomCtxTests
{
private sealed class FakeOutput : IRoomOutput
{
public List<NetMessage> Broadcasts = new List<NetMessage>();
public List<(int, NetMessage)> Sends = new List<(int, NetMessage)>();
public void Broadcast(NetMessage m) => Broadcasts.Add(m);
public void SendTo(int playerId, NetMessage m) => Sends.Add((playerId, m));
}
[Fact]
public void Broadcast_And_SendTo_RouteToOutput()
{
var output = new FakeOutput();
var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
new ConsoleLogger(), new InMemoryStorage(), output, () => { });
ctx.Broadcast(new NetMessage(7, new byte[] { 1 }));
ctx.SendTo(42, new NetMessage(8, new byte[] { 2 }));
Assert.Single(output.Broadcasts);
Assert.Equal((ushort)7, output.Broadcasts[0].Opcode);
Assert.Single(output.Sends);
Assert.Equal(42, output.Sends[0].Item1);
}
[Fact]
public void EndRoom_InvokesCallback()
{
bool ended = false;
var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
new ConsoleLogger(), new InMemoryStorage(), new FakeOutput(), () => ended = true);
ctx.EndRoom();
Assert.True(ended);
}
[Fact]
public void ManualClock_Advances()
{
var clock = new ManualClock();
Assert.Equal(0f, clock.Now);
clock.Advance(1.5f);
clock.Advance(0.5f);
Assert.Equal(2f, clock.Now);
}
[Fact]
public void InMemoryStorage_SaveLoad_RoundTrips_AndMissingReturnsNull()
{
var s = new InMemoryStorage();
Assert.Null(s.Load("k"));
s.Save("k", new byte[] { 9 });
Assert.Equal(new byte[] { 9 }, s.Load("k"));
}
}
}
- Step 2: 运行,确认红
Run: dotnet test Server/Server.sln --filter RoomCtxTests
Expected: 编译失败(相关类型不存在)。
- Step 3: 写
Capabilities/ConsoleLogger.cs
using System;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class ConsoleLogger : ILogger
{
private readonly string _prefix;
public ConsoleLogger(string prefix = "") { _prefix = prefix; }
public void Info(string message) => Console.WriteLine($"[INFO]{_prefix} {message}");
public void Warn(string message) => Console.WriteLine($"[WARN]{_prefix} {message}");
public void Error(string message) => Console.WriteLine($"[ERROR]{_prefix} {message}");
}
}
- Step 4: 写
Capabilities/InMemoryStorage.cs
using System.Collections.Generic;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class InMemoryStorage : IStorage
{
private readonly Dictionary<string, byte[]> _data = new Dictionary<string, byte[]>();
public byte[] Load(string key) => _data.TryGetValue(key, out var v) ? v : null;
public void Save(string key, byte[] data) => _data[key] = data;
}
}
- Step 5: 写
Capabilities/ManualClock.cs
using XWorld.Framework;
namespace XWorld.Server.Host
{
// 手动推进的时钟;真实运行时改用墙钟实现(子项目 2b/后续)
public sealed class ManualClock : ITimer
{
public float Now { get; private set; }
public void Advance(float dt) { Now += dt; }
}
}
- Step 6: 写
IRoomOutput.cs
using XWorld.Framework;
namespace XWorld.Server.Host
{
// 房间出站消息的去向;本子项目无网络,用它对接测试捕获,2b 接 WS 会话
public interface IRoomOutput
{
void Broadcast(NetMessage message);
void SendTo(int playerId, NetMessage message);
}
}
- Step 7: 写
RoomCtx.cs
using System;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class RoomCtx : IRoomCtx
{
private readonly IRoomOutput _output;
private readonly Action _endRoom;
public IRandom Random { get; }
public ITimer Timer { get; }
public ILogger Logger { get; }
public IStorage Storage { get; }
public RoomCtx(IRandom random, ITimer timer, ILogger logger, IStorage storage,
IRoomOutput output, Action endRoom)
{
Random = random; Timer = timer; Logger = logger; Storage = storage;
_output = output; _endRoom = endRoom;
}
public void Broadcast(NetMessage message) => _output.Broadcast(message);
public void SendTo(int playerId, NetMessage message) => _output.SendTo(playerId, message);
public void EndRoom() => _endRoom();
}
}
- Step 8: 运行,确认绿
Run: dotnet test Server/Server.sln --filter RoomCtxTests
Expected: PASS。
Task 4: 房间生命周期与异常隔离 Room(TDD)
Files:
- Create:
Server/Server.Host/Room.cs - Test:
Server/Server.Host.Tests/RoomTests.cs
设计 §6:小游戏 Server 逻辑抛异常时房间隔离——捕获、安全结束本房间、记录日志,绝不拖垮宿主。
- Step 1: 写失败测试
RoomTests.cs
using System;
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class RoomTests
{
private sealed class FakeOutput : IRoomOutput
{
public int Broadcasts;
public void Broadcast(NetMessage m) => Broadcasts++;
public void SendTo(int playerId, NetMessage m) { }
}
// 正常房间:第 N 次 tick 时通过 ctx.EndRoom 请求结束
private sealed class CountingRoom : IGameServerRoom
{
private IRoomCtx _ctx;
private int _ticks;
public int EndCalls;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => _ctx = ctx;
public void OnMessage(int playerId, NetMessage message) { }
public void OnTick(float dt)
{
_ticks++;
_ctx.Broadcast(new NetMessage(1, new byte[0]));
if (_ticks >= 2) _ctx.EndRoom();
}
public void OnRoomEnd() => EndCalls++;
}
// 故障房间:OnTick 抛异常
private sealed class ThrowingRoom : IGameServerRoom
{
public int EndCalls;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) { }
public void OnMessage(int playerId, NetMessage message) { }
public void OnTick(float dt) => throw new InvalidOperationException("boom");
public void OnRoomEnd() => EndCalls++;
}
private static (Room room, RoomCtx ctx, T logic) Build<T>(string id, T logic) where T : IGameServerRoom
{
var clock = new ManualClock();
Room room = null;
var ctx = new RoomCtx(new DeterministicRandom(1), clock, new ConsoleLogger(),
new InMemoryStorage(), new FakeOutput(), () => room.RequestEnd());
room = new Room(id, logic, clock, ctx, new ConsoleLogger());
return (room, ctx, logic);
}
private static readonly IReadOnlyList<PlayerInfo> Players =
new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } };
[Fact]
public void NormalRoom_EndsAfterRequest_AndCallsOnRoomEndOnce()
{
var (room, _, logic) = Build("r1", new CountingRoom());
room.Start(Players);
Assert.True(room.IsActive);
room.Tick(1f); // _ticks=1
Assert.True(room.IsActive);
room.Tick(1f); // _ticks=2 → EndRoom 请求 → 本 tick 后结束
Assert.False(room.IsActive);
Assert.Equal(1, logic.EndCalls);
room.Tick(1f); // 已结束,再 tick 无效
Assert.Equal(1, logic.EndCalls);
}
[Fact]
public void ThrowingRoom_FaultsOut_Safely_WithoutPropagating()
{
var (room, _, logic) = Build("r2", new ThrowingRoom());
room.Start(Players);
var ex = Record.Exception(() => room.Tick(1f)); // 不应向外抛
Assert.Null(ex);
Assert.False(room.IsActive);
Assert.True(room.FaultedOut);
Assert.Equal(1, logic.EndCalls); // 故障也走一次安全结束
}
[Fact]
public void ClockAdvances_BeforeOnTick()
{
float seen = -1f;
var capture = new ClockProbeRoom(now => seen = now);
var (room, _, _) = Build("r3", capture);
room.Start(Players);
room.Tick(2f);
Assert.Equal(2f, seen); // OnTick 内可见已推进的时钟
}
private sealed class ClockProbeRoom : IGameServerRoom
{
private readonly Action<float> _probe;
private IRoomCtx _ctx;
public ClockProbeRoom(Action<float> probe) { _probe = probe; }
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => _ctx = ctx;
public void OnMessage(int playerId, NetMessage message) { }
public void OnTick(float dt) => _probe(_ctx.Timer.Now);
public void OnRoomEnd() { }
}
// 在 OnRoomStart / OnMessage 抛异常的故障房间(验证四个入口都被隔离)
private sealed class ThrowOnStartRoom : IGameServerRoom
{
public int EndCalls;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => throw new InvalidOperationException("start boom");
public void OnMessage(int playerId, NetMessage message) { }
public void OnTick(float dt) { }
public void OnRoomEnd() => EndCalls++;
}
private sealed class ThrowOnMessageRoom : IGameServerRoom
{
public int EndCalls;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) { }
public void OnMessage(int playerId, NetMessage message) => throw new InvalidOperationException("msg boom");
public void OnTick(float dt) { }
public void OnRoomEnd() => EndCalls++;
}
[Fact]
public void ThrowOnStart_FaultsOut_Safely()
{
var (room, _, logic) = Build("rs", new ThrowOnStartRoom());
var ex = Record.Exception(() => room.Start(Players));
Assert.Null(ex); // 不向宿主外抛
Assert.False(room.IsActive);
Assert.True(room.FaultedOut);
Assert.Equal(1, logic.EndCalls); // 安全结束一次
}
[Fact]
public void ThrowOnMessage_FaultsOut_Safely()
{
var (room, _, logic) = Build("rm", new ThrowOnMessageRoom());
room.Start(Players);
Assert.True(room.IsActive);
var ex = Record.Exception(() => room.Deliver(1, new NetMessage(0, new byte[0])));
Assert.Null(ex);
Assert.False(room.IsActive);
Assert.True(room.FaultedOut);
Assert.Equal(1, logic.EndCalls);
}
}
}
- Step 2: 运行,确认红
Run: dotnet test Server/Server.sln --filter RoomTests
Expected: 编译失败(Room 不存在)。
- Step 3: 写
Room.cs
using System;
using System.Collections.Generic;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class Room
{
public string RoomId { get; }
public bool IsActive { get; private set; }
public bool FaultedOut { get; private set; }
private readonly IGameServerRoom _logic;
private readonly ManualClock _clock;
private readonly RoomCtx _ctx;
private readonly ILogger _logger;
private bool _endRequested;
public Room(string roomId, IGameServerRoom logic, ManualClock clock, RoomCtx ctx, ILogger logger)
{
RoomId = roomId; _logic = logic; _clock = clock; _ctx = ctx; _logger = logger;
}
// 由 RoomCtx.EndRoom 回调(或宿主)触发;下一次 tick 末尾或 Start 后据此结束。
// public:RoomHost 与测试在装配 RoomCtx 的 endRoom 回调时需引用它(跨程序集)。
public void RequestEnd() { _endRequested = true; }
public void Start(IReadOnlyList<PlayerInfo> players)
{
try
{
_logic.OnRoomStart(players, _ctx);
IsActive = true;
if (_endRequested) End(); // 允许在 OnRoomStart 内即请求结束
}
catch (Exception ex)
{
Fault("OnRoomStart", ex);
}
}
public void Deliver(int playerId, NetMessage message)
{
if (!IsActive) return;
try { _logic.OnMessage(playerId, message); }
catch (Exception ex) { Fault("OnMessage", ex); }
}
public void Tick(float dt)
{
if (!IsActive) return;
_clock.Advance(dt);
try { _logic.OnTick(dt); }
catch (Exception ex) { Fault("OnTick", ex); return; }
if (_endRequested) End();
}
public void End()
{
if (!IsActive) return;
IsActive = false;
try { _logic.OnRoomEnd(); }
catch (Exception ex) { _logger.Error($"room {RoomId} OnRoomEnd 抛异常: {ex.Message}"); }
}
private void Fault(string where, Exception ex)
{
FaultedOut = true;
_logger.Error($"room {RoomId} 在 {where} 故障: {ex}");
IsActive = false;
try { _logic.OnRoomEnd(); } catch { /* 异常路径,吞掉二次异常 */ }
}
}
}
- Step 4: 运行,确认绿
Run: dotnet test Server/Server.sln --filter RoomTests
Expected: PASS。
Task 5: Hello 夹具游戏(Core + Server)
Files:
- Create:
Server/games-src/Hello/Hello.Core/Hello.Core.csproj - Create:
Server/games-src/Hello/Hello.Core/HelloState.cs - Create:
Server/games-src/Hello/Hello.Core/HelloLogic.cs - Create:
Server/games-src/Hello/Hello.Server/Hello.Server.csproj - Create:
Server/games-src/Hello/Hello.Server/HelloServerRoom.cs
这两个工程是测试夹具,不进
Server.sln,由 Task 6 脚本构建。它们用编译期符号HELLO_V2让起始计数 v1=0 / v2=100,从而产出真实不同的 IL,以验证代码级热更与版本共存。
- Step 1: 写
Hello.Core/Hello.Core.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>Hello.Core</AssemblyName>
<RootNamespace>Hello.Core</RootNamespace>
<!-- 由 build-test-games.sh 传入 -p:HelloVariant=HELLO_V2 时追加该符号 -->
<DefineConstants>$(DefineConstants);$(HelloVariant)</DefineConstants>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
<Private>false</Private> <!-- 不把 Framework.Shared.dll 拷进游戏目录:ALC 从默认上下文解析 -->
</ProjectReference>
</ItemGroup>
</Project>
- Step 2: 写
Hello.Core/HelloState.cs
namespace Hello.Core
{
public sealed class HelloState
{
public int Tick;
public int Counter;
}
}
- Step 3: 写
Hello.Core/HelloLogic.cs
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace Hello.Core
{
// input: 每 tick 的计数增量;event: 文字日志
public sealed class HelloLogic : IGameLogic<HelloState, int, string>
{
// v1 起始 0;v2 编译期注入 100 —— 不同 IL,证明代码级热更
private static int StartCounter =>
#if HELLO_V2
100;
#else
0;
#endif
public HelloState CreateInitial(RoomConfig config, IRandom random)
=> new HelloState { Tick = 0, Counter = StartCounter };
public StepResult<HelloState, string> Step(HelloState state, int input, IRandom random)
{
var next = new HelloState { Tick = state.Tick + 1, Counter = state.Counter + input };
var events = new[] { $"tick {next.Tick} -> {next.Counter}" };
return new StepResult<HelloState, string>(next, events);
}
public byte[] Encode(HelloState state)
{
var w = new PacketWriter();
w.WriteVarInt(state.Tick);
w.WriteVarInt(state.Counter);
return w.ToArray();
}
public HelloState Decode(byte[] data)
{
var r = new PacketReader(data);
return new HelloState { Tick = r.ReadVarInt(), Counter = r.ReadVarInt() };
}
}
}
- Step 4: 写
Hello.Server/Hello.Server.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>Hello.Server</AssemblyName>
<RootNamespace>Hello.Server</RootNamespace>
<DefineConstants>$(DefineConstants);$(HelloVariant)</DefineConstants>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
<Private>false</Private>
</ProjectReference>
<ProjectReference Include="..\Hello.Core\Hello.Core.csproj" />
</ItemGroup>
</Project>
- Step 5: 写
Hello.Server/HelloServerRoom.cs
using System.Collections.Generic;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using Hello.Core;
namespace Hello.Server
{
public sealed class HelloServerRoom : IGameServerRoom
{
public const ushort SnapshotOpcode = 1;
private const int EndAfterTicks = 3;
private readonly HelloLogic _logic = new HelloLogic();
private IRoomCtx _ctx;
private HelloState _state;
private int _pendingInput;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx)
{
_ctx = ctx;
// Version 占位:当前 IGameServerRoom 契约不传版本,HelloLogic 不读此字段(2b 再补版本管线)
_state = _logic.CreateInitial(
new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = players.Count },
ctx.Random);
_ctx.Logger.Info($"hello room start, players={players.Count}, counter={_state.Counter}");
}
public void OnMessage(int playerId, NetMessage message)
{
var r = new PacketReader(message.Payload);
_pendingInput += r.ReadVarInt();
}
public void OnTick(float deltaTime)
{
int input = _pendingInput == 0 ? 1 : _pendingInput; // 无输入时默认 +1,保证推进
_pendingInput = 0;
var result = _logic.Step(_state, input, _ctx.Random);
_state = result.State;
// 复用 Core 的编码器,广播快照永不与 HelloLogic.Encode 漂移
_ctx.Broadcast(new NetMessage(SnapshotOpcode, _logic.Encode(_state)));
if (_state.Tick >= EndAfterTicks)
{
_ctx.Logger.Info($"hello room end at tick {_state.Tick}, counter={_state.Counter}");
_ctx.EndRoom();
}
}
public void OnRoomEnd() => _ctx.Logger.Info("hello room OnRoomEnd");
}
}
- Step 6: 编译性自检(独立构建一次,确认夹具能编)
Run: dotnet build "Server/games-src/Hello/Hello.Server/Hello.Server.csproj" -v minimal
Expected: Build succeeded。(仅验证可编译;正式产物由 Task 6 脚本产出。)
Task 6: 构建测试夹具游戏(v1/v2)+ 测试内容装配
Files:
-
Create:
Server/build-test-games.sh -
Create:
Server/Server.Host.Tests/Support/TestGames.cs -
Modify:
Server/Server.Host.Tests/Server.Host.Tests.csproj(拷贝 TestGames 到输出) -
Step 1: 写
Server/build-test-games.sh
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/Server/games-src/Hello"
TG="$ROOT/Server/Server.Host.Tests/TestGames/hello"
build_variant () {
local ver="$1"; local variant="$2"
local out="$TG/$ver"
# 注意:不要用 -o/--output —— 它会无视 Private=false 把 Framework.Shared.dll 一并拷出(已知 SDK 行为)。
# 直接读 MSBuild 自然输出目录,Private=false 在该路径下正常生效。
local bin="$SRC/Hello.Server/bin/Release/netstandard2.1"
rm -rf "$out"; mkdir -p "$out"
# 传 HelloVariant 让 v2 走 HELLO_V2 分支,产生真实不同的 IL;--no-incremental 确保符号变化必重编
dotnet build "$SRC/Hello.Server/Hello.Server.csproj" -c Release --no-incremental \
-p:HelloVariant="$variant" >/dev/null
# 断言框架程序集未进入构建输出(Private=false 回归的真正保险)
if [ -f "$bin/XWorld.Framework.Shared.dll" ]; then
echo "ERROR: Framework.Shared.dll 出现在构建输出 $bin —— Private=false 可能被破坏" >&2
exit 1
fi
# 拷贝前确认两个游戏 DLL 确实产出
for dll in Hello.Core.dll Hello.Server.dll; do
if [ ! -f "$bin/$dll" ]; then
echo "ERROR: 构建后缺少 $dll (ver=$ver variant=$variant)" >&2
exit 1
fi
done
cp "$bin/Hello.Core.dll" "$out/Hello.Core.dll"
cp "$bin/Hello.Server.dll" "$out/Hello.Server.dll"
cat > "$out/game.json" <<EOF
{
"gameId": "hello",
"version": $ver,
"serverAssembly": "Hello.Server.dll",
"serverEntryType": "Hello.Server.HelloServerRoom",
"minFrameworkVersion": 1,
"playerCount": 1,
"tickRateHz": 10
}
EOF
echo "built hello v$ver -> $out"
}
build_variant 1 ""
build_variant 2 "HELLO_V2"
echo "TestGames ready."
- Step 2: 运行脚本产出夹具
Run: bash Server/build-test-games.sh
Expected: 打印 built hello v1 / built hello v2 / TestGames ready.;目录 Server/Server.Host.Tests/TestGames/hello/1/ 与 /2/ 各含 Hello.Core.dll、Hello.Server.dll、game.json,且不含 XWorld.Framework.Shared.dll。
- Step 3: 写
Server/Server.Host.Tests/Support/TestGames.cs
using System;
using System.IO;
namespace XWorld.Server.Host.Tests
{
internal static class TestGames
{
// 测试工程把 TestGames/** 拷到输出目录;运行时从这里取 games 根
public static string Root => Path.Combine(AppContext.BaseDirectory, "TestGames");
}
}
- Step 4: 在
Server.Host.Tests.csproj加入内容拷贝
在 </Project> 前加入:
<ItemGroup>
<Content Include="TestGames\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>TestGames\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
- Step 5: 验证夹具被拷到测试输出
Run: dotnet build Server/Server.Host.Tests/Server.Host.Tests.csproj -v minimal
然后:ls "Server/Server.Host.Tests/bin/Debug/net10.0/TestGames/hello/1"
Expected: 列出 Hello.Core.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
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class GameModuleLoaderTests
{
private sealed class NullOutput : IRoomOutput
{
public int Broadcasts;
public void Broadcast(NetMessage m) => Broadcasts++;
public void SendTo(int playerId, NetMessage m) { }
}
private static readonly IReadOnlyList<PlayerInfo> Players =
new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } };
[Fact]
public void Load_Hello_v1_ProducesRoom_ThatImplementsSharedInterface()
{
var module = new GameModuleLoader().Load(TestGames.Root, "hello", 1);
Assert.Equal("hello", module.GameId);
Assert.Equal(1, module.Version);
IGameServerRoom room = module.CreateRoom(); // 跨界类型:返回值是默认上下文里的接口
Assert.NotNull(room);
// 跑一次 tick,确认 ALC 内代码真的执行并经默认上下文接口回调出站
var output = new NullOutput();
var clock = new ManualClock();
var ctx = new RoomCtx(new DeterministicRandom(1), clock, new ConsoleLogger(),
new InMemoryStorage(), output, () => { });
room.OnRoomStart(Players, ctx);
room.OnTick(1f);
Assert.True(output.Broadcasts >= 1);
// 实现类型在游戏 ALC 内,而接口在默认上下文 —— 二者程序集不同,证明双上下文隔离生效
Assert.NotEqual(typeof(IGameServerRoom).Assembly, room.GetType().Assembly);
}
[Fact]
public void Load_MissingVersion_Throws()
{
Assert.ThrowsAny<System.Exception>(
() => new GameModuleLoader().Load(TestGames.Root, "hello", 99));
}
}
}
- Step 2: 运行,确认红
Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter GameModuleLoaderTests
Expected: 编译失败(加载器类型不存在)。
- Step 3: 写
GameModuleAlc.cs
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
namespace XWorld.Server.Host
{
// 可卸载上下文:框架契约走默认上下文(类型标识跨界一致),小游戏私有 DLL 载入本上下文
internal sealed class GameModuleAlc : AssemblyLoadContext
{
private readonly string _moduleDir;
public GameModuleAlc(string name, string moduleDir) : base(name, isCollectible: true)
{
_moduleDir = moduleDir;
}
protected override Assembly Load(AssemblyName assemblyName)
{
// 设计 §4.2:框架契约类型必须在默认 ALC,不随小游戏卸载。
// 约束:XWorld.Framework.Shared 只能依赖 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
using System;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class LoadedGameModule
{
public string GameId { get; }
public int Version { get; }
public Func<IGameServerRoom> CreateRoom { get; private set; } // Unload 时改写为抛错委托
private GameModuleAlc _alc; // 强引用;Unload 时置空
internal LoadedGameModule(string gameId, int version, GameModuleAlc alc, Func<IGameServerRoom> factory)
{
GameId = gameId; Version = version; _alc = alc; CreateRoom = factory;
}
// 卸载并返回 ALC 弱引用,便于上层做 GC 内存回收断言
public WeakReference Unload()
{
var alc = _alc;
_alc = null;
// 切断工厂闭包对 entryType→Assembly→ALC 的引用链;卸载后再调用即抛错
CreateRoom = () => throw new ObjectDisposedException($"GameModule {GameId}@{Version} 已卸载");
var weak = new WeakReference(alc);
alc.Unload();
return weak;
}
}
}
- Step 5: 写
GameModuleLoader.cs
using System;
using System.IO;
using System.Reflection;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class GameModuleLoader
{
public LoadedGameModule Load(string gamesRoot, string gameId, int version)
{
string moduleDir = Path.Combine(gamesRoot, gameId, version.ToString());
if (!Directory.Exists(moduleDir))
throw new DirectoryNotFoundException($"游戏模块目录不存在: {moduleDir}");
var manifest = GameManifest.Load(Path.Combine(moduleDir, "game.json"));
if (manifest.GameId != gameId || manifest.Version != version)
throw new InvalidDataException(
$"game.json 与目录不符: 期望 {gameId} v{version}, 实际 {manifest.GameId} v{manifest.Version}");
string serverDll = Path.Combine(moduleDir, manifest.ServerAssembly);
if (!File.Exists(serverDll))
throw new FileNotFoundException($"服务端入口 DLL 不存在: {serverDll}");
var alc = new GameModuleAlc($"{gameId}@{version}", moduleDir);
try
{
Assembly entryAsm = alc.LoadEntry(serverDll);
Type entryType = entryAsm.GetType(manifest.ServerEntryType);
if (entryType == null)
throw new InvalidOperationException(
$"在 {serverDll} 中找不到入口类型 {manifest.ServerEntryType}");
if (!typeof(IGameServerRoom).IsAssignableFrom(entryType))
throw new InvalidOperationException(
$"{manifest.ServerEntryType} 未实现 IGameServerRoom");
Func<IGameServerRoom> factory = () => (IGameServerRoom)Activator.CreateInstance(entryType);
return new LoadedGameModule(gameId, version, alc, factory);
}
catch
{
alc.Unload(); // 失败路径:立刻卸载,避免遗留锁住 DLL 的 collectible ALC
throw;
}
}
}
}
- Step 6: 运行,确认绿
Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter GameModuleLoaderTests
Expected: PASS(跨界接口实例化与 tick 出站均成功)。
Task 8: ALC 卸载与内存回收(集成 TDD,§10 重点验证项)
Files:
-
Test:
Server/Server.Host.Tests/AlcUnloadTests.cs -
Step 1: 写测试
AlcUnloadTests.cs
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class AlcUnloadTests
{
private sealed class NullOutput : IRoomOutput
{
public void Broadcast(NetMessage m) { }
public void SendTo(int playerId, NetMessage m) { }
}
// 在独立方法里加载/运行/卸载,确保局部强引用随方法返回而消失
[MethodImpl(MethodImplOptions.NoInlining)]
private static (WeakReference alc, WeakReference asm) LoadRunAndUnload()
{
var module = new GameModuleLoader().Load(TestGames.Root, "hello", 1);
IGameServerRoom room = module.CreateRoom();
room.OnRoomStart(
new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } },
new RoomCtx(new DeterministicRandom(1), new ManualClock(), new ConsoleLogger(),
new InMemoryStorage(), new NullOutput(), () => { }));
room.OnTick(1f);
var asmWeak = new WeakReference(room.GetType().Assembly); // 游戏 ALC 内加载的程序集
room = null; // 丢掉房间实例(其类型在 ALC 内)
return (module.Unload(), asmWeak); // 丢掉模块强引用 + Unload,返回 ALC 与程序集弱引用
}
[Fact]
public void GameModule_Unload_ReclaimsMemory()
{
var (alcWeak, asmWeak) = LoadRunAndUnload();
for (int i = 0; i < 12 && (alcWeak.IsAlive || asmWeak.IsAlive); i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Assert.False(alcWeak.IsAlive, "游戏模块 ALC 未被回收 —— 存在泄漏引用");
Assert.False(asmWeak.IsAlive, "游戏程序集未被回收 —— 存在泄漏引用");
}
}
}
- Step 2: 运行,确认绿
Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter AlcUnloadTests
Expected: PASS(弱引用在数轮 GC 后失活)。
若此测试不稳定(个别运行
IsAlive仍为真),多为有强引用残留:核对LoadRunAndUnload内没有把module/room/工厂返回或被字段持有;NoInlining必须保留。这是设计 §10 要求重点盯防的泄漏边界。
Task 9: 模块注册表 GameModuleRegistry(集成 TDD)
Files:
-
Create:
Server/Server.Host/GameModuleRegistry.cs -
Test:
Server/Server.Host.Tests/GameModuleRegistryTests.cs -
Step 1: 写失败测试
GameModuleRegistryTests.cs
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class GameModuleRegistryTests
{
private static GameModuleRegistry NewRegistry()
=> new GameModuleRegistry(new GameModuleLoader(), TestGames.Root);
[Fact]
public void Acquire_SameVersionTwice_LoadsOnce_RefCounts()
{
var reg = NewRegistry();
var a = reg.Acquire("hello", 1);
var b = reg.Acquire("hello", 1);
Assert.Same(a, b); // 复用同一模块
Assert.Equal(2, reg.RefCount("hello", 1));
}
[Fact]
public void Release_ToZero_WithoutSupersede_KeepsLoaded()
{
var reg = NewRegistry();
var m = reg.Acquire("hello", 1);
reg.Release(m);
Assert.Equal(0, reg.RefCount("hello", 1));
Assert.True(reg.IsLoaded("hello", 1)); // 未被淘汰则保留以复用
}
[Fact]
public void NewVersion_Supersedes_Old_And_OldUnloadsWhenRefZero()
{
var reg = NewRegistry();
var v1 = reg.Acquire("hello", 1); // v1 引用=1
var v2 = reg.Acquire("hello", 2); // 取 v2 → v1 被标记淘汰
Assert.True(reg.IsLoaded("hello", 1));
Assert.True(reg.IsLoaded("hello", 2));
reg.Release(v1); // v1 引用归零且被淘汰 → 卸载并移除
Assert.False(reg.IsLoaded("hello", 1));
Assert.True(reg.IsLoaded("hello", 2)); // v2 仍在
}
[Fact]
public void Acquire_FailedNewVersionLoad_DoesNotSupersedeExisting()
{
var reg = NewRegistry();
var v1 = reg.Acquire("hello", 1);
Assert.ThrowsAny<System.Exception>(() => reg.Acquire("hello", 99)); // v99 不存在,加载抛出
reg.Release(v1); // v1 引用归零
Assert.True(reg.IsLoaded("hello", 1)); // 未被错误淘汰,仍保留
}
[Fact]
public void Release_MoreThanAcquired_Throws()
{
var reg = NewRegistry();
var m = reg.Acquire("hello", 1);
reg.Release(m);
Assert.Throws<System.InvalidOperationException>(() => reg.Release(m));
}
}
}
- Step 2: 运行,确认红
Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter GameModuleRegistryTests
Expected: 编译失败(GameModuleRegistry 不存在)。
- Step 3: 写
GameModuleRegistry.cs
using System;
using System.Collections.Generic;
namespace XWorld.Server.Host
{
// (gameId,version) -> 已加载模块;按引用计数获取/释放,旧版本被新版本淘汰且引用归零时卸载
public sealed class GameModuleRegistry
{
private sealed class Entry
{
public LoadedGameModule Module;
public int RefCount;
public bool Superseded;
}
private readonly GameModuleLoader _loader;
private readonly string _gamesRoot;
private readonly Dictionary<string, Entry> _entries = new Dictionary<string, Entry>();
public GameModuleRegistry(GameModuleLoader loader, string gamesRoot)
{
_loader = loader; _gamesRoot = gamesRoot;
}
private static string Key(string gameId, int version) => $"{gameId}@{version}";
public LoadedGameModule Acquire(string gameId, int version)
{
string key = Key(gameId, version);
if (!_entries.TryGetValue(key, out var e))
{
// 先加载(失败则直接抛出,不污染任何状态:旧版本不会被错误淘汰)
var loaded = _loader.Load(_gamesRoot, gameId, version);
e = new Entry { Module = loaded, RefCount = 0, Superseded = false };
_entries[key] = e;
// 加载成功后,再把同 gameId 的更旧版本标记淘汰(引用归零即卸载)
foreach (var kv in _entries)
{
if (kv.Value.Module.GameId == gameId && kv.Value.Module.Version < version)
kv.Value.Superseded = true;
}
}
e.RefCount++;
return e.Module;
}
public void Release(LoadedGameModule module)
{
string key = Key(module.GameId, module.Version);
if (!_entries.TryGetValue(key, out var e)) return;
if (e.RefCount <= 0)
throw new InvalidOperationException($"重复释放模块 {module.GameId}@{module.Version}");
e.RefCount--;
if (e.RefCount <= 0 && e.Superseded)
{
_entries.Remove(key);
module.Unload(); // 卸载;GC 回收交由运行时(内存回收已在 Task 8 验证)
}
}
public int RefCount(string gameId, int version)
=> _entries.TryGetValue(Key(gameId, version), out var e) ? e.RefCount : 0;
public bool IsLoaded(string gameId, int version) => _entries.ContainsKey(Key(gameId, version));
}
}
- Step 4: 运行,确认绿
Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter GameModuleRegistryTests
Expected: PASS。
Task 10: 房间宿主 RoomHost(集成 TDD)
Files:
-
Create:
Server/Server.Host/RoomHost.cs -
Test:
Server/Server.Host.Tests/RoomHostTests.cs -
Step 1: 写失败测试
RoomHostTests.cs
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class RoomHostTests
{
private sealed class Capture : IRoomOutput
{
public List<NetMessage> Broadcasts = new List<NetMessage>();
public void Broadcast(NetMessage m) => Broadcasts.Add(m);
public void SendTo(int playerId, NetMessage m) { }
}
private static readonly IReadOnlyList<PlayerInfo> Players =
new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } };
private static int LastCounter(Capture cap)
{
var last = cap.Broadcasts[cap.Broadcasts.Count - 1];
var r = new XWorld.Framework.Protocol.PacketReader(last.Payload);
r.ReadVarInt(); // tick
return r.ReadVarInt(); // counter
}
private static RoomHost NewHost()
=> new RoomHost(new GameModuleRegistry(new GameModuleLoader(), TestGames.Root), new ConsoleLogger());
[Fact]
public void CreateRoom_TickToEnd_ReleasesModule()
{
var host = NewHost();
var cap = new Capture();
var cfg = new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = 1 };
var room = host.CreateRoom("A", "hello", 1, cfg, Players, cap);
Assert.Equal(1, host.ActiveRoomCount);
for (int i = 0; i < 3; i++) host.TickAll(1f); // Hello 第 3 tick 自结束
Assert.Equal(0, host.ActiveRoomCount);
Assert.False(room.IsActive);
Assert.Equal(3, LastCounter(cap)); // v1 起始 0 → 3 次 +1 = 3
}
[Fact]
public void Deliver_AddsInput_AffectsCounter()
{
var host = NewHost();
var cap = new Capture();
var cfg = new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = 1 };
host.CreateRoom("A", "hello", 1, cfg, Players, cap);
var w = new XWorld.Framework.Protocol.PacketWriter();
w.WriteVarInt(5);
host.DeliverTo("A", 1, new NetMessage(0, w.ToArray())); // 本 tick 增量 = 5
host.TickAll(1f); // counter: 0 + 5 = 5
Assert.Equal(5, LastCounter(cap));
}
[Fact]
public void CreateRoom_DuplicateRoomId_Throws()
{
var host = NewHost();
var cfg = new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = 1 };
host.CreateRoom("dup", "hello", 1, cfg, Players, new Capture());
Assert.Throws<System.InvalidOperationException>(
() => host.CreateRoom("dup", "hello", 1, cfg, Players, new Capture()));
}
}
}
- Step 2: 运行,确认红
Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RoomHostTests
Expected: 编译失败(RoomHost 不存在)。
- Step 3: 写
RoomHost.cs
using System;
using System.Collections.Generic;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class RoomHost
{
private readonly GameModuleRegistry _registry;
private readonly ILogger _logger;
private readonly Dictionary<string, RoomEntry> _rooms = new Dictionary<string, RoomEntry>();
private struct RoomEntry { public Room Room; public LoadedGameModule Module; }
public RoomHost(GameModuleRegistry registry, ILogger logger)
{
_registry = registry; _logger = logger;
}
public int ActiveRoomCount
{
get { int n = 0; foreach (var kv in _rooms) if (kv.Value.Room.IsActive) n++; return n; }
}
public Room CreateRoom(string roomId, string gameId, int version, RoomConfig config,
IReadOnlyList<PlayerInfo> players, IRoomOutput output)
{
if (_rooms.ContainsKey(roomId))
throw new InvalidOperationException($"房间 ID 已存在: {roomId}");
var module = _registry.Acquire(gameId, version);
try
{
IGameServerRoom logic = module.CreateRoom();
var clock = new ManualClock();
Room room = null;
var ctx = new RoomCtx(
new DeterministicRandom(config.Seed),
clock, _logger, new InMemoryStorage(),
output, () => room.RequestEnd());
room = new Room(roomId, logic, clock, ctx, _logger);
_rooms[roomId] = new RoomEntry { Room = room, Module = module };
room.Start(players);
if (!room.IsActive) ReleaseRoom(roomId); // Start 内即结束/故障则立即回收
return room;
}
catch
{
// 构建失败(如游戏构造函数抛异常):释放已 Acquire 的模块,避免引用泄漏
if (_rooms.ContainsKey(roomId)) ReleaseRoom(roomId);
else _registry.Release(module);
throw;
}
}
public void TickAll(float dt)
{
List<string> ended = null;
foreach (var kv in _rooms)
{
var room = kv.Value.Room;
if (!room.IsActive) { (ended ??= new List<string>()).Add(kv.Key); continue; }
room.Tick(dt);
if (!room.IsActive) (ended ??= new List<string>()).Add(kv.Key);
}
if (ended != null)
foreach (var id in ended) ReleaseRoom(id);
}
public void DeliverTo(string roomId, int playerId, NetMessage message)
{
if (_rooms.TryGetValue(roomId, out var entry))
entry.Room.Deliver(playerId, message);
}
private void ReleaseRoom(string roomId)
{
if (!_rooms.TryGetValue(roomId, out var entry)) return;
_rooms.Remove(roomId);
_registry.Release(entry.Module);
}
}
}
- Step 4: 运行,确认绿
Run: bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RoomHostTests
Expected: PASS。
Task 11: 端到端热更/版本共存集成测试(验收)
Files:
-
Test:
Server/Server.Host.Tests/HotReloadEndToEndTests.cs -
Step 1: 写测试
HotReloadEndToEndTests.cs
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class HotReloadEndToEndTests
{
private sealed class Capture : IRoomOutput
{
public List<NetMessage> Broadcasts = new List<NetMessage>();
public void Broadcast(NetMessage m) => Broadcasts.Add(m);
public void SendTo(int playerId, NetMessage m) { }
}
private static readonly IReadOnlyList<PlayerInfo> Players =
new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } };
private static int LastCounter(Capture cap)
{
var last = cap.Broadcasts[cap.Broadcasts.Count - 1];
var r = new PacketReader(last.Payload);
r.ReadVarInt();
return r.ReadVarInt();
}
[Fact]
public void V1AndV2_Coexist_RunDifferentCode_ThenV1UnloadsAfterRoomEnds()
{
var registry = new GameModuleRegistry(new GameModuleLoader(), TestGames.Root);
var host = new RoomHost(registry, new ConsoleLogger());
var capA = new Capture();
var capB = new Capture();
var cfg1 = new RoomConfig { GameId = "hello", Version = 1, Seed = 1, PlayerCount = 1 };
var cfg2 = new RoomConfig { GameId = "hello", Version = 2, Seed = 1, PlayerCount = 1 };
host.CreateRoom("A", "hello", 1, cfg1, Players, capA); // 老房间用 v1
host.CreateRoom("B", "hello", 2, cfg2, Players, capB); // 新房间用 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 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.sln0 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)。