第01章 - 数思工坊项目概述与设计理念

1.1 这是什么:一间「思维工具使用场」

不是刷题,是锻造思维工具。

《数思工坊》(Math Mind Forge)面向 5–18 岁,副标题是「从模式到边界的思维闯关」。它的自我定位不是题库、不是练习册,而是一间思维工具使用场:玩家在可操作、可失败、可修正的情境里,反复调用六件工具,构造出满足约束的结构、规则、模型或论证,才能推进。

这一定位把「通关」的定义整个换掉了。通关条件不是「答对」,而是工具产物通过校验、约束被满足、证据链完整。所以界面上没有「提交答案」按钮,只有「完成」「重来」这类动作;玩家不是先想好再点选,而是调工具、看后果、改一处、再看后果。

三条设计红线在工程上被翻译成了可执行、可被机器检查的规则(出自 docs/00-方案总览.md §1):

原始红线 工程化定义 怎么被机器检查
关卡不是题组 每关声明必须使用的工具产物;一件产物都没有时不能过关 运行时门禁 + 反刷题审计 A1
难度不沿计算量上升 难度只有四个轴:结构复杂度、表征抽象度、约束耦合度、迁移距离 关卡 schema 的 difficulty 四轴(各 1–5);审计拒绝 number-size
反馈不指向对错 反馈必须绑定到结构化失败类(规则失效 / 结构断裂 / 模型缺变量 / 论证缺口) 每关失败类必须有反馈文案与负例测试

第三条最容易被写成口号,这个项目把它落成了数据结构:失败不是一句「错了」,而是一份 Verdict,里面写明 failure.class(断在哪一类)、causes(指向哪个格子/哪一步/哪条约束)、missingEvidence(还缺哪一步证据)。玩家看到的是「你的规则在第 3 类地图会撞墙」这种语言,而不是分数。

玩家侧的核心循环是这样的(docs/00-方案总览.md §3):

进入工坊 → 读懂情境(观察) → 选一件/几件工具
        → 构造/调整(规则、摆放、参数、采样、反例)
        → 点「完成」 → 看结构性后果(哪里断了/哪个指标没到/哪个前提失败)
        → 修改 → 再运行 → 通过三道门
        → 得到印章 + 思维轨迹 + 徽章进度

仓库是开源的(MIT):znlgis/mathmindforge。想先玩再读代码,可以直接打开在线版:数思工坊在线试玩,20 关全部可玩,进度只存本机。

1.2 六件工具与六个模板

「六件思维工具」不是六个按钮,而是六套有状态、有产物、可校验的编辑器。每件工具都必须回答三个问题:玩家在这里做什么操作、产出什么结构、这个结构能被谁校验。

工具 id 产物(artifact) 判定在检查什么
模式卡 pattern PatternRule(重复单元 + 周期 + 变换) 规则能否补全缺口、换表征后是否仍成立
规则卡 rule 程序 AST + 执行轨迹 程序可执行、能收齐、在变体地图上仍然成立
构造块 construct 摆放集合 / 统计条 / 图与课表 覆盖、不重叠、对称、连通、预算等硬约束
参数旋钮 knob 参数向量 + 运行指标 预算内指标是否达标、换种子是否仍稳
数据采集器 probe 数据集 + 样本量 + 结论 样本是否充分、结论是否超出证据
反例探测器 counter 反例实例 + 失效前提 这个例子是否真的推翻了规则/贪心

工具与模板是多对多:一个模板可以开放多件工具,一件工具也能出现在多个模板里;工具的实现只写一次,判定逻辑放在纯逻辑包里(第 07 章展开工具台,第 08–12 章逐个模板展开)。六个模板决定「画布长什么样、工具有哪些、判定怎么做」,内容 JSON 只填「这一关的具体参数」:

模板 id 画布 默认工具 判定核心 对应主线
模式 pattern 序列带 / 墙 / 积木路 pattern + construct 规则补全 + 换表征迁移 结构
规则 rule 网格地图 rule + counter 程序执行 + 变体泛化 算法
构造 construct 网格 / 等距板 / 表格图 construct + counter 约束满足 + 多解 + 不可能证书 结构/算法/建模
模拟 simulate 简化城镇 / 队列 knob + probe 预算内指标 + 换种子稳健 建模
实验 experiment 对局 / 数据面板(可无画布) probe(按关卡加 knob / construct / rule) 大样本比例 + 证据充分 数据/算法
论证 argue 结构 / 图表(可无画布) counter + probe + 论证板 证书可检验 + 边界解释 边界

模板的第一个工程要求是数据完备:同一模板要能承载玩家编辑器(v1.1)生成的新关卡,判定与求解只依赖 levelScript,不为某一关写分支。这也是为什么内容体检要在构建期把每关的 levelScript 过一遍模板自己的 zod schema。

第二道保险是审计。requiredToolArtifacts 在 2026-11 的交互简化轮之后被明确为内容自检字段——它保证「声明的必用产物确实有工具产得出」,运行时不再据此做第二道门;真正守住「必须用工具」的是运行时判定顺序与 audit:levels 的 A1:直接 dispatch({ type: 'submit' }) 而无任何产物,必须被拒并且带 missingEvidence。

1.3 20 关的全景:5 学段 × 4 主线

内容层的所有 id 只有一个事实来源:packages/content/src/ids.ts。它刻意不含 zod、不含任何内容 JSON,外壳、工具、运行时都从这里取常量,避免把 zod 拖进首屏或单测:

export const BANDS = ['k', 'g13', 'g46', 'g79', 'g1012'] as const;
export const THREADS = ['structure', 'algorithm', 'modeling', 'data'] as const;
export const TOOL_IDS = ['pattern', 'rule', 'construct', 'knob', 'probe', 'counter'] as const;
export const TEMPLATE_IDS = ['pattern', 'rule', 'construct', 'simulate', 'experiment', 'argue'] as const;
export const STAMP_IDS = ['constraint', 'simple', 'transfer', 'argument'] as const;
export const EVIDENCE_PHASES = [
  'observe', 'hypothesize', 'construct', 'run', 'result', 'revise', 'generalize', 'argue',
] as const;
export const VARIANT_AXES = ['structure', 'representation', 'constraint', 'transfer'] as const;

首发 20 关就是这张 5 × 4 的矩阵,每格一关(括号内是主模板):

学段 结构与模式 算法与构造 建模与优化 数据与系统
幼儿园 K(5–6 岁) 模式修补匠(pattern) 规则小火车(rule) 公平分点心(construct) 分类小站(experiment)
小学低段 G1–3 对称补墙(pattern) 规则工程师·路线(rule) 公平分装(construct) 天气记录站(experiment)
小学高段 G4–6 铺砌挑战(construct) 最少枚数支付(construct) 排队模型(simulate) 不变量侦探(argue)
初中 G7–9 路网连通(construct) 课程表调度(construct) 城市调度员(simulate) 抽样调查(experiment)
高中 G10–12 结构不变量(argue) 算法边界关(argue) 胜率实验室(experiment) 统计推断站(experiment)

模板分布:pattern 2、rule 2、construct 6、simulate 2、experiment 5、argue 3,六件工具全部被真实使用。注意 construct 占了 6 关——它不是「拼图」,而是「在约束下把结构摆出来」这一原语在不同题材里的复用:分点心、分装、铺砌、支付、路网、课表。

关卡 id 的命名规则是 <学段>-<主线>-<特征>,例如 k-structure-pattern-path、g46-algorithm-payment。ids.ts 里其实有两层清单:

/** 20 关目标矩阵:内容层唯一事实来源 */
export const LEVEL_IDS = [ /* 20 条 */ ] as const;

/** 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];

保留 SHIPPED_LEVEL_IDS 这层间接,是为了「迟到的关卡」:内容没写完时把它从清单里摘掉即可,loadLevel 的 loader 表、registry 的 Record<ShippedLevelId, LevelLoader>、摘要表会立刻少一条而 typecheck 报错——不会出现「文件在但路由 404」的半截状态。现在 20 关全部在册,apps/site/src/registry.ts 里就是 20 条动态 import。

1.4 三条架构铁律

docs/02-技术架构.md §2 列了十条架构原则,其中前三条是不可违背的,后面所有包划分、构建配置、审计脚本都是它们的推论。这三条也是本书反复要回到的主线。

铁律一:逻辑与渲染分离。 packages/toolkit(判定与求解)、packages/evidence、packages/progress、所有 *.logic.ts 都不 import Phaser / Vue / DOM。这样判定、求解、证据聚合都能在 Node 里跑,审计脚本才能对 20 关做自动判定。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。' },
    ]}],
  },
},

铁律二:平台出口唯一。 存储、音频、语音、文件、生命周期一律经 PlatformServices。游戏/工具代码里出现 localStorage / fetch / indexedDB 视为 bug,由 lint 的 no-restricted-globals 拦下;platform 包内部才允许碰这些。接口本身只有八个字段(packages/game-kit/src/services.ts):

/** 唯一平台出口:游戏/工具代码不直接碰 localStorage / fetch / IndexedDB */
export interface PlatformServices {
  storage: ProgressStore;
  evidenceStore: EvidenceStore;
  audio: AudioBus;
  files: FileBus;
  preferences: Preferences;
  lifecycle: LifecycleBus;
  telemetry: Telemetry;
  now(): number;
}

铁律三:动作驱动,同一路径。 所有交互(点、拖、调滑杆、采样、提交)都变成 LevelAction,由纯 LevelRuntime 处理;画布与工具台只是两个视图,都只读同一份 RunSnapshot。最关键的一条纪律是:自动通关(autoplay)内部逐条 dispatch 黄金解的动作序列,不允许另写「自动通关分支」。所以 e2e 与审计跑的是玩家那条路,而不是一条平行实现。

这三条合起来解释了一个设计取舍:为什么规则编辑器、数据表、论证板要做成 Vue 的 ToolDock,而不是画在 Phaser 画布里。因为这几样东西必须可读、可键盘操作、可响应式;而画布负责空间与时间。双视图的细节在第 13 章。

1.5 仓库结构总览

mathmindforge/
├─ apps/
│  └─ site/                     # Vue 3 外壳:工坊大厅、星图、关卡宿主、日志、徽章、报告
│                               # (desktop/ 与 mobile/ 尚未创建:本机无 Rust / Android SDK)
├─ packages/
│  ├─ game-kit/                 # 契约:LevelModule / Context / Runtime / Action / Verdict / Save
│  ├─ content/                  # ids / 摘要 / 详情 / loadLevel / localize / full(仅构建期)
│  ├─ toolkit/                  # 纯逻辑:模式规则、种子 RNG、统计(Wilson 区间等)
│  ├─ toolkit-ui/               # Vue:ToolDock、六件工具面板、论证板
│  ├─ canvas-kit/               # Phaser 裁剪版接入 + BaseScene + 主题 + 绘制助手
│  ├─ templates/                # 六个模板:schema / logic / runtime / scene / layout / verifier
│  ├─ evidence/                 # 证据聚合、四维画像、徽章规则、轨迹、报告与本地化
│  ├─ progress/                 # 存档、迁移、ProgressManager、日志
│  ├─ platform/                 # Web 实现:IndexedDB / 音频 / TTS / 文件 / 偏好 / 生命周期
│  └─ i18n/                     # 界面文案(zh 常驻 + en 表)
├─ games/forge/                 # 20 个关卡入口(一关一个动态 import 子路径)
├─ e2e/                         # Playwright
├─ scripts/                     # verify / content-check / audit-levels / check-size / check-packaging
├─ docs/                        # 方案、规格、实施记录、审查记录
└─ 根配置文件(workspace / tsconfig / eslint / vite / vitest / playwright)

三个要点值得先记住:

  1. apps/site 是唯一产品入口。 它不是「网页版」,而是产品本身;将来 Tauri/Capacitor 只是加载同一份 apps/site/dist。
  2. games/forge 是一个玩法包承载 20 关。 每关一个模块文件、一个子路径导出(@mmf/game-forge/k-structure-pattern-path),由外壳逐条动态 import,一关一个 chunk。包级出口刻意是空的,理由写在源码里:
/**
 * @mmf/game-forge 的包级出口。
 *
 * 关卡入口是**子路径导出**(./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 {};
  1. 契约包 game-kit 不依赖 Vue / Phaser / zod。 它只定义类型与常量(常量从 @mmf/content/ids re-export,避免双份事实)。谁都可以依赖它,它不依赖业务。

包的职责边界与依赖方向、exports 直连 .ts 源码的解析方式,是第 02 章的内容。

1.6 一个关卡的完整生命周期

一关从「星图上的卡片」到「完成面板」,走的是一条固定的五段路。这里用真实代码把顺序钉死,第 04 章再展开运行时内部,第 05 章展开外壳。

① 关卡模块是一个「纯运行时 + 可选画布」的组合。 20 个关卡模块都长这样,只有 manifest 不同:

// games/forge/src/levels/k-structure-pattern-path.ts
import type { LevelManifest } from '@mmf/game-kit';
import { definePatternLevel } from '@mmf/templates/pattern';
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);

definePatternLevel 返回的就是 LevelModule:createRuntime(detail, opts) 造纯运行时,mount(ctx, runtime) 才去创建 Phaser 游戏。无画布关卡(例如纯数据实验)的 manifest 里 hasCanvas: false,mount 是空实现,chunk 里可以完全没有 Phaser。

② 宿主按顺序装配。 WorkshopView 的启动函数是这条链路的唯一权威:

async function start(): Promise<void> {
  // 先 await 读档:深链秒通关时,旧快照不能覆盖刚写入的完成状态
  await progress.init();
  const loader = registry[props.levelId];
  if (!loader) throw new Error('关卡没有登记:' + props.levelId);
  const loaded = (await loader()).default;          // ① 关卡 chunk(可能含 Phaser)
  module.value = loaded;
  // hasCanvas 的画布容器由 v-if 渲染:等这一拍 DOM 出来,ctx.canvasHost 才不会为 null
  await nextTick();
  const loadedDetail = await loadLevel(props.levelId);  // ② 详情 chunk(懒加载)
  if (!loadedDetail) throw new Error('关卡内容没有加载成功:' + props.levelId);
  detail.value = loadedDetail;
  const runtimeRef = loaded.createRuntime(loadedDetail, {
    seed: seedFor(props.levelId, progress.save?.profile.id ?? 'local-default'),
    now: platform.now,
    locale: 'zh-CN',
    reducedMotion,
    autoplay: debugOptions?.autoplay,
    onEvidence: (draft) => { /* 入队,2 秒批量落盘 */ },
  });
  runtime.value = runtimeRef;
  unsubscribe = runtimeRef.subscribe((snapshot) => { liveSnapshot.value = snapshot; });
  const ctx = createLevelContext({ /* container / canvasHost / manifest / services … */ });
  await loaded.mount(ctx, runtimeRef);              // ③ 画布挂载
  recordDebugEvent('level:ready:' + props.levelId);
}

顺序里填过三个坑,值得单独记一笔:先读档再挂载(否则深链秒通关的状态会被旧快照覆盖)、先 nextTick 再取 canvasHost(画布容器由 v-if 渲染,早一拍拿到的是 null)、mount 之后再报 level:ready(e2e 用这个事件判「可以操作了」)。

③ 工具台按内容渲染,不依赖关卡模块导出 UI。 ToolDock 只吃 detail.tools(ToolSpec[])与同一个 runtime,因此「这关开放哪几件工具」是内容的事,不是代码的事。

④ 提交走 dispatch({ type: 'submit' }),拿回一个 Verdict。 通过则进完成面板;不通过则失败横幅只显示「刚刚那次提交」的结果——玩家一动工具,旧提示立刻消失,避免提示粘住误导。

⑤ 完成才落盘。 completeNow() 先 await progress.completeLevel(...),再 refreshBadges(),最后才发 level:complete 事件。事件必须在写盘之后:e2e 用 level:complete 当「存档已就绪」的信号,事件早于写盘会让第二个页面读到旧档(这条在双页用例里真的踩过)。

同一路径的验证方式是 autoplay:?autoplay=1 时宿主调用 runtime.autoplay(),而它内部就是逐条 dispatch 黄金解的动作序列,最后补一个 submit。审计脚本用的也是这条路径,所以「autoplay 能过」与「玩家点得过去」在结构上是同一件事(第 04 章详述,第 18 章讲门禁)。

1.7 与 CHAGS、BambooBuddy 的传承关系

这个项目的技术底子来自同一作者的两个前作,但复用与不复用被划得很清楚。docs/00-方案总览.md §2.6 把这张表列成两半:

沿用(被验证过的工程纪律,来自 BambooBuddy) 有意不同(来自本作的玩法判断)
pnpm workspace + TS strict + Vite + Vue + Pinia + Tailwind 不做宠物养成与亲密度经济;成长感来自工具库、徽章、星图与证据链
Phaser 只出现在关卡 chunk,外壳首屏不含引擎 不做每日任务/连续天数压力;改为「推荐下一关 + 自由工坊 + 每周回顾」
内容 JSON + zod、构建期体检、摘要/详情拆两半 不做固定竖屏的纯 Phaser 页面;改为 Canvas + ToolDock 双面板
逻辑与渲染分离、纯逻辑可脱离浏览器单测 不做星星评分;完成结果只有「通过了哪些门 + 得到哪些印章」
PlatformServices 是唯一平台出口 不做「点选即通关」;工具产物与约束满足才是判定对象
每关可无头直达、?autoplay=1 自动通关 不做刷题式关卡量;v1.0 收敛到 20 关、每关 2–3 个变体
Vitest + Playwright + 体积门禁 + 一条 pnpm verify 不做正确率/排名/计分(报告里也不出现)

与 CHAGS(历史典故游戏合集)的关系更偏「方法论」:CHAGS 用「一个模板 = 一种交互原语」的思路,把 53 部游戏压到 26 个模板 id 上,证明的是「模板 + 内容 JSON + 约 20% 定制」这条路能规模化生产。数思工坊继承了这个结构,但换了分组依据:CHAGS 的模板按交互原语分(拖拽、瞄准、分类、路径……),数思工坊的模板按教学结构分(pattern / rule / construct / simulate / experiment / argue)——因为这里要判定的不是「有没有玩完」,而是「产物是否满足约束、是否泛化、是否有边界」。

由此带来两处必须重写的东西:

  • 判定必须能在 Node 里跑。 CHAGS 的判定可以贴着场景写,因为闸门主要是「玩到了没」;数思工坊的判定要对 20 关做自动审计(A1/A1b/A2–A4/A6/A8),所以模板的 logic.ts 必须是纯函数,validate(detail, variant, artifact) 与 actionsFor(detail, variant, artifact) 成对出现:前者给审计快速验产物,后者给 autoplay 走真实动作链,两者共用同一套约束检查函数,避免判定漂移。
  • 证据是一等公民。 CHAGS 的存档记的是过关与星级;数思工坊记的是 EvidenceEvent(阶段 / 工具 / 动作 / 产物指纹 / 结果 / 失败类 / 修订序号),再聚合成四维画像、9 枚徽章、思维日志与家长报告。这套东西必须可复现,于是引出了第 04 章的确定性纪律:注入种子、注入时钟,纯逻辑层禁止 Math.random / Date.now。

一个诚实的边界:CHAGS 那 53 部游戏是「一局 1–2 分钟」的短体验,而数思工坊一关 3–10 分钟、要求构造与修正,内容成本明显更高。这正是为什么 v1.0 只做 20 关而不是原方案的 120–200 关——把「少而深」从口号变成唯一可执行的内容预算。

1.8 本章要点

  • 数思工坊是思维工具使用场,不是题库;通关靠工具产物、约束满足与证据链,不靠答对。
  • 六件工具(pattern / rule / construct / knob / probe / counter)各有产物与校验器;六个模板(pattern / rule / construct / simulate / experiment / argue)决定画布与判定;两者多对多,工具实现只写一次。
  • 20 关 = 5 学段 × 4 主线;LEVEL_IDS 是目标矩阵,SHIPPED_LEVEL_IDS 是真正上线的子集,后者驱动 registry 与 typecheck。
  • 三条铁律:逻辑与渲染分离、平台出口唯一、动作驱动同一路径(autoplay 与真实操作共用 dispatch)。
  • 一关的生命周期是固定五段:读档 → 加载关卡 chunk 与详情 chunk → createRuntime → mount 画布并接上同一个 runtime → submit 拿 Verdict → 写盘后才发完成事件。
  • 与 CHAGS 共享「模板 + 内容 JSON」的生产方式,与 BambooBuddy 共享工程纪律;但双视图、动作驱动运行时、证据链与三道门是本作新增的部分。