Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
@@ -0,0 +1,14 @@
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}");
}
}
@@ -0,0 +1,12 @@
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;
}
}
@@ -0,0 +1,11 @@
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; }
}
}
+33
View File
@@ -0,0 +1,33 @@
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; } // 例如 "RPS.Server.dll"
public string ServerEntryType { get; set; } // 例如 "RPS.Server.RpsServerRoom"
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));
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
namespace XWorld.Server.Host
{
// 可卸载上下文:框架契约走默认上下文(类型标识跨界一致),小游戏私有 DLL 载入本上下文。
// DLL 一律字节加载(LoadFromStream):不持有文件句柄,同版本覆盖重发不被文件锁挡住。
// 代价:字节加载的 Assembly.Location 为空——小游戏 DLL 不得依赖它。
internal sealed class GameModuleAlc : AssemblyLoadContext
{
private readonly string _moduleDir;
public GameModuleAlc(string name, string moduleDir) : base(name, isCollectible: true)
{
_moduleDir = moduleDir;
}
protected override Assembly Load(AssemblyName assemblyName)
{
// 设计 §4.2:框架契约类型必须在默认 ALC,不随小游戏卸载。
// 约束:XWorld.Framework.Shared 只能依赖 BCLSystem.*);若将来加第三方依赖,需同步扩展下面白名单。
if (assemblyName.Name == "XWorld.Framework.Shared")
return null;
// BCL / 运行时程序集始终走默认上下文,防止游戏目录里同名文件遮蔽(类型标识错乱)
string n = assemblyName.Name;
if (n != null && (n.StartsWith("System.", System.StringComparison.Ordinal) ||
n.StartsWith("Microsoft.", System.StringComparison.Ordinal) ||
n == "System" || n == "netstandard" || n == "mscorlib"))
return null;
// 小游戏私有程序集放在版本目录里,载入本可卸载上下文
string candidate = Path.Combine(_moduleDir, assemblyName.Name + ".dll");
if (File.Exists(candidate))
return LoadBytes(candidate);
return null; // 其余交默认上下文
}
public Assembly LoadEntry(string serverDllPath) => LoadBytes(serverDllPath);
private Assembly LoadBytes(string path)
{
using (var ms = new MemoryStream(File.ReadAllBytes(path)))
return LoadFromStream(ms);
}
}
}
+80
View File
@@ -0,0 +1,80 @@
using System;
using System.IO;
using System.Reflection;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class GameModuleLoader
{
private readonly string _publicKeyPem;
public GameModuleLoader(string publicKeyPem = null)
{
_publicKeyPem = publicKeyPem;
}
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}");
if (_publicKeyPem != null)
VerifySignature(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}");
if (manifest.MinFrameworkVersion > XWorld.Framework.FrameworkInfo.Version)
throw new InvalidOperationException(
$"小游戏要求最小框架版本 {manifest.MinFrameworkVersion},当前框架 {XWorld.Framework.FrameworkInfo.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;
}
}
// 注意:签名清单仅覆盖 ServerAssembly/CoreDll/game.json(当前小游戏 = Core+Server 两 DLL,完整覆盖)。
// 若将来小游戏带私有第三方 DLL,发布工具(PublishLayout)与此处校验须同步把它们纳入清单。
private void VerifySignature(string moduleDir)
{
string filesTxt = Path.Combine(moduleDir, "files.txt");
string sigPath = Path.Combine(moduleDir, "files.txt.sig");
if (!File.Exists(filesTxt) || !File.Exists(sigPath))
throw new InvalidOperationException($"缺少签名清单,拒绝加载: {moduleDir}");
var fm = XWorld.PublishTool.FilesManifest.Parse(File.ReadAllText(filesTxt));
if (!XWorld.PublishTool.ManifestSigner.Verify(fm.Digest(), File.ReadAllBytes(sigPath), _publicKeyPem))
throw new InvalidOperationException($"签名验证失败,拒绝加载: {moduleDir}");
foreach (var kv in fm.Entries)
{
string p = Path.Combine(moduleDir, kv.Key);
if (!File.Exists(p) || XWorld.PublishTool.Md5Util.OfFile(p) != kv.Value.Md5)
throw new InvalidOperationException($"文件 md5 不符或缺失,拒绝加载: {kv.Key}");
}
}
}
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace XWorld.Server.Host
{
// (gameId,version) -> 已加载模块;按引用计数获取/释放。
// 淘汰有两个来源:①同 gameId 更高版本加载成功;②同 (gameId,version) 磁盘内容指纹变化(覆盖重发)。
// 被淘汰且引用归零即卸载;内容变化时新旧模块并存至旧房间结束(_retired,Release 按实例识别)。
// 生产环境版本不可变、指纹恒命中缓存,行为与旧实现一致。
public sealed class GameModuleRegistry
{
private sealed class Entry
{
public LoadedGameModule Module;
public int RefCount;
public bool Superseded;
public string Fingerprint;
}
private readonly GameModuleLoader _loader;
private readonly string _gamesRoot;
private readonly Dictionary<string, Entry> _current = new Dictionary<string, Entry>();
private readonly List<Entry> _retired = new List<Entry>();
public GameModuleRegistry(GameModuleLoader loader, string gamesRoot)
{
_loader = loader; _gamesRoot = gamesRoot;
}
private static string Key(string gameId, int version) => $"{gameId}@{version}";
private string ModuleDir(string gameId, int version) => Path.Combine(_gamesRoot, gameId, version.ToString());
public LoadedGameModule Acquire(string gameId, int version)
{
string key = Key(gameId, version);
if (_current.TryGetValue(key, out var e))
{
// 覆盖重发检测:磁盘内容指纹变化 → 旧条目退役、加载新内容。
// 指纹算不出(目录处于覆盖交换的瞬间窗口/被删)→ 视为未变化,用缓存兜底。
string fp = ComputeFingerprintOrNull(ModuleDir(gameId, version));
if (fp == null || fp == e.Fingerprint)
{
e.RefCount++;
return e.Module;
}
var reloaded = LoadEntry(gameId, version); // 失败直接抛出:旧条目原样保留,不污染状态
Retire(e);
_current[key] = reloaded;
SupersedeOlderVersions(gameId, version);
reloaded.RefCount++;
return reloaded.Module;
}
var loaded = LoadEntry(gameId, version);
_current[key] = loaded;
SupersedeOlderVersions(gameId, version);
loaded.RefCount++;
return loaded.Module;
}
public void Release(LoadedGameModule module)
{
string key = Key(module.GameId, module.Version);
if (_current.TryGetValue(key, out var e) && ReferenceEquals(e.Module, module))
{
if (e.RefCount <= 0)
throw new InvalidOperationException($"重复释放模块 {module.GameId}@{module.Version}");
e.RefCount--;
if (e.RefCount <= 0 && e.Superseded)
{
_current.Remove(key);
module.Unload();
}
return;
}
// 退役条目:同版本被覆盖重发淘汰后仍被旧房间持有,按实例识别
for (int i = 0; i < _retired.Count; i++)
{
if (!ReferenceEquals(_retired[i].Module, module)) continue;
var r = _retired[i];
if (r.RefCount <= 0)
throw new InvalidOperationException($"重复释放模块 {module.GameId}@{module.Version}");
r.RefCount--;
if (r.RefCount <= 0)
{
_retired.RemoveAt(i);
module.Unload();
}
return;
}
// 未知模块的释放忽略(与旧实现对不存在 key 直接 return 的行为一致)
}
public int RefCount(string gameId, int version)
=> _current.TryGetValue(Key(gameId, version), out var e) ? e.RefCount : 0;
public bool IsLoaded(string gameId, int version) => _current.ContainsKey(Key(gameId, version));
private Entry LoadEntry(string gameId, int version)
{
// 指纹在加载前采样:加载成功即与所载内容对应(单线程 tick actor 下无并发写窗口)
string fp = ComputeFingerprintOrNull(ModuleDir(gameId, version));
var loaded = _loader.Load(_gamesRoot, gameId, version);
return new Entry { Module = loaded, RefCount = 0, Superseded = false, Fingerprint = fp };
}
private void SupersedeOlderVersions(string gameId, int version)
{
foreach (var kv in _current)
if (kv.Value.Module.GameId == gameId && kv.Value.Module.Version < version)
kv.Value.Superseded = true;
}
private void Retire(Entry e)
{
e.Superseded = true;
if (e.RefCount <= 0) e.Module.Unload();
else _retired.Add(e);
}
// 目录内容指纹:全部 *.dll + game.json 按名 Ordinal 排序拼 name=md5; 再整体 md5
// 读不到(目录不存在/IO 竞态)返回 null 由调用方兜底
private static string ComputeFingerprintOrNull(string dir)
{
try
{
if (!Directory.Exists(dir)) return null;
var names = Directory.GetFiles(dir, "*.dll")
.Select(Path.GetFileName)
.Append("game.json")
.Where(n => File.Exists(Path.Combine(dir, n)))
.Distinct()
.OrderBy(n => n, StringComparer.Ordinal);
var sb = new StringBuilder();
foreach (var n in names)
sb.Append(n).Append('=')
.Append(XWorld.PublishTool.Md5Util.OfFile(Path.Combine(dir, n))).Append(';');
return XWorld.PublishTool.Md5Util.OfBytes(Encoding.UTF8.GetBytes(sb.ToString()));
}
catch (IOException) { return null; }
catch (UnauthorizedAccessException) { return null; }
}
}
}
+11
View File
@@ -0,0 +1,11 @@
using XWorld.Framework;
namespace XWorld.Server.Host
{
// 房间出站消息的去向;本子项目无网络,用它对接测试捕获,2b 接 WS 会话
public interface IRoomOutput
{
void Broadcast(NetMessage message);
void SendTo(int playerId, NetMessage message);
}
}
+31
View File
@@ -0,0 +1,31 @@
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; }
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;
}
}
}
+81
View File
@@ -0,0 +1,81 @@
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;
}
public RoomEndResult EndResult { get; private set; }
// 对外发布用的结算结果:故障房间的 EndResult 不可信(可能是故障前残留),按无结果处理
public RoomEndResult TrustedEndResult => FaultedOut ? null : EndResult;
// 由 RoomCtx.EndRoom 回调(或宿主)触发;下一次 tick 末尾或 Start 后据此结束。
// 多次调用以最后一次的 result 为准。
// publicRoomHost 与测试在装配 RoomCtx 的 endRoom 回调时需引用它(跨程序集)。
public void RequestEnd() => RequestEnd(null);
public void RequestEnd(RoomEndResult result) { _endRequested = true; EndResult = result; }
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 { /* 异常路径,吞掉二次异常 */ }
}
}
}
+28
View File
@@ -0,0 +1,28 @@
using System;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class RoomCtx : IRoomCtx
{
private readonly IRoomOutput _output;
private readonly Action<RoomEndResult> _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<RoomEndResult> 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(null);
public void EndRoom(RoomEndResult result) => _endRoom(result);
}
}
+94
View File
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using XWorld.Framework;
namespace XWorld.Server.Host
{
// TickAll 的结束房间条目:携带小游戏经 EndRoom(result) 给出的结算结果(可为 null)
public readonly struct EndedRoom
{
public readonly string RoomId;
public readonly RoomEndResult Result;
public EndedRoom(string roomId, RoomEndResult result) { RoomId = roomId; Result = result; }
}
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, r => room.RequestEnd(r));
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 IReadOnlyList<EndedRoom> TickAll(float dt)
{
List<EndedRoom> ended = null;
foreach (var kv in _rooms)
{
var room = kv.Value.Room;
if (!room.IsActive) { (ended ??= new List<EndedRoom>()).Add(new EndedRoom(kv.Key, room.TrustedEndResult)); continue; }
room.Tick(dt);
if (!room.IsActive) (ended ??= new List<EndedRoom>()).Add(new EndedRoom(kv.Key, room.TrustedEndResult));
}
if (ended != null)
foreach (var e in ended) ReleaseRoom(e.RoomId);
return ended ?? (IReadOnlyList<EndedRoom>)System.Array.Empty<EndedRoom>();
}
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);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<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" />
<ProjectReference Include="..\PublishTool\PublishTool.csproj" />
</ItemGroup>
</Project>