第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 里:

  1. 先加类型、再加处理分支、最后才让 UI 派发。 动作表里没有的类型不能出现在内容里——那会绕过类型检查,autoplay 与真实操作就不再是同一条路径。
  2. 表里只有实现过的动作。 早期文档列过 construct/rotate、rule/setParam 等,落地时没有使用,已从表和文档里删掉;保留「计划中的动作」会让 dispatch 的 default 分支变成垃圾桶。
  3. 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。注册一个模板是三步:

  1. templates/<template>/runtime.ts 实现适配器,并导出 createXxxRuntime(detail, opts),函数体是一行 return createRuntimeWithAdapter(detail, opts, adapter);
  2. templates/<template>/index.ts 导出 defineXxxLevel(manifest),返回 LevelModule;
  3. 关卡模块只需要 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 内部逐条 dispatch actionsFor 生成的动作并补一个 submit,因此审计与 e2e 测的是玩家那条路径。
  • RunSnapshot 是不可变事实:运行时逐工具浅拷贝、外壳用 shallowRef 整棵替换;画布与工具台只是它的两个消费者。
  • Verdict 说给玩家(failure.class + causes + missingEvidence),EvidenceDraft 说给证据系统;草稿进、事件出,profileId 与时间由外壳补。
  • 模板通过 RuntimeAdapter + createRuntimeWithAdapter 注册(pattern 自管 runtime 是唯一例外);确定性靠注入种子(mulberry32 + seedFor)与注入时钟(now()),并由 lint 禁止 Math.random / Date.now。