Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
# 服务端运行中热更去锁(字节加载 + 内容指纹失效)实现计划
|
||||
|
||||
> **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:** 同版本覆盖重发不再被文件锁挡住且发布即生效——DLL 字节加载(不锁文件)+ Registry 按磁盘内容指纹自动失效重载。
|
||||
|
||||
**Architecture:** `GameModuleAlc` 两处 `LoadFromAssemblyPath` 改 `File.ReadAllBytes`+`LoadFromStream`(无文件句柄);`GameModuleRegistry` 加内容指纹:`Acquire` 命中缓存时重算磁盘指纹,变化则旧条目退役(`_retired`,旧房间打完引用归零即卸载)、加载新内容。生产环境版本不可变、指纹恒命中,行为零变化。
|
||||
|
||||
**Tech Stack:** .NET 10(Server.Host)、xUnit、复用 `XWorld.PublishTool.Md5Util`(Server.Host 已引用 PublishTool)。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-07-minigame-publish-pipeline-design.md` §4.3.2
|
||||
|
||||
**约定:无 git(不提交,commit 步骤替换为验证 checkpoint)。仓库根 `<repo>` = `D:\UD\AI\AIC#Project`,所有命令在 `<repo>` 下执行。基线:`dotnet test Server/Server.sln` = 182 通过 / 0 失败(若 `Server/Server.Host.Tests/TestGames/` 不存在,先跑 `bash Server/build-test-games.sh`)。测试注意:`TestGames.Root` 是共享只读夹具——**涉及改写文件的测试必须先拷到临时 gamesRoot,不许原地改**。**
|
||||
|
||||
---
|
||||
|
||||
### Task 1: GameModuleAlc 字节加载(去文件锁),TDD
|
||||
|
||||
**Files:**
|
||||
- Create: `Server/Server.Host.Tests/ModuleFileLockTests.cs`
|
||||
- Modify: `Server/Server.Host/GameModuleAlc.cs`
|
||||
|
||||
- [ ] **Step 1.1: 写失败测试** `ModuleFileLockTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
using XWorld.Server.Host;
|
||||
|
||||
namespace XWorld.Server.Host.Tests
|
||||
{
|
||||
// 字节加载去锁:模块加载期间磁盘 DLL 可被覆盖/删除(同版本覆盖重发的前提)
|
||||
public class ModuleFileLockTests
|
||||
{
|
||||
// 把共享只读夹具 rps/1 拷到独立临时 gamesRoot(不许改 TestGames.Root 原件)
|
||||
private static string CopyRps1ToTempRoot()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "games_" + Guid.NewGuid().ToString("N"));
|
||||
string dst = Path.Combine(root, "rps", "1");
|
||||
Directory.CreateDirectory(dst);
|
||||
string src = Path.Combine(TestGames.Root, "rps", "1");
|
||||
foreach (var f in Directory.GetFiles(src))
|
||||
File.Copy(f, Path.Combine(dst, Path.GetFileName(f)));
|
||||
return root;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadedModule_DoesNotLockDlls_OverwriteAndDeleteSucceed()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var module = new GameModuleLoader().Load(root, "rps", 1);
|
||||
try
|
||||
{
|
||||
string serverDll = Path.Combine(root, "rps", "1", "RPS.Server.dll");
|
||||
string coreDll = Path.Combine(root, "rps", "1", "RPS.Core.dll");
|
||||
|
||||
// 加载期间覆盖写入 —— LoadFromAssemblyPath 会锁文件抛 IOException,字节加载应成功
|
||||
byte[] original = File.ReadAllBytes(serverDll);
|
||||
File.WriteAllBytes(serverDll, original);
|
||||
|
||||
// 加载期间删除
|
||||
File.Delete(coreDll);
|
||||
Assert.False(File.Exists(coreDll));
|
||||
|
||||
// 模块本体仍可用(IL 已在内存)
|
||||
var room = module.CreateRoom();
|
||||
Assert.NotNull(room);
|
||||
}
|
||||
finally
|
||||
{
|
||||
module.Unload();
|
||||
try { Directory.Delete(root, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: 跑测试确认失败**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project" && dotnet test Server/Server.Host.Tests -nologo --filter ModuleFileLockTests 2>&1 | tail -6`
|
||||
Expected: FAIL,`IOException`(文件正由另一进程使用)——Windows 上 `LoadFromAssemblyPath` 锁定 DLL。
|
||||
|
||||
- [ ] **Step 1.3: 实现字节加载** —— `GameModuleAlc.cs` 整文件替换为:
|
||||
|
||||
```csharp
|
||||
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 只能依赖 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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.4: 跑测试确认通过 + Server.Host.Tests 全量**
|
||||
|
||||
Run: `dotnet test Server/Server.Host.Tests -nologo 2>&1 | tail -4`
|
||||
Expected: 全 PASS(51 原有 + 1 新 = 52;`AlcUnloadTests` 的弱引用 GC 断言对 LoadFromStream 同样成立)。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: GameModuleRegistry 内容指纹自动失效,TDD
|
||||
|
||||
**Files:**
|
||||
- Create: `Server/Server.Host.Tests/RegistryContentInvalidationTests.cs`
|
||||
- Modify: `Server/Server.Host/GameModuleRegistry.cs`(整文件替换)
|
||||
|
||||
- [ ] **Step 2.1: 写失败测试** `RegistryContentInvalidationTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xunit;
|
||||
using XWorld.Server.Host;
|
||||
|
||||
namespace XWorld.Server.Host.Tests
|
||||
{
|
||||
// 同 (gameId,version) 磁盘内容变化(覆盖重发)→ Registry 自动失效重载;
|
||||
// 旧房间打完旧代码(退役条目引用归零即卸载),新房间用新代码。
|
||||
public class RegistryContentInvalidationTests
|
||||
{
|
||||
private static string CopyRps1ToTempRoot()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "games_" + Guid.NewGuid().ToString("N"));
|
||||
string dst = Path.Combine(root, "rps", "1");
|
||||
Directory.CreateDirectory(dst);
|
||||
string src = Path.Combine(TestGames.Root, "rps", "1");
|
||||
foreach (var f in Directory.GetFiles(src))
|
||||
File.Copy(f, Path.Combine(dst, Path.GetFileName(f)));
|
||||
return root;
|
||||
}
|
||||
|
||||
// 改变内容指纹(game.json 在指纹范围内;追加空白不影响 JSON 解析)
|
||||
private static void TouchContent(string root)
|
||||
{
|
||||
string gameJson = Path.Combine(root, "rps", "1", "game.json");
|
||||
File.AppendAllText(gameJson, " ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameContent_ReturnsCachedInstance()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
|
||||
var a = reg.Acquire("rps", 1);
|
||||
var b = reg.Acquire("rps", 1);
|
||||
Assert.Same(a, b);
|
||||
Assert.Equal(2, reg.RefCount("rps", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentChange_AfterRefZero_ReloadsNewInstance()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
|
||||
var a = reg.Acquire("rps", 1);
|
||||
reg.Release(a); // 引用归零,未淘汰 → 保留缓存
|
||||
TouchContent(root);
|
||||
var b = reg.Acquire("rps", 1);
|
||||
Assert.NotSame(a, b); // 内容变了 → 新模块实例
|
||||
Assert.Equal(1, reg.RefCount("rps", 1));
|
||||
Assert.True(reg.IsLoaded("rps", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentChange_WhileOldActive_OldAndNewCoexist_ReleaseBothCorrect()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
|
||||
var oldM = reg.Acquire("rps", 1); // 旧房间在打
|
||||
TouchContent(root);
|
||||
var newM = reg.Acquire("rps", 1); // 新房间 → 新内容
|
||||
Assert.NotSame(oldM, newM);
|
||||
Assert.Equal(1, reg.RefCount("rps", 1)); // RefCount 只统计当前条目
|
||||
|
||||
reg.Release(oldM); // 旧房间结束:退役条目按实例识别释放,不抛
|
||||
reg.Release(newM);
|
||||
Assert.Equal(0, reg.RefCount("rps", 1));
|
||||
Assert.True(reg.IsLoaded("rps", 1)); // 当前条目未被淘汰 → 保留复用
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentChange_RetiredModule_UnloadsAndReclaims()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var weak = AcquireChangeReleaseOld(root);
|
||||
for (int i = 0; i < 12 && weak.IsAlive; i++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
}
|
||||
Assert.False(weak.IsAlive, "退役模块的 ALC 未被回收 —— 存在泄漏引用");
|
||||
}
|
||||
|
||||
// 独立方法确保局部强引用随返回消失(同 AlcUnloadTests 套路)
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static WeakReference AcquireChangeReleaseOld(string root)
|
||||
{
|
||||
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
|
||||
var oldM = reg.Acquire("rps", 1);
|
||||
TouchContent(root);
|
||||
var newM = reg.Acquire("rps", 1); // oldM 退役
|
||||
var weak = new WeakReference(oldM);
|
||||
reg.Release(oldM); // 退役 + 归零 → 卸载
|
||||
reg.Release(newM);
|
||||
return weak;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingDir_ServesCachedEntry()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
|
||||
var a = reg.Acquire("rps", 1);
|
||||
string dir = Path.Combine(root, "rps", "1");
|
||||
string away = dir + ".away";
|
||||
Directory.Move(dir, away); // 模拟覆盖交换的瞬间窗口
|
||||
try
|
||||
{
|
||||
var b = reg.Acquire("rps", 1); // 指纹算不出 → 用缓存兜底
|
||||
Assert.Same(a, b);
|
||||
}
|
||||
finally { Directory.Move(away, dir); }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: 跑测试确认失败**
|
||||
|
||||
Run: `dotnet test Server/Server.Host.Tests -nologo --filter RegistryContentInvalidationTests 2>&1 | tail -6`
|
||||
Expected: `SameContent/MissingDir` 可能通过(旧实现即缓存),`ContentChange_*` 三个 FAIL(`Assert.NotSame` 失败——旧实现不看内容)。
|
||||
|
||||
- [ ] **Step 2.3: 实现** —— `GameModuleRegistry.cs` 整文件替换为:
|
||||
|
||||
```csharp
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: 跑新测试 + Server.Host.Tests 全量**
|
||||
|
||||
Run: `dotnet test Server/Server.Host.Tests -nologo 2>&1 | tail -4`
|
||||
Expected: 全 PASS(52 + 5 新 = 57)。既有 `GameModuleRegistryTests` 5 个必须原样通过(`Acquire_SameVersionTwice`/`Release_ToZero`/`NewVersion_Supersedes`/`FailedNewVersionLoad`/`Release_MoreThanAcquired`——公共 API 与语义未变)。
|
||||
|
||||
- [ ] **Step 2.5: Checkpoint——全解决方案回归**
|
||||
|
||||
Run: `dotnet test Server/Server.sln -nologo 2>&1 | tail -8`
|
||||
Expected: 0 失败(182 基线 + 6 新 = 188 左右;Gateway 的 RoomHost/ServerLoop 只用 Acquire/Release,零改动)。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 文档同步 + 终验
|
||||
|
||||
**Files:**
|
||||
- Modify: `Tools/PcMiniGameSmoke/README.md`(「运行中热更」小节)
|
||||
|
||||
- [ ] **Step 3.1: 改 README 注意事项**——把
|
||||
|
||||
```markdown
|
||||
注意:网关运行中且某版本已被房间加载时,**覆盖重发同版本**会因 DLL 文件锁失败——
|
||||
运行中更新一律发新版本号(新版本自动淘汰旧版本,旧房间打完即卸载)。
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```markdown
|
||||
**同版本覆盖重发也支持**:服务器 DLL 为字节加载(不锁文件),覆盖落位总能成功;
|
||||
网关按磁盘内容指纹自动失效——下一个新房间即用新代码,已入房玩家打完旧代码后旧模块自动卸载。
|
||||
(生产环境仍建议版本号递增,便于客户端 AB 与服务器 DLL 的版本对应关系可追溯。)
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2: 终验**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "D:/UD/AI/AIC#Project"
|
||||
dotnet test Server/Server.sln -nologo 2>&1 | tail -8
|
||||
```
|
||||
Expected: 0 失败。
|
||||
|
||||
- [ ] **Step 3.3: 人工核对项(交给用户)**:网关运行中(Editor Play 或命令行起 `Gateway.Runner --gamesRoot deploy/minigame`),菜单对同版本"覆盖重发"RPS——落位成功(无文件锁报错);不重启网关再匹配进一局,日志可见模块重新加载(新 ALC)。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- **Spec 覆盖**:§4.3.2 字节加载(Task 1)、指纹失效/退役并存/Release 按实例(Task 2)、README 语义更新(Task 3)✓
|
||||
- **占位符**:无;每步含完整代码/命令 ✓
|
||||
- **类型一致性**:`Entry.Fingerprint`、`_current/_retired`、`ComputeFingerprintOrNull` 在测试与实现间一致;`Md5Util` 来自 Server.Host 已有的 PublishTool 引用(`GameModuleLoader.VerifySignature` 已在用)✓
|
||||
- **测试隔离**:全部改写文件的测试用临时 gamesRoot 拷贝,不碰共享 `TestGames.Root` ✓
|
||||
- **无 git**:commit 步骤替换为验证 checkpoint ✓
|
||||
Reference in New Issue
Block a user