Magic Game Harness — ModApi 契约面解读
源码位置:
Assets/GameFramework/ModApi/8 个文件 / ~410 行(spec 列了 11 个子命名空间,但只 4 个有内容,其余先建空目录等后续 plan 填充) 这是 Mod 作者唯一能引用的程序集——spec 第 8.2 节明文规定Game.ModApi不依赖Game.Core.Context。
0. ModApi 在整个框架里的位置
按 spec 第 8.2 节的依赖方向:
Game.Core.Primitives (无依赖)
↓
Game.ModApi (公共契约 — 只依赖 Primitives)
↓
[Context / Rules / Networking / Persistence / HotUpdate] (不依赖 ModApi)
↓
Game.Bootstrap (组合根)为什么 ModApi 不依赖 Context? 因为 Mod 作者不应该看到 Context Runtime 的具体类型——spec 第 6.1 节:"Public contracts, replaceable implementations"。
Mod 作者的视角:
Mod 程序集 (作者写的 DLL)
│
│ 引用
↓
Game.ModApi.dll ← 唯一依赖
Game.Core.Primitives.dll ← 唯一依赖
(编译时用 SDK 里的这两个 DLL,运行时由 HybridCLR 加载)Mod 作者拿不到:
- ❌
Game.Core.Context.*(fiber registry、依赖解析) - ❌
Game.Core.Networking.*(NGO 桥) - ❌
Game.Core.Persistence.*(存档) - ❌
Game.Core.HotUpdate.*(HybridCLR 加载) - ❌
Game.Bootstrap.*(VContainer scope) - ❌
Game.Framework.Editor(框架开发工具) - ❌
Game.Framework.Conformance(测试夹具)
1. 11 个 spec 子命名空间 vs 4 个已实现
spec 第 11.1 节的命名空间策略:
Game.ModApi
Lifecycle ← ✅ 已实现
Capabilities ← ✅ 已实现
Content ← ⏳ 空目录
UI ← ⏳ 空目录
Input ← ⏳ 空目录
Storage ← ⏳ 空目录
Networking ← ⏳ 空目录
Diagnostics ← ✅ 已实现(2 文件)
AI ← ⏳ 空目录
Packaging ← ⏳ 空目录
Versioning ← ✅ 已实现(1 文件)为什么先实现 4 个——它们是 Module 加载生命周期 + 能力声明 + 版本这条最小链路上必需的:
| 子命名空间 | 用途 | 没有它会怎样 |
|---|---|---|
| Lifecycle | IModule.ActivateAsync/DeactivateAsync | Mod 根本无法被加载 |
| Capabilities | CapabilityRequirement/Provision | 依赖解析无法工作 |
| Versioning | ModuleVersion | 无法判断 Mod 升级是否兼容 |
| Diagnostics | IModuleDiagnostics | Mod 出错时无法报告 |
Content / UI / Input / Storage / Networking / AI / Packaging 都是领域相关的接口——spec 说 "genre-neutral contracts",等具体产品类型确定后再设计(避免预先承诺超出当前需要的 API surface)。
2. 公共类型清单(spec 硬约束)
测试 ModApiShapeTests.cs 锁定了 11 个 required public types:
static readonly string[] RequiredPublicTypes =
{
"Game.ModApi.Versioning.ModuleVersion",
"Game.ModApi.Capabilities.CapabilityVersion",
"Game.ModApi.Capabilities.CapabilityRequirement",
"Game.ModApi.Capabilities.CapabilityProvision",
"Game.ModApi.Lifecycle.ModuleDescriptor",
"Game.ModApi.Lifecycle.ModuleOperationError",
"Game.ModApi.Lifecycle.ModuleOperationResult",
"Game.ModApi.Lifecycle.IModule",
"Game.ModApi.Lifecycle.IModuleContext",
"Game.ModApi.Diagnostics.ModuleDiagnostic",
"Game.ModApi.Diagnostics.IModuleDiagnostics",
};
[TestCaseSource(nameof(RequiredPublicTypes))]
public void Required_public_contract_type_exists(string fullName)
{
var assembly = AppDomain.CurrentDomain
.GetAssemblies()
.Single(candidate => candidate.GetName().Name == "Game.ModApi");
var type = assembly.GetType(fullName, throwOnError: false);
Assert.That(type, Is.Not.Null, $"Required Phase 1 Mod API type is missing: {fullName}");
Assert.That(type.IsPublic || type.IsNestedPublic, Is.True, $"Required Mod API type is not public: {fullName}");
}测试用反射 + Single 锁定——如果将来有人误改 public → internal,这 11 个测试立刻全红。
这是非常强的契约保护——任何一个改名/改可见性的 PR 都会被 CI 拦截。
3. Lifecycle 命名空间 — Mod 的入口
文件:Lifecycle/IModule.cs (26 行)
3.1 IModule 接口
/// <summary>
/// Public module lifecycle contract. A future Context Runtime owns calls, ordering, cancellation, and context lifetime.
/// Implementations run on the Unity main thread unless a future contract explicitly states otherwise.
/// </summary>
public interface IModule
{
ModuleDescriptor Descriptor { get; }
Task<ModuleOperationResult> ActivateAsync(IModuleContext context, CancellationToken cancellationToken);
Task<ModuleOperationResult> DeactivateAsync(CancellationToken cancellationToken);
}3 个关键设计点:
(a) "A future Context Runtime owns..."
当前 Context Runtime 还没实现——IModule 接口只定义了"Mod 怎么被调用",没定义"被谁调用、按什么顺序、被谁取消"。这就是 spec 第 10.4 节 Context Runtime 要做的事。
接口仍然先定——这样 Context Runtime 实现时,接口是稳定的契约。
(b) "Implementations run on the Unity main thread"
Mod 作者被告知——他们的 ActivateAsync 主体必须在 Unity 主线程跑。这与 Bootstrap 里 ApplicationLifecycleCoordinator / Session 的主线程守门对偶——两边都强制 main-thread-only。
(c) 两个方法的"不对称"
Task<ModuleOperationResult> ActivateAsync(IModuleContext context, CancellationToken cancellationToken);
Task<ModuleOperationResult> DeactivateAsync(CancellationToken cancellationToken);ActivateAsync 收 IModuleContext——因为激活时需要"我能注册能力、订阅事件、发诊断"的能力。 DeactivateAsync 不收——因为上下文在 deactivation 时已经 invalidate 了(spec 第 10.6 节:"Logical unload"):
/// Implementations must not retain the context after deactivation.
如果 Mod 想要 deactivate 之后还能报告诊断——必须用 Mod 自己保存的 reference,不能再用 context.Diagnostics。
3.2 IModuleContext 接口
public interface IModuleContext
{
ModuleId ModuleId { get; }
IModuleDiagnostics Diagnostics { get; }
}只有 2 个属性——没有 service locator、没有 capability 注册、没有事件总线。
这是故意的——spec 第 6 节:"Context-mediated effects return inverses or disposable handles"。
如果 Mod 想注册能力——它应该通过 Context Runtime 的注册方法(RegisterCapability<T>(...)),而不是通过 IModuleContext 拿一个万能解析器。当前接口没有暴露这些注册方法——因为 Context Runtime 还没实现,未来添加时会扩展这个接口(或加新接口),但不会改 IModule 的形状。
Diagnostics 字段让 Mod 发诊断——但具体怎么路由到 DiagnosticRouter,由 Context Runtime 决定(通过 Bootstrap/ModuleDiagnosticsAdapter.cs 里的实现)。
3.3 IModuleContext 的生命周期
/// <summary>
/// Activation-scoped public context. The future Context Runtime owns its lifetime and calls it on the Unity main thread.
/// It exposes no unrestricted service resolution and becomes invalid after deactivation completes.
/// </summary>3 个生命周期约束:
- 作用域:仅在 activation 期间有效
- 线程:只能在主线程上用
- 失效:deactivation 后失效(虽然 C# 不能强制)
"becomes invalid"——C# 没有"接口生命周期"概念。spec 的保证是契约式的("Mod 作者承诺不这么用"),不是技术上的(运行时检查)。
未来可能的 enforcement:Context Runtime 可以在 ActivateAsync 调用前构造新 context、DeactivateAsync 调用后 dispose context——但Mod 仍可能持有引用。所以文档约束+测试断言(不在 ModApi shape test 里,因为接口行为)就够了。
4. ModuleDescriptor — 不可变 + 防御性 ToArray
文件:Lifecycle/ModuleDescriptor.cs (47 行)
4.1 完整代码
public sealed class ModuleDescriptor
{
public ModuleDescriptor(
ModuleId id,
ModuleVersion version,
IEnumerable<CapabilityRequirement> requiredCapabilities = null,
IEnumerable<CapabilityRequirement> optionalCapabilities = null,
IEnumerable<CapabilityProvision> providedCapabilities = null)
{
if (!id.IsValid) throw new ArgumentException("Module descriptor ID must be valid.", nameof(id));
if (!version.IsValid) throw new ArgumentException("Module descriptor version must be valid.", nameof(version));
Id = id;
Version = version;
var required = requiredCapabilities?.ToArray() ?? Array.Empty<CapabilityRequirement>();
var optional = optionalCapabilities?.ToArray() ?? Array.Empty<CapabilityRequirement>();
var provided = providedCapabilities?.ToArray() ?? Array.Empty<CapabilityProvision>();
if (required.Any(value => !value.IsValid)) throw new ArgumentException("Required capability declarations must be valid.", nameof(requiredCapabilities));
if (optional.Any(value => !value.IsValid)) throw new ArgumentException("Optional capability declarations must be valid.", nameof(optionalCapabilities));
if (provided.Any(value => !value.IsValid)) throw new ArgumentException("Provided capability declarations must be valid.", nameof(providedCapabilities));
RequiredCapabilities = Array.AsReadOnly(required);
OptionalCapabilities = Array.AsReadOnly(optional);
ProvidedCapabilities = Array.AsReadOnly(provided);
}
public ModuleId Id { get; }
public ModuleVersion Version { get; }
public IReadOnlyList<CapabilityRequirement> RequiredCapabilities { get; }
public IReadOnlyList<CapabilityRequirement> OptionalCapabilities { get; }
public IReadOnlyList<CapabilityProvision> ProvidedCapabilities { get; }
}4.2 5 个不变量
id.IsValid+version.IsValid—— 构造时强制验证- 3 个能力集合都
ToArray拷贝 —— 防止外部修改 Any(value => !value.IsValid)验证 —— 不放过单个无效元素Array.AsReadOnly(...)—— 暴露IReadOnlyList<T>但底层是数组(避免装箱)null→Array.Empty<T>()—— 调用者不需要 null check
4.3 IReadOnlyList<T> vs IReadOnlyCollection<T>
public IReadOnlyList<CapabilityRequirement> RequiredCapabilities { get; } // ← 用 IList,不是 ICollection为什么是 IReadOnlyList 而不是 IReadOnlyCollection? 因为后者只允许 Count 和 foreach,前者还允许 this[int index]。Mod 可能要按 index 访问能力声明(比如"我要第 3 个 required capability"),所以 IReadOnlyList 更友好。
4.4 防御性拷贝的测试
var required = new[] { new CapabilityRequirement(CapabilityKey.Parse("test.required"), CapabilityVersion.Parse("1.0.0")) };
// ...
var descriptor = new ModuleDescriptor(..., required, optional, provided);
required[0] = new CapabilityRequirement(CapabilityKey.Parse("test.changed"), CapabilityVersion.Parse("2.0.0")); // ← 改外部数组!
Assert.That(descriptor.RequiredCapabilities[0].Key.ToString(), Is.EqualTo("test.required")); // ← 不受影响ToArray() 的双重作用:
- 拷贝数组 → 防止外部修改
- 拷贝元素 → 但因为
CapabilityRequirement是 readonly struct,元素本身不可变——所以元素拷贝其实只是数组层面的保护
4.5 三种能力的语义
| 字段 | 用途 | spec 章节 |
|---|---|---|
RequiredCapabilities | "必须 resolve 才能激活" | 10.5 "Dependency Reactivity" |
OptionalCapabilities | "如果没 resolve 也能激活" | — |
ProvidedCapabilities | "我激活后会提供这些能力" | 10.5 |
为什么区分 Required 和 Optional?
// 示例:一个游戏内 Mod
public ModuleDescriptor Descriptor => new ModuleDescriptor(
"auth.discord-login", // id
ModuleVersion.Parse("1.0.0"),
requiredCapabilities: new[] { // ← 必须有 Discord API
new CapabilityRequirement("platform.oauth", CapabilityVersion.Parse("1.x"))
},
optionalCapabilities: new[] { // ← 有就用,没有也行
new CapabilityRequirement("ui.notification", CapabilityVersion.Parse("1.x"))
},
providedCapabilities: new[] { // ← 我提供 OAuth UI
new CapabilityProvision("ui.oauth-discord", CapabilityVersion.Parse("1.0.0"))
}
);Activation gate(spec 10.5):
- 解析所有
RequiredCapabilities→ 都成功才允许激活 - 解析
OptionalCapabilities→ 成功或不存在都行 - 解析完后才能看到
ProvidedCapabilities中的 capability keys 在 registry 里出现
5. Capability 命名空间 — 依赖声明
文件:Capabilities/CapabilityContracts.cs (118 行)
5.1 三个类型
public readonly struct CapabilityVersion : IEquatable<CapabilityVersion>, IComparable<CapabilityVersion>
{
public CapabilityVersion(SemanticVersion value) { ... }
// ... Parse/TryParse/ComparePrecedenceTo/CompareTo/Equals/GetHashCode
}
public readonly struct CapabilityRequirement : IEquatable<CapabilityRequirement>
{
public CapabilityRequirement(CapabilityKey key, CapabilityVersion version) { ... }
public CapabilityKey Key { get; }
public CapabilityVersion Version { get; }
}
public readonly struct CapabilityProvision : IEquatable<CapabilityProvision>
{
public CapabilityProvision(CapabilityKey key, CapabilityVersion version) { ... }
public CapabilityKey Key { get; }
public CapabilityVersion Version { get; }
}3 个 readonly struct,实现完全一样的模式(验证 + IEquatable + GetHashCode)。
5.2 为什么有 CapabilityVersion 而不只是用 SemanticVersion?
类型安全 + 意图明确:
// ❌ 没有专门类型
void RegisterCapability(SemanticVersion version) // 不清楚这个 SemVer 表示什么
// ✅ 有专门类型
void RegisterCapability(CapabilityVersion version) // 一眼看出是"能力版本"更精细的好处:未来 CapabilityVersion 可以加额外验证(比如"必须是 X.Y.0 形式"或"必须 ≥ 1.0.0"),而不污染 SemanticVersion。
5.3 为什么 Requirement 和 Provision 分开两个类型?
理论上它们的形状完全一样——(CapabilityKey, CapabilityVersion) 对。为什么要分?
public readonly struct CapabilityRequirement { ... }
public readonly struct CapabilityProvision { ... }3 个好处:
(a) 意图清晰
descriptor.RequiredCapabilities.Add(new CapabilityRequirement(...)); // ← 一眼看出是"声明需求"
descriptor.ProvidedCapabilities.Add(new CapabilityProvision(...)); // ← 一眼看出是"声明提供"(b) 防止混用
// 类型系统阻止这种错误:
descriptor.RequiredCapabilities.Add(new CapabilityProvision(...)); // ← 编译错误(c) 未来可扩展
// 未来 Requirement 可以加额外字段
public readonly struct CapabilityRequirement
{
public CapabilityKey Key { get; }
public CapabilityVersion Version { get; }
public bool IsOptional { get; } // ← 新字段
public string ReasonIfMissing { get; } // ← 错误信息
}而 Provision 可以保持不变——扩展不会波及其他类型。
5.4 Requirement / Provision 是 readonly struct 而不是 class
两个好处:
- 零分配——AOT 友好,IL2CPP 友好
- 隐式不可变——编译器层面保证
Key/Version不能改
Hash code 计算:
public override int GetHashCode() => unchecked((Key.GetHashCode() * 397) ^ Version.GetHashCode());397 是 ReSharper 推荐的"hash combine"质数(<= 65536),^ 是 XOR——简单、稳定、跨进程一致(与 StableStringHash.Combine 同源)。
6. Versioning 命名空间 — Module 版本
文件:Versioning/ModuleVersion.cs (70 行)
6.1 与 Primitives 的 SemanticVersion / ModApiVersion 对比
// Primitives/SemanticVersion.cs(已经讲过)
public readonly struct SemanticVersion : IEquatable<SemanticVersion>, IComparable<SemanticVersion>
public readonly struct ModApiVersion : ... // 包 SemanticVersion
public readonly struct NetworkProtocolVersion : ... // 正整数
public readonly struct ContentCompatibilityVersion : ... // 正整数
// ModApi/Versioning/ModuleVersion.cs
public readonly struct ModuleVersion : IEquatable<ModuleVersion>, IComparable<ModuleVersion>4 个版本类型:
| 类型 | 用途 | 验证规则 |
|---|---|---|
SemanticVersion | 通用 SemVer | 完整 SemVer 2.0.0 |
ModApiVersion | 框架的 Mod API 版本 | 包 SemVer |
NetworkProtocolVersion | 网络协议版本 | 正整数 |
ContentCompatibilityVersion | 内容兼容性版本 | 正整数 |
ModuleVersion | 单个 Mod 的发布版本 | 包 SemVer |
为什么需要这么多类型?——类型系统是 spec 的执行者:
// 编译错误:ModVersion 不能传给 NetworkProtocolVersion
void ConfigureModule(ModuleVersion modVersion, NetworkProtocolVersion netVersion);
// 编译错误:ModApiVersion 和 ModuleVersion 不能混
bool IsCompatible(ModApiVersion framework, ModuleVersion module); // ← 强制传对类型6.2 ModuleVersion 是 wrapper
public ModuleVersion(SemanticVersion value)
{
if (!value.IsValid) throw new ArgumentException("ModuleVersion requires a valid SemanticVersion.", nameof(value));
Value = value;
}
public SemanticVersion Value { get; }整个类型就是 SemanticVersion 的强类型包装——这是 Primitive Obsession 反模式的解药。
7. ModuleOperationResult — 不抛异常,规范化失败
文件:Lifecycle/ModuleOperationResult.cs (65 行)
7.1 设计动机
/// <summary>
/// Represents a completed public module lifecycle operation without leaking private exceptions.
/// </summary>
public readonly struct ModuleOperationResult关键短语:"without leaking private exceptions"——Mod 抛 InvalidCastException 不能跨边界漏出来。
7.2 Success() vs Failure(error)
public static ModuleOperationResult Success() => new ModuleOperationResult(true, null);
public static ModuleOperationResult Failure(ModuleOperationError error)
{
if (!error.IsValid) throw new ArgumentException("Module operation failure requires a valid normalized error.", nameof(error));
return new ModuleOperationResult(false, error);
}3 条不变量:
Success必须Error == null(由IsValid强制)Failure必须Error.IsValid == true(由IsValid强制)default(ModuleOperationResult).IsValid == false(无默认成功)
public bool IsValid => initialized && (Succeeded ? !Error.HasValue : Error.HasValue && Error.Value.IsValid);这是"完备性"验证——Succeeded == true 时 Error 必须 null;Succeeded == false 时 Error 必须 valid。
7.3 ModuleOperationError — "safe caller-facing"
public ModuleOperationError(string code, string message)
{
if (!DiagnosticEventName.TryParse(code, out _))
throw new ArgumentException("Module operation error code must be a canonical lowercase dot-separated identifier.", nameof(code));
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("Module operation error message must be non-empty.", nameof(message));
Code = code;
Message = message;
initialized = true;
}故意不带 Exception 字段——这是"跨边界 safe message"设计:
// Mod 内部抛:
throw new FileNotFoundException("C:/Users/Admin/AppData/Local/.../auth.dll");
// 跨边界给 framework 应该是:
new ModuleOperationError(
"module.activation-failed",
"Failed to load required assembly"); // ← 不要泄露路径!测试断言:
Assert.That(() => new ModuleOperationError("invalid", "message"), Throws.ArgumentException);
Assert.That(() => new ModuleOperationError("module.error", ""), Throws.ArgumentException);"invalid" 不是 namespaced identifier → reject。"" 空消息 → reject。
7.4 为什么 code 是 string 而不是 DiagnosticEventName?
public string Code { get; } // ← string,不是 DiagnosticEventName两个原因:
string在 ModApi 里没暴露DiagnosticEventName类型——避免循环引用- 运行时验证:构造时
DiagnosticEventName.TryParse(code, out _)确保格式正确,但只存 string——Mod 作者不需要 import DiagnosticEventName
测试:
Assert.That(() => new ModuleOperationError("module.error", ""), Throws.ArgumentException);
// ↑ "module.error" 是合法 namespaced identifier,但 message 空 → 拒绝7.5 ModuleOperationResult 不是 Result<T>
spec 6.4 节"Recoverable composition"提到用 IDisposable 句柄——但 ModApi 没有暴露 Result<T>(带成功值的 Result):
// 假设的失败:
// new ModuleOperationResult.Success<int>(returnValue); ← 没这个 API为什么? 因为 ActivateAsync 的"成功"没有值——激活要么成功要么失败。返回 ModuleOperationResult 本身就够了。
未来 GetCapability<T>(key) 这种"读"操作——可能用 Result<T>。但现在 spec 没要求。
8. Diagnostics — Mod 报告错误的渠道
文件:Diagnostics/IModuleDiagnostics.cs (11 行) + Diagnostics/ModuleDiagnostic.cs (54 行)
8.1 接口
public interface IModuleDiagnostics
{
/// <summary>Reports one immutable diagnostic value; invalid values throw before private routing occurs.</summary>
void Report(ModuleDiagnostic diagnostic);
}故意只有一个方法——Report。
故意是同步方法——void Report(...) 而不是 Task ReportAsync(...)。
为什么同步? 因为诊断是"控制面"事件——本质上是 fire-and-forget。如果做成 async,要么诊断 send 会被 IO 阻塞(违背 main-thread 守门),要么要做 queue(增加复杂度)。
8.2 ModuleDiagnostic 构造
public ModuleDiagnostic(
DiagnosticSeverity severity,
DiagnosticEventName name,
CorrelationId correlationId,
FiberId? fiberId = null,
LifecycleEpisodeId? lifecycleEpisodeId = null,
CapabilityKey? capabilityKey = null,
DiagnosticAttributeSet attributes = null,
DiagnosticError? error = null)对比 Game.Core.Primitives.DiagnosticEvent:
| 字段 | DiagnosticEvent (Primitives) | ModuleDiagnostic (ModApi) |
|---|---|---|
timestamp | ✅ 调用方填 | ❌ 由 adapter 填(用 DiagnosticEventFactory) |
severity | ✅ | ✅ |
name | ✅ | ✅ |
correlationId | ✅ | ✅ |
sessionId | ✅ 可选 | ❌ 由 adapter 自动注入 |
moduleId | ✅ 可选 | ❌ 由 adapter 自动注入 |
fiberId | ✅ 可选 | ✅ 可选(Mod 知道的) |
lifecycleEpisodeId | ✅ 可选 | ✅ 可选 |
capabilityKey | ✅ 可选 | ✅ 可选 |
attributes | ✅ | ✅ |
error | ✅ | ✅ |
关键设计:sessionId 和 moduleId 故意从 Mod 作者手里拿掉。
8.3 为什么 ModuleDiagnostic 没有 sessionId/moduleId?
故意防"假冒"——看测试:
[Test]
public void Module_diagnostic_validates_public_values_without_module_or_session_spoofing()
{
// ...
Assert.That(() => new ModuleDiagnostic(DiagnosticSeverity.Information, default, CorrelationId.New()), Throws.ArgumentException);
Assert.That(() => new ModuleDiagnostic(DiagnosticSeverity.Information, DiagnosticEventName.Parse("module.event"), default), Throws.ArgumentException);
}测试名直接说:without_module_or_session_spoofing——Mod 不能给自己打别人的 SessionId。
注入点在 Bootstrap/ModuleDiagnosticsAdapter.cs:
public void Report(ModuleDiagnostic diagnostic)
{
if (diagnostic == null) throw new System.ArgumentNullException(nameof(diagnostic));
router.Emit(eventFactory.Create(
diagnostic.Severity,
diagnostic.Name,
diagnostic.CorrelationId,
sessionId, // ← 由 factory 注入
moduleId, // ← 由 factory 注入
diagnostic.FiberId,
diagnostic.LifecycleEpisodeId,
diagnostic.CapabilityKey,
diagnostic.Attributes,
diagnostic.Error));
}sessionId 和 moduleId 是闭包变量——ModuleDiagnosticsAdapterFactory.Create(moduleId, sessionId) 时捕获,Mod 拿不到这两个参数。
8.4 Activation-scoped 语义
/// <summary>
/// Activation-scoped diagnostic facade owned by the future Context Runtime.
/// Calls are synchronous and may occur only while its module context is valid.
/// </summary>"may occur only while its module context is valid" — Mod 在 deactivate 之后不应该再调用 Diagnostics.Report。但这是契约式约束,不是技术性约束。
测试:
Assert.That(() => new ModuleDiagnostic(DiagnosticSeverity.Information, default, CorrelationId.New()), Throws.ArgumentException);default(DiagnosticEventName) 不可用——所以 Report 永远不会被错误数据成功发出。
9. Conformance — 中性 Mod 夹具
文件:Conformance/NeutralModuleFixture.cs (39 行)
public sealed class NeutralModuleFixture : IModule
{
public NeutralModuleFixture()
{
Descriptor = new ModuleDescriptor(
ModuleId.Parse("test.module"),
ModuleVersion.Parse("0.1.0"),
new[] { new CapabilityRequirement(CapabilityKey.Parse("test.required"), CapabilityVersion.Parse("1.0.0")) },
new[] { new CapabilityRequirement(CapabilityKey.Parse("test.optional"), CapabilityVersion.Parse("1.0.0")) },
new[] { new CapabilityProvision(CapabilityKey.Parse("test.provided"), CapabilityVersion.Parse("1.0.0")) });
}
public ModuleDescriptor Descriptor { get; }
public IModuleContext LastContext { get; private set; }
public Task<ModuleOperationResult> ActivateAsync(IModuleContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
LastContext = context;
return Task.FromResult(ModuleOperationResult.Success());
}
public Task<ModuleOperationResult> DeactivateAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(ModuleOperationResult.Success());
}
}这是一个故意合成的中性 Mod**——spec 第 1 节:
The framework deliverable intentionally contains no genre implementation, gameplay skeleton, official gameplay mod, or community mod. Its behavior is validated by internal synthetic conformance fixtures that exercise lifecycle, dependency, networking, persistence, package, and hot-loading contracts without representing a game genre or shipping as player content.
NeutralModuleFixture 不代表任何游戏类型——它只是证明合约的形状是对的。
为什么需要这个? 因为 Game.ModApi 不依赖 Context Runtime,所以测试不能走完整的"加载 Mod → 激活 → 看效果"流程。只能断言:
- 编译能过(11 个 public 类型存在)
- 构造不抛(验证字段 OK)
IModule能被实例化、能被 activate/deactivate
测试:
[Test]
public async Task Neutral_fixture_expresses_lifecycle_capabilities_context_and_cancellation()
{
var fixture = new NeutralModuleFixture();
var diagnostics = new RecordingModuleDiagnostics();
var context = new TestModuleContext(fixture.Descriptor.Id, diagnostics);
var activation = await fixture.ActivateAsync(context, CancellationToken.None);
var deactivation = await fixture.DeactivateAsync(CancellationToken.None);
Assert.That(activation.Succeeded, Is.True);
Assert.That(deactivation.Succeeded, Is.True);
Assert.That(fixture.LastContext, Is.SameAs(context));
// ...
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.That(
async () => await fixture.ActivateAsync(context, cancellation.Token),
Throws.InstanceOf<OperationCanceledException>());
}ActivateAsync 应该 propagate OperationCanceledException——而不是吞掉 cancel 转成 failure。这与 spec 10.5 节一致:"Cyclic required dependencies remain inactive and produce a diagnosable dependency-cycle error"——cancel 是用户主动操作,不是 failure。
10. 测试密度
| 测试 | 数量 | 覆盖 |
|---|---|---|
ModApiShapeTests | 11 | 反射验证 11 个 public 类型存在 |
ModApiContractTests | 5 | 验证不变量、neutral fixture |
CompatibilityTests(Primitives) | 多 | 已覆盖 ModuleVersion 的 SemVer 行为 |
11 + 5 = 16 个 ModApi 测试——400 行实现 vs 200 行测试(比例 2:1)。密度合理。
11. 与 spec 的对照
| spec 章节 | ModApi 实现 |
|---|---|
| 10.1 Module Specification(id/version/required/optional/provided/permissions) | ModuleDescriptor 部分实现(权限还没加) |
| 10.2 Fiber State | 未实现(Context Runtime 未实现) |
| 10.3 Lifecycle States | IModule.ActivateAsync/DeactivateAsync(底层状态机未实现) |
| 10.5 Dependency Reactivity | CapabilityRequirement/Provision 字段已就位,resolution 算法未实现 |
| 11.2 Stable Contracts | 11 个公共类型全部 public,无 internal 泄漏 |
| 11.5 Activation Contracts | ModuleOperationResult + ModuleOperationError 已就位 |
| 11.6 Capability Contracts | CapabilityVersion/Requirement/Provision 已就位 |
| 11.11 Lifecycle Contracts | IModule 接口已就位 |
| 11.12 Diagnostic Contracts | IModuleDiagnostics + ModuleDiagnostic 已就位 |
ModApi 是 Phase 1 的"契约面"——数据结构 100% 就位,算法(依赖解析、状态机、lifecycle 调度)都在 Context Runtime 的 plan 里。
12. 我对 ModApi 的整体评价
优点
- 极简的公共 surface——11 个类型,没有过度设计
- 类型系统执行 spec——
CapabilityRequirement≠CapabilityProvision、ModuleVersion≠ModApiVersion - 防御性拷贝——
ModuleDescriptor不暴露内部数组 - 构造时验证——所有 readonly struct 都有
IsValid检查 - "safe caller-facing"错误模型——
ModuleOperationError不暴露内部异常 - 反"假冒"设计——ModDiagnostic 没有 sessionId/moduleId
- 测试守门——
ModApiShapeTests用反射 + Single 锁定 11 个 public 类型
局限与可改进点
ModuleDescriptor没有permissions字段——spec 第 10.1 节列了,但当前未实现。后续 Context Runtime 实现时再加。CapabilityRequirement没有"可选"标记——当前通过RequiredCapabilitiesvsOptionalCapabilities区分,没有CapabilityRequirement.IsOptional。当前够用,但如果有"required-or-optional-with-reason"语义时需要扩展。IModuleContext接口故意薄——未来加RegisterCapability<T>等方法时,接口会变——是 breaking change。spec 第 11.2 节"explicit compatibility"原则会要求老 Mod 升级后重编译。- 没有
GetCapability<T>(key)——Context Runtime 实现时大概率要加,类型签名需要仔细设计(是同步还是异步?返回Result<T>还是直接抛?)。 ModuleOperationResult没有T value——读操作("读 capability 值")需要Result<T>。当前没必要,先放着。
可借鉴的设计模式
| 模式 | 适用 | 学习难度 |
|---|---|---|
类型化语义包装(ModuleVersion、CapabilityVersion) | 任何有"多种同形类型"的场景 | 🟢 简单 |
Result<T> + 安全错误模型 | 跨信任边界(Mod ↔ Kernel) | 🟡 中等 |
| readonly struct + 不可变集合 | 高频创建、低频修改的数据 | 🟢 简单 |
反射 + [TestCaseSource] 锁定 API surface | 任何"必须保留的公共类型" | 🟢 简单 |
| "无 X 字段"防止假冒 | 跨信任边界的诊断/审计 | 🟡 中等 |
13. 关键 takeaway
读完 ModApi,最大的认知收获:
"Stable contracts, replaceable implementations" 的"稳定"是字面意思——这 11 个 public 类型一旦发布就是契约。
具体到这个项目:
- Phase 1 完成度:100% 数据结构 + 0% 算法(Context Runtime 是算法层)
- 类型系统是 spec 的执行者——编译期就拦住"用错版本"或"用错 capability 类型"
- 跨信任边界要"safe message"——
ModuleOperationError不带 Exception,避免泄露内部状态
这套模式可以应用到:
- 插件系统(VSCode extensions、Sublime packages)
- 微服务 SDK(client 调用 server,要 safe error)
- 第三方集成(OAuth 提供商、支付网关)
14. 与 Bootstrap 的连接点
ModApi 与 Bootstrap 通过 ModuleDiagnosticsAdapter 连接:
Mod (user code)
↓ IModuleDiagnostics.Report(diagnostic)
ModuleDiagnosticsAdapter (private, in Bootstrap)
↓ DiagnosticRouter.Emit(event)
UnityConsoleDiagnosticSink → Debug.Log(...)Mod 写诊断 → adapter 注入 moduleId/sessionId → 路由到 sink
当前实现:adapter 已经在 Bootstrap/ModuleDiagnosticsAdapter.cs(59 行),但 Context Runtime 未实现,所以 adapter factory(ModuleDiagnosticsAdapterFactory)被注册但没人调用。
等 Context Runtime 实现时:
- 每个激活的 Mod 会拿到一个绑定的
IModuleDiagnostics(实际是ModuleDiagnosticsAdapter) - Mod 调
Report(diagnostic)→ adapter 注入 moduleId + sessionId → 路由
参考链接
- Primitive Obsession 反模式
- Result 类型(C# 讨论)
- Spec 6.1 节"Stable contracts"
- .NET
IReadOnlyList<T>vsIReadOnlyCollection<T> - Reflection in .NET test fixtures
下一步:读 架构守门笔记,看依赖规则怎么用代码强制(Architecture Tests + PublicApiSurface Tests)。