第07章 - 六件思维工具与工具台

7.1 三层分工:toolkit、toolkit-ui 与外壳

六件工具横跨三个包,边界很硬:纯逻辑不带 Vue、Vue 组件不带 Phaser、外壳不碰工具内部。

层 包 里面有什么 禁止什么
纯逻辑 @mmf/toolkit pattern/ 的规则与校验、shared/rng.ts、shared/stats.ts 禁止 vue、phaser、Math.random()、Date.now()
视图 @mmf/toolkit-ui ToolDock.vue 与十几件工具面板、ArgumentBoard.vue 不 import Phaser,不直接写存档
宿主 apps/site 只引 ToolDock 与 ArgumentBoard,负责布局、存档、音效 不写着色/校验逻辑

@mmf/toolkit 的入口只有一行,因为它只导出模式工具:

// packages/toolkit/src/index.ts(全文)
export * from './pattern/index';

但 shared/ 里有另两个入口:@mmf/toolkit/shared 导出 mulberry32 / hashString / digest 与 wilsonInterval / clamp / round2。抽出来是因为它们与具体工具无关,又被多处复用:

// packages/toolkit/src/shared/rng.ts(节选)
/** 可注入种子的确定性 RNG;纯逻辑层唯一允许的随机来源。 */
export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export function digest(value: unknown): string {
  // JSON.stringify(undefined / 函数 / symbol) 返回 undefined 而不是字符串;
  // 产物摘要来自玩家输入,缺字段是常态,这里退成空串,避免取 .length 时整次提交崩掉。
  const text = JSON.stringify(value) ?? '';
  return hashString(text).toString(16).padStart(8, '0');
}

这条「纯逻辑层唯一允许的随机来源」不是文档约定,而是 eslint 硬规则——同一份配置里禁止了 Math.random 与 Date.now:

// 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。' },
    ],
    'no-restricted-imports': ['error', { paths: [
      { name: 'phaser', message: '纯逻辑层禁止 Phaser。' },
      { name: 'vue', message: '纯逻辑层禁止 Vue。' },
    ]}],
  },
},

这条禁令换来的是可测性:packages/toolkit/src/shared/rng.test.ts、stats.test.ts、pattern/index.test.ts 都能直接在 Node 里跑,不需要 jsdom、不需要 canvas、不需要等 Phaser 初始化。工具面板里那层「Vue 组件」则一律薄——读快照、派发动作、把结果画成按钮,不自己算判定。

7.2 ToolDock:按内容声明渲染,而不是写死六个组件

工具台不写死「这一关有哪几件工具」——它完全按内容 JSON 里的 tools 数组渲染。ToolDock 的 props 就是三样东西:

// packages/toolkit-ui/src/ToolDock.vue(节选)
const props = defineProps<{
  tools: ToolSpecLike[];
  runtime: LevelRuntime;
  detail: LevelDetailLike;
  locale: Locale;
}>();

const snapshot = shallowRef<RunSnapshot>(props.runtime.snapshot());
const unsubscribe = props.runtime.subscribe((next) => {
  snapshot.value = next;
});

const translator = shallowRef<Translator>(createTranslator(props.locale));
watch(
  () => props.locale,
  (locale) => {
    translator.value = createTranslator(locale);
  },
);

三个决定值得说:

  • 订阅在组件里做,不在外壳做:ToolDock 自己 subscribe 运行时快照并在 onBeforeUnmount 里 unsubscribe()。订阅与组件同生共死,外壳不需要转发快照。
  • shallowRef 而不是 ref:快照是整棵替换的只读视图(里面还有 Map 派生的数组),深层响应式既没必要也容易把大对象代理一遍。
  • locale 变化时重建 translator:createTranslator 绑死了表,换语言必须换 translator 实例;用 watch 而不是每次渲染 createTranslator,避免每帧建对象。

工具卡是手风琴:同一时刻只展开一张,默认开第一张,换关时回到第一张。

/**
 * 手风琴:同一时刻只展开一张工具卡,默认开第一张。
 * 工具区原来全部摊开有 928px,右侧 dock 只能看到 566px,孩子要滚动才能找到下面的工具和按钮——
 * 折叠后整块工具区在 1280×800 里不用滚动,也不需要靠"滚下去找"来发现第二件工具。
 * -1 = 全部收起(再点一次当前卡头),把整块空间让给画布。
 */
const openIndex = ref(0);

function toggle(index: number): void {
  openIndex.value = openIndex.value === index ? -1 : index;
}

// 换关时 tools 整表替换:不回第一张的话,新关卡会停在上一个关卡选的工具位置上
watch(() => props.tools, () => { openIndex.value = 0; });

展开/收起用的是 v-show 而不是 v-if,理由写在模板注释里:折叠只是藏起来,工具内部填了一半的输入/选择在来回切换时不会丢。这条对「先摆两块再回头看看规律」的玩法很关键——如果折叠就销毁面板,玩家每次回来都要重填。

渲染分支按 spec.kind + spec.config.mode 两级分发,这一长串 v-if/v-else-if 就是「声明式工具规格」的落地:

<PatternTool v-if="spec.kind === 'pattern'" :spec="spec" :runtime="runtime" :snapshot="snapshot" :t="translator" />
<PaymentTool v-else-if="spec.kind === 'construct' && spec.config.mode === 'payment'" … />
<DominoTool v-else-if="spec.kind === 'construct' && spec.config.mode === 'domino'" … />
<NetworkTool v-else-if="spec.kind === 'construct' && spec.config.mode === 'network'" … />
<ConstructTool v-else-if="spec.kind === 'construct'" … />
<RuleTool v-else-if="spec.kind === 'rule'" … />
<CounterTool v-else-if="spec.kind === 'counter'" … />
<KnobTool v-else-if="spec.kind === 'knob'" … />
<TallyTool v-else-if="spec.kind === 'probe' && spec.config.mode === 'tally'" … />
<SampleTool v-else-if="spec.kind === 'probe'" … />
<p v-else class="mt-3 text-sm text-ink-soft">这个工具暂时还没接上。</p>

注意最后那个 v-else:内容里声明了一件还没实现的工具,界面给一行「这个工具暂时还没接上」,而不是白屏或崩溃。这让「内容先行、组件补齐」成为可能。

这套分发的规模值得点明:construct 有 placement(默认)/ payment / domino / distribution / network / schedule / frontier 七种 mode,probe 有 sample(默认)/ tally / metrics / invariant / queue / sampling / inference 七种 mode——七种视觉形态共用同一件「构造块」或「数据采集器」,动作名仍是 construct/place 与 probe/sample,只有舞台不同。

7.3 六件工具:动作、产物、失败类

动作是工具唯一能改变世界的方式,全部集中定义在 packages/game-kit/src/runtime.ts 的 ActionPayloadMap 里。下表按工具列出实际驱动的动作;契约里还登记了少数保留动作(例如 rule/run),模板当前统一走宿主的 run,所以没有列进表里:

工具 动作 产物 失败类(模板 FAILURE_CLASSES)
pattern 模式卡 pattern/inspect、pattern/setCycle、pattern/preview、pattern/commit pattern-rule({tokens, period, transform}) pattern-no-observation / pattern-rule-invalid / pattern-cycle-wrong / pattern-repair-incomplete / pattern-mismatch / pattern-transfer-failed
rule 规则卡 rule/place、rule/remove、rule/move、rule/commit(运行走统一 run) program(ProgramBlock[]) rule-empty / rule-wall / rule-loop / rule-missed-item
construct 构造块 construct/place、construct/remove placement-set / tally 等 construct-not-optimal / construct-overpay,另有按 mode 划分的 distribution-* 等
knob 参数旋钮 knob/set、knob/run、knob/compare、knob/reset、knob/commit params({values})+ RunMetrics sim-not-run / sim-budget / sim-coverage / sim-wait / sim-overfit-seed
probe 数据采集器 probe/sample、probe/clear、probe/focus、probe/claim、probe/tally dataset + claim(ClaimInput) probe-sample-small / probe-not-collected / probe-claim-mismatch / probe-tally-wrong
counter 反例探测器 counter/edit、counter/test、counter/annotate、counter/commit counterexample(CounterPatch) counter-invalid / counter-wrong-premise / counter-not-tested

这张表背后有一条纪律:先加类型、再加处理分支、最后才让 UI 派发。动作表里没有的类型不能出现在组件里,否则 autoplay 与真实操作就不再是同一条路径。

工具的产物不是「答案」,而是结构化证据。以模式卡为例:

// packages/toolkit/src/pattern/index.ts(节选)
export interface PatternRule {
  tokens: string[];
  period: number;
  transform: PatternTransform;
}

export interface PatternArtifact {
  patternRule: PatternRule;
  repair: PatternRepair[];
}

repair 是玩家在缺口上放的块({index, token} 数组),patternRule 是他声明的规律。两者都可以单独存在:只放块不声明规律,母本仍能通过(按可见格反推候选规律),但没有可翻译的规律,迁移门会拒绝——这个设计在第 8 章展开。

7.4 防点选:每件工具都有一道「不做事就过不去」的坎

《数思工坊》的通关条件不是「选对选项」,所以每件工具都必须让「点一下蒙对」变得无效。反刷题审计(scripts/audit-levels.ts)里 A1/A1b/A2 三条就是查这个:A1 要求无工具提交必须被拒且给出缺失证据、A1b 要求空壳/错误产物被结构反馈拒绝、A2 要求动作空间里存在非选择动作。

六件工具各自的「坎」:

工具 坎的具体实现
pattern 只选周期不算完:必须真的补块;且提交时跑换表征变体(scenario.symbols),只补不声明规律会落 pattern-transfer-failed
rule 必须提交可执行程序并真的运行;同一程序要在旋转/换图的变体上通过(rule-not-general 的变体口径)
construct 必须真的摆放;不可能关提交的是「不可能性证书」(不变量选择 + 检查),不是选一个「不可能」
knob 必须至少运行一次并观察指标;达标还要换种子仍达标(sim-overfit-seed)
probe 结论必须绑定本次采样(probe-not-collected);样本太小或结论与数据对不上会判 probe-sample-small / probe-claim-mismatch
counter 必须自己构造实例并测试;还得指出它推翻了哪条前提(counter-wrong-premise)

界面层面的「坎」也做了对应改造,最典型的是反例探测器:

/** 编辑即自动测:玩家不用再点一次"测试贪心"。 */
async function autoTest(): Promise<void> {
  const result = await props.runtime.dispatch({ type: 'counter/test', payload: {} });
  if (!result.ok) error.value = result.message ? props.t.t(result.message) : null;
}

/** 失效前提是一排 chip:点一下就指出(不需要下拉展开、也不需要再点提交)。 */
async function choosePremise(id: string): Promise<void> {
  if (busy.value || !lastTest.value?.passed) return;
  // …dispatch({ type: 'counter/annotate', payload: { premiseId: id } })
}

注意 choosePremise 的守卫:!lastTest.value?.passed 时 chip 全部禁用——还没测出一个真正让贪心失效的例子,就先别让你指认前提。这道顺序约束把「先猜结论再凑证据」堵掉了。

参数旋钮的坎在「必须跑」上:面板里把参数调到底不等于通关,knob/set 只是改值,knob/run 才产生 RunMetrics;判定读的是运行记录,不是参数值本身。数据采集器同理:probe/sample 产生 dataset,probe/claim 提交的结论会被拿去和这份 dataset 比对,对不上就是 probe-claim-mismatch。

7.5 论证板:argue 的提交面,不是第七件工具

ArgumentBoard.vue 常常被误当成「第七件工具」。它不是:它不声明自己的 ToolSpec、不出现在 tools 数组里,只在 template === 'argue' 的关卡里由宿主额外渲染。

// apps/site/src/views/WorkshopView.vue(节选)
<ArgumentBoard
  v-if="runtime && detail?.template === 'argue' && liveSnapshot"
  :runtime="runtime"
  :snapshot="liveSnapshot"
  :detail="detail"
  :t="translator"
/>

它的四个字段(主张 / 依据 / 证据 / 适用范围)并不是四个输入框。当前实现把输入压到了最小:玩家只选一条依据,其余三格由运行时推出来:

/**
 * 一切显示都从快照派生:没有本地表单状态,所以重玩/换关不会把上一轮的旧选择带回来。
 * 主张与范围不再由玩家填:选一条依据,运行时就把「不可能铺满 → 1×2 多米诺铺满」推出来。
 */
const warrant = computed(() => argument.value?.warrant ?? '');
const claim = computed(() => argument.value?.claim ?? '');
const scope = computed(() => argument.value?.scope ?? '');

点一条依据时,它先让运行时真的去数棋盘(probe/sample),再写依据(argue/setWarrant):

async function pick(next: string): Promise<void> {
  if (busy.value) return;
  busy.value = true;
  error.value = null;
  try {
    // 一次点击 = 一次输入:先把黑白格真的数出来(观察证据),再写一条依据。
    // 沿用旧实现里把统计与论证拆成两个按钮,会逼玩家先点 probe 再回来点依据。
    const sampled = await props.runtime.dispatch({ type: 'probe/sample', payload: {} });
    if (!sampled.ok) { /* error ... */ return; }
    const result = await props.runtime.dispatch({ type: 'argue/setWarrant', payload: { warrant: next } });
    if (!result.ok) error.value = result.message ? props.t.t(result.message) : null;
  } finally {
    busy.value = false;
  }
}

这一步很关键:如果论证板只写「依据 = 染色法」而不真的去数棋盘,它就退化成选择题。先 probe/sample 再 argue/setWarrant,意味着证书里的每个数字都有一次真实的工具调用作依据。

界面上还会就地给出「这条依据在这张棋盘上到底能不能下结论」,例如染色法要比较黑白格数是否相等,奇偶/面积要看总格数是否奇数——这是把同一条数学事实翻成人话,真正的判定仍以运行时的 validateArtifact 为准。

ArgumentBoard 的产物是 argument(ArgumentSnapshot),失败类是 argue-claim-unsupported / argue-warrant-unsupported / argue-invariant-false / argue-evidence-missing / argue-scope-wrong。

7.6 工具面板里的 i18n:两种文案来源并存

工具面板的文案有两个来源,理解这个分裂有助于读懂现有代码。

第一类是正式 i18n key,主要用在卡头:内容 JSON 里 tools[i].titleKey = 'tool.pattern.title'、descriptionKey = 'tool.pattern.desc',ToolDock 用 translator 渲染:

<h2 class="text-base font-black text-blueprint">
  <button type="button" :aria-expanded="openIndex === index" …>
    <span class="min-w-0 truncate"></span>
    <span class="min-w-0 flex-1 truncate text-xs font-semibold text-ink-soft">
      
    </span>
    <span aria-hidden="true" class="shrink-0 text-xs text-ink-soft"></span>
  </button>
</h2>

第二类是面板内的短句,仍直接写中文(RuleTool.vue 里有注释写明「面板文案是 toolkit-ui 里的中文短句」)。PatternTool 这类组件会把 t 传进来并统一走 translate(),有的能查表、有的查不到就回退原文:

// packages/toolkit-ui/src/PatternTool.vue(节选)
/** 始终读取当前的 t,避免语言切换后拿到旧翻译器。 */
function translate(key: string, params?: MessageParams): string {
  return props.t.t(key, params);
}

为什么每个组件都写一个 translate() 包装而不是直接 props.t.t:props 是响应式的,渲染时读 props.t 才能拿到最新 translator;如果解构成局部变量 const { t } = props,语言切换后仍是旧实例。

这条缺口在 i18n 包里用兜底表补了一半:zh-source.ts 是「中文源串 → 英文」映射,正式 en.ts 优先,查不到再查兜底表,再查不到回退中文(宁可中文也不白屏):

// packages/i18n/src/index.ts(节选)
/**
 * 英文表 = 正式 en 表 + "中文源串 → 英文"兜底表。
 * 后者兜住历史遗留的"以中文句子当 key"(内容 feedback / 模板失败文案),
 * 让英文界面不再漏出中文;正式 key 优先,避免兜底表盖掉真实文案。
 */
const EN: Record<string, string> = { ...zhSourceEn, ...en };

所以现状是:切英文后界面基本干净,但工具面板里仍有硬编码中文。这不是遗漏,而是 docs/05 里登记过的待办(「内容 i18n 化」),content:check 对这类 key 只记警告。写新工具时应该走 titleKey / descriptionKey 那条路,别再往组件里塞中文字符串。

7.7 工具与画布的协作:一个 runtime,两个消费者

有画布的模板(pattern / rule)里,工具面板与 Phaser 场景是同一个运行时的两个消费者:

玩家点画布格子 ──▶ 场景 dispatch(construct/place) ──▶ runtime 状态变化
玩家点工具面板 ──▶ 面板 dispatch(pattern/setCycle) ──▶ ─┘
                                                      │
                              runtime.subscribe 通知 ──┤──▶ ToolDock 重渲染
                                                      └──▶ 场景重画(签名比对后才重画)

两侧都不持有状态副本:面板读 snapshot.toolState,场景读同一份快照。场景侧还有一条性能纪律——先算 signature,没变就不重画:

// packages/templates/src/pattern/scene.ts(节选)
private render(snapshot: RunSnapshot): void {
  const signature = this.signatureOf(snapshot);
  if (signature === this.lastSignature) return;
  this.lastSignature = signature;
  // …重新绘制整条带子
}

这对「工具面板每次输入都派发一个动作」的交互是必须的:没有签名比对,改一个数字也要重画整条带子与所有文本对象。

无画布的模板(construct / simulate / experiment / argue)里,工具面板就是全部界面。宿主的 mount() 是空的,ToolDock 被拉满宽度居中(max-w-3xl),这也是为什么 manifest.hasCanvas 这个字段存在——它决定宿主是否渲染 canvas 容器、给工具区多少宽度。

7.8 小结

  • 三层分工:@mmf/toolkit 纯逻辑(禁 Vue/Phaser/Math.random/Date.now)、@mmf/toolkit-ui 视图组件、外壳只做布局与存档。
  • ToolDock 按内容 tools 数组渲染,kind + config.mode 两级分发;七种 construct mode 与七种 probe mode 共用动作名,只是舞台不同。
  • 工具面板自己订阅运行时快照(subscribe + onBeforeUnmount 解订阅),用 shallowRef 存快照,折叠用 v-show 保住填了一半的输入。
  • 六件工具的动作名集中在 ActionPayloadMap,产物是结构化证据(pattern-rule / program / placement-set / params / dataset+claim / counterexample),失败类由模板 FAILURE_CLASSES 定义。
  • 防点选不是文案口号:pattern 要补块 + 过迁移门,counter 要先测出反例才能指认前提,knob 必须运行、probe 的结论必须绑定本次采样。
  • ArgumentBoard 是 argue 模板的提交面,不是第七件工具;玩家只选一条依据,且必须先真的数出棋盘数据。
  • 工具面板 i18n 是两种来源并存:卡头走 titleKey,面板内仍有中文短句;zh-source.ts 兜底让英文界面不漏中文。
  • 有画布关卡里工具面板与画布共用一个 runtime,场景靠 signature 比对避免无谓重画。

下一章从工具转到模板:六个模板各自「画布长什么样、判定核心是什么」,并完整拆开 pattern 模板。