1702 lines
62 KiB
Markdown
1702 lines
62 KiB
Markdown
# 服务端房间宿主 + 可卸载 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`)。`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 清单解析 GameManifest(TDD)
|
||
|
||
**Files:**
|
||
- Create: `Server/Server.Host/GameManifest.cs`
|
||
- Test: `Server/Server.Host.Tests/GameManifestTests.cs`
|
||
|
||
- [ ] **Step 1: 写失败测试 `GameManifestTests.cs`**
|
||
|
||
```csharp
|
||
using System.IO;
|
||
using Xunit;
|
||
using XWorld.Server.Host;
|
||
|
||
namespace XWorld.Server.Host.Tests
|
||
{
|
||
public class GameManifestTests
|
||
{
|
||
private const string Valid = @"{
|
||
""gameId"": ""hello"",
|
||
""version"": 2,
|
||
""serverAssembly"": ""Hello.Server.dll"",
|
||
""serverEntryType"": ""Hello.Server.HelloServerRoom"",
|
||
""minFrameworkVersion"": 1,
|
||
""playerCount"": 1,
|
||
""tickRateHz"": 10
|
||
}";
|
||
|
||
[Fact]
|
||
public void Parse_Valid_ReadsAllFields()
|
||
{
|
||
var m = GameManifest.Parse(Valid);
|
||
Assert.Equal("hello", m.GameId);
|
||
Assert.Equal(2, m.Version);
|
||
Assert.Equal("Hello.Server.dll", m.ServerAssembly);
|
||
Assert.Equal("Hello.Server.HelloServerRoom", m.ServerEntryType);
|
||
Assert.Equal(1, m.MinFrameworkVersion);
|
||
Assert.Equal(1, m.PlayerCount);
|
||
Assert.Equal(10, m.TickRateHz);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_CaseInsensitive_FieldNames()
|
||
{
|
||
var m = GameManifest.Parse(@"{""GameId"":""x"",""Version"":1,""ServerAssembly"":""X.dll"",""ServerEntryType"":""X.Y""}");
|
||
Assert.Equal("x", m.GameId);
|
||
Assert.Equal(1, m.Version);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(@"{""version"":1,""serverEntryType"":""a"",""serverAssembly"":""a.dll""}")] // 缺 gameId
|
||
[InlineData(@"{""gameId"":""x"",""version"":1,""serverAssembly"":""a.dll""}")] // 缺 entryType
|
||
[InlineData(@"{""gameId"":""x"",""version"":1,""serverEntryType"":""a""}")] // 缺 serverAssembly
|
||
[InlineData(@"{""gameId"":""x"",""version"":0,""serverEntryType"":""a"",""serverAssembly"":""a.dll""}")] // version 非正
|
||
public void Parse_Invalid_Throws(string json)
|
||
{
|
||
Assert.Throws<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 后据此结束。
|
||
// 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`**
|
||
|
||
```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 只能依赖 BCL(System.*);若将来加第三方依赖,需同步扩展下面白名单。
|
||
if (assemblyName.Name == "XWorld.Framework.Shared")
|
||
return null;
|
||
|
||
// BCL / 运行时程序集始终走默认上下文,防止游戏目录里同名文件遮蔽(类型标识错乱)
|
||
string n = assemblyName.Name;
|
||
if (n != null && (n.StartsWith("System.", System.StringComparison.Ordinal) ||
|
||
n.StartsWith("Microsoft.", System.StringComparison.Ordinal) ||
|
||
n == "System" || n == "netstandard" || n == "mscorlib"))
|
||
return null;
|
||
|
||
string candidate = Path.Combine(_moduleDir, assemblyName.Name + ".dll");
|
||
if (File.Exists(candidate))
|
||
return LoadFromAssemblyPath(candidate);
|
||
|
||
return null; // 其余交默认上下文
|
||
}
|
||
|
||
public Assembly LoadEntry(string serverDllPath) => LoadFromAssemblyPath(serverDllPath);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 写 `LoadedGameModule.cs`**
|
||
|
||
```csharp
|
||
using System;
|
||
using XWorld.Framework;
|
||
|
||
namespace XWorld.Server.Host
|
||
{
|
||
public sealed class LoadedGameModule
|
||
{
|
||
public string GameId { get; }
|
||
public int Version { get; }
|
||
public Func<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); // 新房间用 v2(v1 被淘汰)
|
||
|
||
Assert.True(registry.IsLoaded("hello", 1));
|
||
Assert.True(registry.IsLoaded("hello", 2));
|
||
|
||
for (int i = 0; i < 3; i++) host.TickAll(1f); // 两房各 3 tick 后自结束
|
||
|
||
// v1 起始 0 → 3;v2 起始 100 → 103:两份不同代码并发运行
|
||
Assert.Equal(3, LastCounter(capA));
|
||
Assert.Equal(103, LastCounter(capB));
|
||
|
||
// 老房间结束 → v1 引用归零且被淘汰 → ALC 卸载并移除;v2 保留
|
||
Assert.False(registry.IsLoaded("hello", 1));
|
||
Assert.True(registry.IsLoaded("hello", 2));
|
||
Assert.Equal(0, host.ActiveRoomCount);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行,确认绿**
|
||
|
||
Run: `bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter HotReloadEndToEndTests`
|
||
Expected: PASS —— 验证「版本共存(不同 IL 并发)→ 老房间跑完老逻辑 → 老版本卸载移除 → 新版本保留」闭环(设计 §4.2 / §9 第 2 步验收)。
|
||
|
||
---
|
||
|
||
## Task 12: 收尾——全量验证
|
||
|
||
**Files:**
|
||
- 无新增;运行完整套件。
|
||
|
||
- [ ] **Step 1: 生成夹具并跑全量测试**
|
||
|
||
Run:
|
||
```bash
|
||
bash Server/build-test-games.sh
|
||
dotnet test Server/Server.sln
|
||
```
|
||
Expected: 全绿。包含子项目 1 的 46 个测试(PacketCodec/FrameCodec/DeterministicRandom/FrameworkMessages)与本子项目新增的 GameManifest/RoomCtx/Room/GameModuleLoader/AlcUnload/GameModuleRegistry/RoomHost/HotReloadEndToEnd 各测试,失败 0。
|
||
|
||
- [ ] **Step 2: 构建零警告确认**
|
||
|
||
Run: `dotnet build Server/Server.sln -v minimal`
|
||
Expected: `Build succeeded`,0 error / 0 warning。
|
||
|
||
---
|
||
|
||
## 完成判据(Definition of Done)
|
||
|
||
- [ ] `bash Server/build-test-games.sh && dotnet test Server/Server.sln` 全绿。
|
||
- [ ] `dotnet build Server/Server.sln` 0 error / 0 warning。
|
||
- [ ] ALC 加载:能按 `(gameId,version)` 从 `games/<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)。
|