第17章 - 实战-从零开发一个新关卡

前面 16 章分别拆开了契约、模板、工具台、画布、证据与存档。这一章把它们合起来,回答一个具体问题:明天要上线第 21 关,从空文件到 pnpm verify 全绿,一共要动哪些地方、按什么顺序动、哪一步最容易漏。

20 关是同一套机制长出来的,新增一关的成本本来只该有两件事:写清楚这关训练什么思维操作,再用模板给的脚本把它表达出来。真正让人卡住的是内容之外的登记点——它们分散在近十个文件里,却必须在同一次提交里对齐。少一个,typecheck 会报;错一处,content:check 会报;还有一种最麻烦:门禁都不报,进游戏才发现 404,或者首屏凭空多了 220 KB。

17.1 六个登记点与真实文件清单

“六个登记点”是心智模型,落到文件上其实是九处改动。把它们按”内容 → 代码 → 外壳 → 测试”的顺序排开:

# 登记点 文件 漏了会怎样
1 关卡 id packages/content/src/ids.ts(LEVEL_IDS / SHIPPED_LEVEL_IDS) 内容 schema 的 z.enum(LEVEL_IDS) 直接拒收;Record<ShippedLevelId, …> 全部报缺键
2 摘要 packages/content/src/levels/<id>.json + summaries.ts 的 RAW 表 typecheck 报 Record 缺属性;运行时抛”缺少关卡摘要”
3 详情 packages/content/src/details/<id>.level.json + loadLevel.ts 的 LOADERS + full.ts 的 LEVEL_DETAILS loadLevel() 返回 null,宿主抛「关卡内容没有加载成功」,页面显示加载失败错误态(不白屏,但进不去)
4 关卡模块 games/forge/src/levels/<id>.ts + games/forge/package.json 的子路径导出 动态 import 解析失败,路由进入即报错
5 外壳注册表 apps/site/src/registry.ts 缺键本身已被 Record<ShippedLevelId, …> 挡住;真漏了运行时会抛「关卡没有登记:」
6 测试与内容门禁 content:check / audit:levels / 单测 / e2e 内容或判定不合规却没被拦住,等于把校验推迟到玩家

有一条容易误解的”第 7 处”:games/forge/src/index.ts 不是登记点,而且不能改。它现在只有一个 export {},注释写得很直白:

// games/forge/src/index.ts
/**
 * 关卡入口是**子路径导出**(./k-structure-pattern-path 等,见 package.json exports),
 * 由 apps/site/src/registry.ts 逐条动态 import —— 这样每关是一个独立 chunk,
 * 外壳的 registry 才不会被 20 关的代码拖进首屏。
 *
 * 这里刻意不 re-export 任何关卡模块:一句 export * from './levels/...'
 * 就会让所有关卡(连同 Phaser 画布场景)进入同一个 chunk,docs/02 §7.1 的
 * "外壳不 import Phaser" 与体积门禁都会失守。
 */
export {};

games/forge/src/forge.test.ts 把这条约束钉成了断言(code 不允许 export *、不允许 from './levels/'),顺手还校验了 package.json 的子路径导出与 SHIPPED_LEVEL_IDS 逐条一致。所以第 4 处真正要写的是 package.json,不是 index.ts。

17.2 第 1 处:两套 id 与它们的区别

ids.ts 是整条链路的常量层,刻意不含 zod、不含内容 JSON、不含 JSX,这样外壳和工具都能放心 import 而不会把 zod 拖进首屏。文件里有两套 id:

/** 20 关目标矩阵:内容层唯一事实来源 */
export const LEVEL_IDS = [
  'k-structure-pattern-path',
  'k-algorithm-train',
  // ……共 20 条
] as const;
export type LevelId = (typeof LEVEL_IDS)[number];

/** M0 只发布这一关;M1 扩到 6 关、M2 扩到 20 关 */
export const SHIPPED_LEVEL_IDS = [
  'k-structure-pattern-path',
  'k-data-tally',
  // ……共 20 条
] as const;
export type ShippedLevelId = (typeof SHIPPED_LEVEL_IDS)[number];

新增一关要同时加进两个数组,因为它们的语义不同:

  • LEVEL_IDS 是目标矩阵(5 学段 × 4 主线 = 20),内容 schema 用它做 z.enum,所以 id 不在这里,摘要/详情连解析都过不了;
  • SHIPPED_LEVEL_IDS 是”本次发布包含哪些关”,星图、注册表、单测、e2e、内容体检全部以它为准;content:check --require-all 会拿两者相减,差一关就红。

id 的命名约定是 <band>-<thread>-<slug>,三段的枚举值都在同一个文件里(BANDS、THREADS)。加完之后先跑一次最短的校验:

pnpm typecheck   # 常量层加完,下面的 Record 表会立刻开始报缺键

这一步报错是好事:它把所有必须同步登记的表一次性点出来。

17.3 第 2 处:摘要 JSON 与静态摘要表

摘要只服务星图与列表页,字段少、体积小,所以是静态 import(20 份摘要随首屏一起加载,代价可接受)。最小可用的一份摘要长这样(真实文件 packages/content/src/levels/k-data-tally.json):

{
  "id": "k-data-tally",
  "title": "分类小站",
  "emoji": "🔢",
  "color": "#2f7d78",
  "band": "k",
  "thread": "data",
  "template": "experiment",
  "tools": ["probe"],
  "estimatedMinutes": 5,
  "thinkingTarget": "把一筐图形数清楚,再用统计条说明最多和最少",
  "difficulty": {
    "structure": 1,
    "representation": 2,
    "constraint": 1,
    "transfer": 2
  },
  "unlock": { "after": [] },
  "titleEn": "Sorting Station",
  "thinkingTargetEn": "Sort a basket of shapes and answer most/least with a tally."
}

摘要的 schema(packages/content/src/schema.ts)是 strict() 的,字段名写错不会被静默剥掉,会直接报错——这是刻意的:thinking_target 这种拼写错误如果被忽略,会变成”字段没生效却校验通过”。几条容易踩的上限也在这里:title ≤ 24 字、thinkingTarget ≤ 40 字、estimatedMinutes 3–15、难度四轴各 1–5。

写完文件,还要在 summaries.ts 里登记两处:顶部的静态 import 和 RAW 表。RAW 的键类型是 ShippedLevelId,这就是”少登记一关 typecheck 就报”的机制:

const RAW: Record<ShippedLevelId, unknown> = {
  'k-structure-pattern-path': kPatternPath,
  'k-data-tally': kDataTally,
  // ……一关一行
};

export const LEVEL_SUMMARIES: readonly LevelSummary[] = SHIPPED_LEVEL_IDS.map((id) => {
  const summary = RAW[id] as LevelSummary | undefined;
  if (!summary) throw new Error('缺少关卡摘要:' + id);
  if (summary.id !== id) throw new Error('关卡摘要 id 不一致:' + id);
  return summary;
});

注意这段注释,它说明了为什么运行时不跑 zod:

运行时不跑 zod(构建期由 content:check 校验),否则 zod 会进首屏。

也就是说,schema 的防线在构建期,运行时只保留 id 一致性断言和一句能看懂的报错。作者写内容时不能指望”运行时兜底”,content:check 才是那道门。

17.4 第 3 处:详情 JSON 与作者检查表的 12 行

详情是重头:details/<id>.level.json 一个文件承载了关卡的全部可玩内容,通过 loadLevel() 动态 import 成独立 chunk,因此它的体积直接进”单关 chunk”预算。

// packages/content/src/loadLevel.ts(节选)
const LOADERS: Record<ShippedLevelId, () => Promise<unknown>> = {
  'k-structure-pattern-path': () => import('./details/k-structure-pattern-path.level.json'),
  'k-data-tally': () => import('./details/k-data-tally.level.json'),
  // ……一关一行
};

详情文件的顶层键(以 k-data-tally.level.json 为例,顺序与 schema 一致):

id / title / band / thread / template / thinkingTarget
intro / prompt / done / retry
tools[] / requiredToolArtifacts[]
levelScript
evidencePlan[] / solutions[] / negativeSamples[] / variants[] / feedback{}
badges[]
manualAudit{}
titleEn / thinkingTargetEn / introEn / promptEn / doneEn / retryEn

docs/03 §1 给了”每关必须填满”的 12 行作者模板,它就是这张键表的自然语言版本:

关卡 id:
学段 / 主线 / 模板:
一句话认知目标(thinking target):
情境(scenario):
工具与产物(tools / requiredToolArtifacts):
硬约束与多解空间(constraints / solutions):
可观察后果(what the player sees when it breaks):
失败类与反馈(failure classes / feedback):
证据检查点(evidencePlan):
变体(variants,至少 2,轴不能是数字大小):
徽章映射(badges):
验收 fixtures(至少 2 解 + 3 负例):
作者检查表 落到详情 JSON 的哪里 硬下限
关卡 id id 必须在 LEVEL_IDS 里,且与文件名一致
学段 / 主线 / 模板 band / thread / template 与摘要、manifest 三方一致
认知目标 thinkingTarget ≤ 40 字,星图卡片直接用它
情境 intro / prompt 各 ≤ 120 字;宿主会渲染成情境条
工具与产物 tools[].requiredArtifacts / requiredToolArtifacts 每个必用产物都要有工具声明产出
约束与多解 levelScript + solutions[] ≥ 2 解,且逐条通过 verifier
可观察后果 feedback[].messageKey / hintKey 可达失败类必须都有反馈
失败类与反馈 feedback{} + negativeSamples[] ≥ 3 条负例,失败类名要对得上
证据检查点 evidencePlan[] ≥ 2 阶段,id 唯一,require 非空
变体 variants[] ≥ 2 个,axis 取自 VARIANT_AXES
徽章映射 badges[] 取自 BADGE_IDS
验收 fixtures solutions[] / negativeSamples[] / variants[] 见上三行

三个片段值得单独看,它们决定了 author 写的东西能不能被机器复核:

"evidencePlan": [
  { "id": "sample", "phase": "observe", "require": "probe/sample >= 1",
    "descriptionKey": "要把整筐都采完,再下结论。" }
]

require 是”动作计数 + 比较”的字符串,不是自由文本;它只用于提示,不参与判定(判定在模板 verifier 里)。变体是一份 patch,而不是另一份完整脚本:

{
  "id": "v-fruit",
  "axis": "representation",
  "patch": {
    "categories": [
      { "id": "circle", "label": "苹果", "token": "🍎", "count": 6 }
    ]
  }
}

patch 会被模板的 applyVariant 合并到 levelScript 上;content:check 只验证”patch 可解析”,不验证”变体有意思”——后者是人工复核的事,所以每份详情都必须带 manualAudit:

"manualAudit": {
  "reviewed": true,
  "reviewer": "exp-builder · M1",
  "reviewedAt": "2026-11-01",
  "notesKey": "四类 20 个图形、采集批次 5;统计条与结论的失败类已逐条走查。"
}

reviewedAt 不是 ISO 日期、reviewed 不是 true,content:check 与 pack:check 都会红。这一条是 A5/A10 人工项的唯一机器痕迹:脚本没法判断”这关是不是一道选择题”,只能要求作者签字。

最后别忘了 full.ts——它是 Node CLI 与单测的入口,把 LEVEL_DETAILS 全量静态拼起来(只给脚本用,绝不进首屏):

export const LEVEL_DETAILS: Record<ShippedLevelId, LevelDetail> = {
  'k-structure-pattern-path': kPatternDetail as LevelDetail,
  'k-data-tally': kDataTallyDetail as LevelDetail,
  // ……一关一行
};

17.5 第 4、5 处:关卡模块、子路径导出与外壳注册表

关卡模块是全仓最薄的文件。真实的无画布关卡 k-data-tally 只有 15 行:

// games/forge/src/levels/k-data-tally.ts
import type { LevelManifest } from '@mmf/game-kit';
import { defineExperimentLevel } from '@mmf/templates/experiment';

/** 无画布实验关:情境在工具面板里,designSize 为 0×0,宿主隐藏"运行"。 */
const manifest: LevelManifest = {
  id: 'k-data-tally',
  band: 'k',
  thread: 'data',
  template: 'experiment',
  designSize: { width: 0, height: 0 },
  estimatedMinutes: 5,
  hasCanvas: false,
};

export default defineExperimentLevel(manifest);

有画布的关卡多一行设计尺寸常量,其余同构:

// games/forge/src/levels/k-structure-pattern-path.ts
import { DESIGN } from '@mmf/templates/layout';

const manifest: LevelManifest = {
  id: 'k-structure-pattern-path',
  band: 'k',
  thread: 'structure',
  template: 'pattern',
  designSize: DESIGN,
  estimatedMinutes: 5,
  hasCanvas: true,
};

export default definePatternLevel(manifest);

manifest 里有一半字段与内容重复(band/thread/template/estimatedMinutes),这不是冗余,而是故意留的漂移探测器:forge.test.ts 会逐个比对 manifest 与摘要,任何一处对不上就红。hasCanvas 也有断言——只有 pattern / rule 允许为 true,因为其余模板的正文由 Vue 面板承担。

然后补 games/forge/package.json 的一行:

"exports": {
  ".": "./src/index.ts",
  "./k-data-tally": "./src/levels/k-data-tally.ts"
}

再补外壳注册表 apps/site/src/registry.ts 的一行:

export const registry: Record<ShippedLevelId, LevelLoader> = {
  'k-data-tally': () => import('@mmf/game-forge/k-data-tally'),
};

这两处加起来才构成一条完整的动态 import 链:registry[id]() → 子路径导出 → levels/<id>.ts → 模板工厂。少任何一半,都是一个 “Failed to resolve import” 或者运行时 undefined。 而类型系统只能保证 registry 的键齐(因为它是 Record<ShippedLevelId, …>),不能保证 package.json 里有对应子路径——所以 forge.test.ts 才要读 package.json 逐条比对。

17.6 第 6 处:测试与内容门禁

内容类的东西不会自动”被覆盖”,得知道哪一层在守什么:

门禁 对新关卡做什么 是否自动
pnpm typecheck summaries / loadLevel / full / registry 四张 Record 表缺键即报 自动
pnpm content:check 摘要/详情 schema、标题一致、英文字段零中文、2 解/3 负例/2 变体、反馈覆盖、禁词、unlock 引用 自动
pnpm audit:levels A1 无工具提交被拒、A1b 空产物被结构反馈拒绝、A3 两解产物不同、A4 负例失败类、A6 变体、A8 autoplay 走到 passed 自动
pnpm test content.test.ts 逐关比对摘要/详情;forge.test.ts 比对 manifest 与子路径导出 自动
pnpm e2e(m1.spec.ts) 从 SHIPPED_LEVEL_IDS 枚举,每关两条:autoplay 通关、无工具提交被拒 自动
pnpm e2e(budget.spec.ts) 交互次数预算,BUDGETS 是手写列表 手动补
pnpm e2e(ui-smoke.spec.ts) 工具面板”关键动作后可见区必须变化”,CASES 也是手写 手动补

前五行是”不补就红”,后两行是”不补不会红,但会静默失去保护”。budget.spec.ts 的注释写明了它存在的理由:

/**
 * 交互次数预算(docs/12 §4 / §7-3)。
 * 目的:把"每关最少几次点击"钉成门禁,防止以后又长回仪式步骤或死按钮。
 */

也就是说,一个新关卡如果没进 BUDGETS,将来有人给它加三个仪式按钮也不会被发现。作者提交前应当问自己一句:这关的”最少几次点击”是多少,写进 BUDGETS 了吗?

17.7 最小可跑骨架与校验顺序

下面是一个”能过门禁”的最小骨架,假设新增 id 为 g46-structure-mirror(示例用,仓库里没有这一关)。真正的细节按模板不同而不同,但文件的形状是固定的。

1)packages/content/src/ids.ts 两个数组各加一行:

export const LEVEL_IDS = [
  // ……
  'g46-structure-mirror',
] as const;
export const SHIPPED_LEVEL_IDS = [
  // ……
  'g46-structure-mirror',
] as const;

2)packages/content/src/levels/g46-structure-mirror.json:

{
  "id": "g46-structure-mirror",
  "title": "镜像补墙",
  "emoji": "🪞",
  "color": "#7a5c3e",
  "band": "g46",
  "thread": "structure",
  "template": "pattern",
  "tools": ["pattern", "construct"],
  "estimatedMinutes": 6,
  "thinkingTarget": "从半面墙推出镜像规则,再把缺的格子补成对称",
  "difficulty": { "structure": 2, "representation": 3, "constraint": 2, "transfer": 3 },
  "unlock": { "after": [] },
  "titleEn": "Mirror Wall",
  "thinkingTargetEn": "Infer a mirror rule from half a wall and complete the missing cells."
}

3)summaries.ts 加 import 与 RAW 一行;4)details/g46-structure-mirror.level.json 按 17.4 的键表写满;5)loadLevel.ts 的 LOADERS 与 full.ts 的 LEVEL_DETAILS 各加一行;6)games/forge/src/levels/g46-structure-mirror.ts:

import type { LevelManifest } from '@mmf/game-kit';
import { definePatternLevel } from '@mmf/templates/pattern';
import { DESIGN } from '@mmf/templates/layout';

const manifest: LevelManifest = {
  id: 'g46-structure-mirror',
  band: 'g46',
  thread: 'structure',
  template: 'pattern',
  designSize: DESIGN,
  estimatedMinutes: 6,
  hasCanvas: true,
};

export default definePatternLevel(manifest);

7)games/forge/package.json 加子路径导出;8)apps/site/src/registry.ts 加一行。然后按下面的顺序验证——每一步盯的是不同的东西:

# 1. 类型与登记表:四张 Record 表是否齐
pnpm typecheck

# 2. 内容:schema、双语、2 解 / 3 负例 / 2 变体、反馈覆盖、禁词
pnpm content:check

# 3. 判定与可达性:A1/A1b/A3/A4/A6/A8(含 autoplay 真的走到 passed)
pnpm audit:levels

# 4. 单测:摘要/详情/ manifest / 子路径导出 的漂移检查
pnpm test

# 5. 产物:新关必须切出两个 chunk;顺带量体积
pnpm build && pnpm size
ls apps/site/dist/assets | grep g46-structure-mirror

# 6. 真实浏览器:autoplay + 无工具被拒(自动覆盖),预算与冒烟(手写列表)
pnpm e2e

第 5 步的 ls 是一个很实用的自查:vite.config.ts 的 chunkFileNames 会把关卡模块命名为 assets/level-<id>-[hash].js、详情命名为 assets/content-<id>-[hash].js。如果产物里找不到这两个名字,说明动态 import 没接上(导出或注册表漏了),而不是”构建有问题”。

17.8 常见错法

症状 根因 正确做法
typecheck 报 Property 'xxx' is missing in type 只加了 LEVEL_IDS,没加 SHIPPED_LEVEL_IDS,或反之 两个数组同一次提交里一起加;让编译器逐一指出剩下四张表
content:check 报”目标矩阵缺 1 关” SHIPPED_LEVEL_IDS 里没有它,而 CI 跑的是 --require-all 确认两套 id 都有;本地也用 pnpm content:check --require-all 复跑一次
content:check 报”摘要/详情标题不一致” 两份 JSON 的 title / titleEn 手抖写岔 从摘要复制标题过去,别重打
关卡能进,但画面空白 / 控制台报 resolve 失败 只改了 registry.ts,忘了 package.json 子路径导出(或反之) 两处成对加;用 forge.test.ts 与第 5 步的 ls 双重确认
首屏体积突然变差 在 games/forge/src/index.ts 里写了 export * from './levels/...' 保持 index.ts 为 export {};每关走子路径导出,保证独立 chunk
模板里出现 if (detail.id === 'xxx') 想给单关加特殊逻辑,于是把内容耦合进模板 模板只依赖 levelScript;差异写进脚本或加可选 config 字段,否则下一个内容作者复制不了、audit:levels 也覆盖不到
12 行作者检查表填不满 负例只写 2 条、变体只写 1 个、evidencePlan 只写 1 阶段、manualAudit 没签 schema 的硬下限就是 2 解 / 3 负例 / 2 变体 / 2 阶段;少一条content:check 直接红,没有”以后补”
英文界面漏中文 六个 *En 字段只写了一半,或复制中文时带上了全角标点 content:check 会扫 CJK 与 ,。;:!?()【】;写英文时不要中文标点

有一条经验值得单独写出来:先写详情、再写摘要、最后写模块。详情是最重的,它逼你把失败类、多解、变体想清楚;摘要和 manifest 都是它的投影,最后照着填,反而不会漂移。反过来(先做模块再补内容)看着快,实际上会因为 designSize / hasCanvas / estimatedMinutes 的反复而重写三遍。

小结

新增一关要动九个文件、六个登记点,链路的每一段都有机器守着:

  • ids.ts 的两套数组决定”这关存不存在”与”这次发不发”;
  • 摘要是静态 import 的首屏数据,详情的动态 import 决定单关 chunk;
  • summaries.ts / loadLevel.ts / full.ts / registry.ts 四张 Record<ShippedLevelId, …> 表让”少登记”在 typecheck 阶段就报,而不是等到线上 404;
  • games/forge/src/index.ts 必须保持空出口,package.json 的子路径导出才是真正的入口;
  • content:check 管内容合规,audit:levels 管判定不可绕过,e2e/m1 管可完成,BUDGETS 与 ui-smoke 需要作者手动补;
  • 模板只吃 levelScript,任何”单关特殊逻辑”都是在给自己和下一个作者挖坑。

下一章把这里提到的门禁逐个打开:369 条单测、90 条 e2e、内容体检与反刷题审计各自守什么,以及 pnpm verify 为什么把 size 放在最后串行跑。