第04章 - 核心契约-动作驱动运行时
4.1 五个契约文件:类型是这套架构的地基
@mmf/game-kit 不写业务,它只定义「外壳、关卡模块、模板运行时、审计脚本」之间必须共同遵守的形状。整包是五个文件加一个汇总出口 index.ts(逐条 export *,最后再 export type { LevelDetail, LevelSummary, ToolSpec } from '@mmf/content'):
| 文件 | 定义什么 | 谁在用 |
|---|---|---|
ids.ts |
一行 export * from '@mmf/content/ids' |
全仓;避免 id 常量出现两份事实 |
runtime.ts |
LevelManifest / LevelContext / LevelModule / LevelRuntime / LevelAction / RunSnapshot / Verdict |
外壳、模板、关卡模块、审计 |
events.ts |
LevelEventMap 与 DebugOptions |
外壳(事件上报)、e2e(调试桥) |
save.ts |
SaveData / LevelProgress / EvidenceDraft / EvidenceEvent / ArtifactSummary |
存档、证据、报告 |
services.ts |
PlatformServices 与它包含的七个能力接口 |
平台实现、关卡上下文 |
关卡模块的契约只有五个成员,边界非常窄:
export interface LevelModule {
manifest: LevelManifest;
/** 纯运行时:不碰 DOM;外壳与审计都用它 */
createRuntime(detail: LevelDetail, opts: RuntimeOptions): LevelRuntime;
/** 挂载画布(如有)并接上同一个 runtime;无画布关卡实现为空 */
mount(ctx: LevelContext, runtime: LevelRuntime): Promise<void>;
unmount(): Promise<void>;
pause(): void;
resume(): void;
}
这个签名里藏着一个关键决定:createRuntime 在渲染之前就能拿到完整运行时。 因为运行时是纯 TS 状态机(不 import Phaser / Vue / DOM),外壳可以先建 runtime、再渲染工具台(ToolDock 只需要 runtime 与 detail.tools)、最后才 mount 画布。三个好处:无画布关卡的 chunk 里可以完全没有 Phaser;工具台按内容声明的 detail.tools 渲染,不需要关卡模块导出 UI;审计与 e2e 可以不进浏览器就验证判定。
LevelContext 是「挂载期」那一包东西:container / canvasHost(无画布时是 null)、manifest、已加载并合并语言的 detail、services(PlatformServices)、locale、reducedMotion、completedBefore、可选 debug,再加两个回传口子 emit(type, payload) 与 requestExit(reason)。两者都是回调,不是返回值:画布不能自己决定「退出关卡」或「写一条完成事件」,它只能把事实喊出来,由外壳决定路由与落盘——这是「平台出口唯一」在挂载期的延伸。
4.2 LevelAction:唯一的写入入口
运行时状态只能通过动作改变。动作表集中在 ActionPayloadMap,再用映射类型折叠成一个可辨识联合:
export type LevelAction = {
[K in keyof ActionPayloadMap]: { type: K; payload: ActionPayloadMap[K] };
}[keyof ActionPayloadMap];
一张表就是一个工具/模板能做的全部事情。节选(工具前缀即 ToolId,宿主动作没有前缀):
export interface ActionPayloadMap {
'pattern/inspect': { index: number };
'pattern/setCycle': { cycle: string[]; transform: PatternTransform };
/** 可选带规则:预览只写"预测",不该把已提交状态打掉(玩家常先提交再点预览)。 */
'pattern/preview': { cycle?: string[]; transform?: PatternTransform };
'pattern/commit': Record<string, never>;
'construct/place': { piece: PiecePlacementInput };
'rule/place': { block: ProgramBlock; at: number };
'knob/set': { key: string; value: number };
'probe/sample': { n?: number };
'counter/edit': { patch: CounterPatch };
/** 宿主统一动作:所有模板都必须处理 */
'run': Record<string, never>;
'submit': Record<string, never>;
'reset': Record<string, never>;
}
三条纪律写在 docs/02 §4.4 里:
- 先加类型、再加处理分支、最后才让 UI 派发。 动作表里没有的类型不能出现在内容里——那会绕过类型检查,autoplay 与真实操作就不再是同一条路径。
- 表里只有实现过的动作。 早期文档列过
construct/rotate、rule/setParam等,落地时没有使用,已从表和文档里删掉;保留「计划中的动作」会让dispatch的default分支变成垃圾桶。 type的前缀就是ToolId。 所以共享运行时能用一行代码统计「这件工具被用了几次」:
function toolOf(type: string): ToolId | null {
const prefix = type.split('/')[0];
return (TOOL_IDS as readonly string[]).includes(prefix) ? (prefix as ToolId) : null;
}
一个容易被误读的点:'run' 是宿主动作,不属于任何工具。但 2026-11 的交互简化轮把宿主独立的「运行」按钮删掉了,run 与 submit 合并成一次「完成」,所以多数关卡 UI 只发 submit;run 仍留在契约里,供需要「先跑一次看指标」的模拟类关卡与 actionsFor 生成的黄金解使用。
4.3 dispatch:所有交互都走这一条路
LevelRuntime 只有五个成员,dispatch 是唯一的写入口:
export interface LevelRuntime {
readonly detail: LevelDetail;
snapshot(): RunSnapshot;
subscribe(listener: (snapshot: RunSnapshot) => void): () => void;
dispatch(action: LevelAction): Promise<ActionResult>;
/** 自动通关:内部仍逐条 dispatch,不允许直接改状态 */
autoplay(): Promise<ActionResult>;
reset(): Promise<void>;
}
ActionResult 把「运行时拒绝了」与「判定没通过」分开:ok: false 表示动作本身不合法(比如「这一格不是断开的地方」),message 是给人看的短句;ok: true 而 verdict.passed === false 表示动作执行了、结论是「还没通过」——这是正常流程,不是错误。
共享运行时 createRuntimeWithAdapter 把 dispatch 分两段:宿主动作(submit / run / reset)由共享层统一处理,其余动作转交模板的 apply;工具动作还会顺手累加 toolUsage(meta.usage[tool].used += 1、lastAction),有 result.evidence 就 emit 一条证据草稿,最后 notify() 推一份新快照。模板只写「动作 → 状态」,不写「快照怎么发、证据怎么写、运行次数怎么数」。
submit 分支是本项目最值得逐行读的一段,因为它把「三道门」写成了代码。判定顺序是 2026-11 修订的重点,源码里留着理由:
// 判定顺序(docs/01 §5.1 的修订版 + docs/12 P0-1):
// 1) 一件产物都没有:给出缺失证据(audit A1 要求无工具提交必须被拒且带缺失证据);
// 2) 有产物:先跑结构校验。真实错误必须优先于缺步骤提示,
// 以前 missingEvidence 在前,把 network/schedule/frontier 写好的失败反馈全顶掉了;
// 3) 结构正确但证据步骤没走完:仍然按缺失证据处理(守住 A8 与内容门禁)。
对应的骨架(省略日志与 notify):
const artifact = adapter.artifact(meta.state);
const missing = missingEvidence();
if (artifact === null) { // ① 空产物
if (missing.length === 0) return fail('还差一个可以提交的结构。');
return { ok: true, verdict: missingVerdict(missing), snapshot: snapshot() };
}
const verdict = adapter.validate(detail, BASE_VARIANT, artifact);
if (!verdict.passed) { // ② 结构错误优先上屏
verdict.missingEvidence = missing; // 缺步骤降级为第二行
recordRun('fail', verdict.failure?.class);
return { ok: true, verdict, snapshot: snapshot() };
}
if (missing.length > 0) { // ③ 结构对但步骤没走完
return { ok: true, verdict: missingVerdict(missing), snapshot: snapshot() };
}
const transfer = transferCheck(artifact); // ④ 迁移门:逐个变体验一遍
if (!transfer.passed) { /* failure.class = 模板声明的迁移失败类,带 variantId */ }
为什么顺序重要?旧版把「缺步骤」放在结构校验之前,玩家答错时永远先看到「先完成工具步骤:<内部检查点名>」,真实错误(「你的规则和左边墙对不上」)被顶掉。改动之后,`network` / `schedule` / `frontier` 写进内容的失败反馈第一次在正常 UI 里可达。内部检查点名>
迁移门还有个防「白送印章」的细节:通过之后才盖 transfer 印章,而且只在模板真的声明了变体时才盖——if ((adapter.transferVariants?.(detail) ?? []).length > 0 && !verdict.stamps.includes('transfer'))。早先 argue 的旋转迁移门恒真(黑白计数在旋转下不变),rule 的旋转门同理,两关都在没有迁移意义的情况下发了印章。现在的规矩是:门必须能拒绝,才配发印章。
最后是 autoplay,它短得几乎不像功能,却撑起整个审计体系:
async function autoplay(): Promise<ActionResult> {
const solution = detail.solutions[0];
if (!solution) return { ok: false, reason: 'not-ready', message: '这一关还没有黄金解。', snapshot: snapshot() };
const actions = adapter.actionsFor(detail, BASE_VARIANT, solution.artifact);
let submitted = false;
let last: ActionResult = { ok: true, snapshot: snapshot() };
for (const action of actions) {
if (action.type === 'submit') submitted = true;
last = await dispatch(action); // ← 逐条走玩家的同一条路径
if (!last.ok) return last;
}
if (!submitted) last = await dispatch({ type: 'submit', payload: {} });
return last;
}
actionsFor 的输入是产物(不是动作脚本),由模板把它翻译成动作序列。方向很重要:内容里只需写「黄金解长什么样」,不需要维护动作录制文件;模板改了动作语义,actionsFor 跟着改,内容不用动。
4.4 RunSnapshot:两个视图读同一份事实
画布(Phaser)与工具台(Vue)不是两套状态,而是同一份快照的两个消费者:
export interface RunSnapshot {
levelId: LevelId;
variantId: string;
phase: 'idle' | 'observing' | 'editing' | 'running' | 'failed' | 'passed';
artifacts: ArtifactSummary[];
toolState: Partial<ToolStateMap>; // 工具编辑态;未开放的工具不出现
toolUsage: Record<ToolId, ToolUsageState>;
checkpoints: Record<string, boolean>;
runs: RunRecord[]; // 最近 10 次
lastRun?: RunRecord;
verdict?: Verdict;
argument?: ArgumentSnapshot; // 只有 argue 模板会出现
stamps: StampId[];
revision: number;
updatedAt: number;
}
它的消费方式在两端各有一句注释镇着。运行时侧是 // 快照是浅拷贝:toolUsage 逐工具复制,避免监听方拿到的旧快照被后续动作改写。,对应 toolUsage: cloneUsage(meta.usage);外壳侧(WorkshopView.vue)是 // detail / snapshot 都是大对象整棵替换,不能进深层 reactive,对应 const snapshot = shallowRef<...> 与 const runtime = shallowRef<LevelRuntime | null>(null)。
这是同一条纪律:快照是不可变事实,不是可变模型。 每次 notify() 重建对象,监听方整棵替换;不做深层 reactive,因为把运行时塞进 Vue 深层代理会让每帧比较整棵对象图,而且工具台的回写会污染运行时状态。
| 视图 | 读什么 | 绝不做什么 |
|---|---|---|
画布(templates/src/<t>/scene.ts) |
toolState(画什么)、checkpoints(亮哪些提示)、verdict(失败点高亮) |
不直接改状态、不写存档、不自己掷骰子 |
工具台(toolkit-ui/src/ToolDock.vue) |
toolState(回显编辑态)、artifacts(产物列表)、toolUsage |
不直接改状态、不写存档 |
工具台的四个 prop 就说明了这件事:tools(本关开放的工具)、runtime、detail、locale;它订阅快照的方式只有一句 props.runtime.subscribe((next) => { snapshot.value = next; })。它不认识关卡、不认识模板,只知道「当前开放哪几件工具」和「运行时给我什么快照」。所以给 ToolDock 加一件新工具不需要改外壳,换布局也不需要改工具。
4.5 Verdict、missingEvidence 与 EvidenceDraft:判定的两种输出
一次 dispatch 会同时产生两种「说法」,服务不同的人:Verdict 说给玩家听(过没过、断在哪、还缺哪一步),EvidenceDraft 说给证据系统听(这一次做了什么、用了哪件工具、结果如何)。
export interface Verdict {
passed: boolean;
stamps: StampId[]; // constraint / simple / transfer / argument
failure?: FailureReport;
missingEvidence: EvidenceRequirement[];
metrics?: Record<string, number>;
artifacts: ArtifactSummary[];
/** 迁移门的逐变体结果;没有迁移门的关卡不出现 */
variantResults?: { id: string; passed: boolean }[];
}
export interface FailureReport {
class: string; // 模板定义的失败类,如 'rule-not-general'
variantId?: string; // 迁移门失败时指出是哪张地图
causes: FailureCause[]; // 指向具体格子/步骤/约束/结论
}
export interface FailureCause {
kind: 'cell' | 'step' | 'constraint' | 'variant' | 'claim' | 'sample';
ref: string;
messageKey: string;
params?: Record<string, string | number>;
}
failure.class 不是自由字符串:每个模板在 logic.ts 里导出 FAILURE_CLASSES,内容必须为「该关实际可触发」的每个类提供 feedback { messageKey, hintKey, focus },content:check 拿模板的 reachableFailureClasses(script) 去比对。focus 给界面用:高亮哪个冲突格、哪一步、哪张变体图。
missingEvidence 的每一项都刻意写成「世界里应该发生什么」,而不是「还差一步」的命令句(字段注释原话:/** 世界里应该发生什么,而不是"还差一步"的命令句 */),结构是 { checkpointId, phase, messageKey, actionHint }。它由 evidencePlan 与模板的 checkpoints(state, ctx) 算出来:遍历计划、把没满足的挑出来。注意检查点的语义在 2026-11 被收窄了——checkpoint 只记录「答案状态的事实」(填了几块、跑过一次、对照过数据),不再要求玩家按固定顺序走仪式,也不再上屏成「第 n 步 / 共 N 步」进度条。完成面板那行「通过 M 个检查点」是事实统计,不是任务清单。
证据那一侧是草稿制,运行时只产出草稿,不知道 profileId、语言与路由:
export interface EvidenceDraft {
phase: EvidencePhase; // observe / hypothesize / construct / run /
// result / revise / generalize / argue
variantId: string;
tool?: ToolId;
action: string;
artifact?: ArtifactSummary; // { id, kind, digest, summary }
outcome?: 'info' | 'pass' | 'fail';
failureClass?: string;
metrics?: Record<string, number>;
scaffold?: Scaffold; // none / env / tool / demo:提示等级
}
共享运行时的 emit 只补一件事——variantId,然后交给外壳注入的回调:opts.onEvidence?.({ ...draft, variantId: BASE_VARIANT.id })。id / profileId / at 由外壳补齐后成为 EvidenceEvent(save.ts 里 EvidenceEvent extends EvidenceDraft),再变成 ProgressManager.recordEvidence 的一批写入、四维画像的一格、思维日志的一条。执行纪律很明确:运行时不知道 profileId、语言、路由;这些由外壳注入。 完整链路在第 14–16 章。
平台那一侧只有八个字段(services.ts):storage / evidenceStore / audio / files / preferences / lifecycle / telemetry(只写本地调试事件,不上传)/ now()(唯一时钟)。任何要碰浏览器的地方都得从这里走。
4.6 模板 runtime 怎么注册:适配器,不是大一统 reduce
六个模板的状态形态差别很大:程序 AST、盘面、指标表、论证证书。早期方案想用 init / reduce / solve 统一它们,落地时发现只会造出一堆 switch (template)。现在的做法(docs/02 §6.1):每个模板自己管状态,只把共享运行时需要的那几件事以适配器形式交出来。
export interface RuntimeAdapter<S> {
initialState(detail: LevelDetail, now: number): S;
/** 只处理工具动作;run/submit/reset 由共享运行时统一处理 */
apply(state: S, action: LevelAction): AdapterResult<S>;
artifact(state: S): unknown | null; // 没有产物时返回 null(提交会被拒)
artifactSummaries(state: S): ArtifactSummary[];
checkpoints(state: S, ctx: TemplateCheckpointContext): Record<string, boolean>;
toolState(state: S): Partial<ToolStateMap>;
/** 用最终产物直接校验(审计/多解/编辑器用,不依赖动作回放) */
validate(detail: LevelDetail, variant: RuntimeVariant, artifact: unknown): Verdict;
/** 黄金解 → 动作序列(autoplay 用;必须走与玩家相同的 dispatch 路径) */
actionsFor(detail: LevelDetail, variant: RuntimeVariant, artifact: unknown): LevelAction[];
transferVariants?(detail: LevelDetail): RuntimeVariant[];
transferFailureClass?(detail: LevelDetail): string;
argumentState?(state: S): ArgumentSnapshot | undefined;
}
validate 与 actionsFor 成对存在,是为了两种速度:审计要快速验证「这个产物是否满足约束、是否多解」,不需要回放动画;autoplay 要真实走一遍动作链,保证 e2e 测的是玩家路径。两者共用同一套约束检查函数,避免判定漂移。
createRuntimeWithAdapter 负责把这些零件组装成 LevelRuntime:submit / run / reset / autoplay、证据草稿、快照与订阅全在共享层,模板只管 S。注册一个模板是三步:
templates/<template>/runtime.ts实现适配器,并导出createXxxRuntime(detail, opts),函数体是一行return createRuntimeWithAdapter(detail, opts, adapter);templates/<template>/index.ts导出defineXxxLevel(manifest),返回LevelModule;- 关卡模块只需要 manifest:
// games/forge/src/levels/k-structure-pattern-path.ts
const manifest: LevelManifest = {
id: 'k-structure-pattern-path', band: 'k', thread: 'structure',
template: 'pattern', designSize: DESIGN, estimatedMinutes: 5, hasCanvas: true,
};
export default definePatternLevel(manifest);
definePatternLevel 自己就能说明「模块 = 纯运行时 + 可选画布」:它闭包持有一个 game,createRuntime 直接返回 createPatternRuntime(detail, opts);mount 里 if (!ctx.canvasHost) return; 之后才 createPhaserGame(ctx.canvasHost, { designSize: manifest.designSize, scenes: [createPatternScene(ctx, runtime)] });unmount 必须 game?.destroy(true)(连同 canvas 一起销毁,否则路由切换会残留画布与 rAF);pause / resume 只做 game?.loop.sleep() 与 wake()。
一个诚实的例外:pattern 的 runtime 没有走 createRuntimeWithAdapter,它自己写了一整套 dispatch(packages/templates/src/pattern/runtime.ts)。原因写在源码注释里:
// 真迁移门:内容里带 scenario.symbols 的变体(换表征),submit 时逐条重验。
// pattern 自管 runtime(不像 rule/construct 走 shared/runtime 的 adapter),所以门要在这里接。
这条自管路径要遵守与共享运行时同语义的判定顺序(空产物 → 结构校验 → 缺证据 → 迁移门),否则审计会看到两套行为。它是历史遗留的单点分叉,读代码时不要当成第二种范式;其余模板(rule / construct / simulate / experiment / argue)都走适配器。
4.7 确定性:注入种子与时钟,禁掉两件「随手可用」的东西
证据、报告、autoplay、审计全都要求「同一输入 → 同一输出」。所以纯逻辑层被禁掉两件最容易破坏确定性的事:
// eslint.config.js(节选)
{
files: ['packages/toolkit/src/**/*.ts', 'packages/templates/src/**/*.logic.ts'],
rules: {
'no-restricted-properties': ['error',
{ object: 'Math', property: 'random', message: '纯逻辑层禁止 Math.random,请注入种子。' },
{ object: 'Date', property: 'now', message: '纯逻辑层禁止 Date.now,请注入 now。' },
],
},
}
替代品都是显式注入的。随机数只有一个允许的来源(packages/toolkit/src/shared/rng.ts):mulberry32(seed) 返回一个 () => number 的闭包,内部只用 Math.imul 做整数混合,最后 / 4294967296 归一到 [0, 1)——全程没有 Math.random。种子由外壳按「关卡 + 档案」算一个稳定值(apps/site/src/shell/seed.ts),用 FNV-1a 而不是随机数:
export function seedFor(levelId: string, profileId: string): number {
const input = levelId + '|' + profileId;
let hash = 2166136261;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) % 1_000_000_000;
}
它通过 RuntimeOptions 交给运行时,与时钟一起:
export interface RuntimeOptions {
seed: number;
now: () => number;
locale: Locale;
reducedMotion: boolean;
/** 只允许 DEV / webdriver 打开;生产分享链接不得带 autoplay */
autoplay?: boolean;
/** 证据出口:运行时只写草稿,落盘由外壳负责 */
onEvidence?: (draft: EvidenceDraft) => void;
}
now() 只在两处被用来「记录事实」:RunRecord.at / durationMs 与 RunSnapshot.updatedAt。判定逻辑里不出现时间——时间可以进记录,不能进判定,否则同一份产物在不同时刻会得到不同结论,审计就失去意义。产物摘要同理:digest 用同一套 FNV-1a 哈希,并对 JSON.stringify 返回 undefined 的情况退成空串(产物来自玩家输入,缺字段是常态)。
纪律清单可以直接拿来做代码评审的检查项:
| 要求 | 怎么检查 |
|---|---|
纯逻辑层 0 处 Math.random / Date.now |
lint 的 no-restricted-properties(错误级) |
游戏/工具代码 0 处 localStorage / fetch / indexedDB |
lint 的 no-restricted-globals(错误级) |
| 同一档案进同一关得到同一序列 | seedFor 的 FNV-1a + 单测 |
| autoplay 与真实操作同路径 | autoplay 内部逐条 dispatch + 审计 A8 |
| 证据可复现 | 种子注入 + actionsFor 只依赖产物 |
4.8 本章要点
@mmf/game-kit只有五个文件:ids(再导出常量)、runtime(模块/上下文/动作/快照/判定)、events(事件与调试开关)、save(存档与证据类型)、services(平台接口)。LevelModule边界很窄:createRuntime出纯运行时,mount才碰 DOM/Phaser;工具台按detail.tools渲染,不依赖模块导出 UI。LevelAction是唯一写入口,动作表只列实现过的动作;纪律是「先加类型、再加分支、最后让 UI 派发」。dispatch把宿主动作(submit/run/reset)收在共享运行时,其余交给模板适配器;submit的顺序是「空产物 → 结构校验 → 缺证据 → 迁移门」,真实错误优先上屏。autoplay内部逐条dispatchactionsFor生成的动作并补一个submit,因此审计与 e2e 测的是玩家那条路径。RunSnapshot是不可变事实:运行时逐工具浅拷贝、外壳用shallowRef整棵替换;画布与工具台只是它的两个消费者。Verdict说给玩家(failure.class+causes+missingEvidence),EvidenceDraft说给证据系统;草稿进、事件出,profileId与时间由外壳补。- 模板通过
RuntimeAdapter+createRuntimeWithAdapter注册(pattern自管 runtime 是唯一例外);确定性靠注入种子(mulberry32+seedFor)与注入时钟(now()),并由 lint 禁止Math.random/Date.now。