第03章 - 环境准备与快速开始
3.1 两条起跑线:在线试玩与本地运行
想尽快知道这个项目长什么样,有两条路,用途不同:
| 方式 | 地址 | 适合做什么 | 拿不到什么 |
|---|---|---|---|
| 在线试玩 | https://znlgis.github.io/mathmindforge/ | 先玩 20 关,理解「工具 → 运行 → 修正」的节奏 | 不能改代码、看不到源码映射、autoplay 参数被关掉 |
| 本地仓库 | git clone 后 pnpm dev → http://127.0.0.1:5192 |
读代码、改代码、跑门禁、写新关卡 | 需要 Node 24 与 pnpm 11.22.0 |
在线版跑的是构建产物(apps/site/dist),本地 pnpm dev 跑的是 Vite 开发服务器。这个差别不是「快慢」,而是三个具体行为:
- CSP 只在产物里。 生产构建由
cspPlugin在</head>前注入Content-Security-Policymeta(connect-src 'none'等);dev 不注入,因为 HMR 要连ws://127.0.0.1:5192,写死connect-src 'none'会把热更新掐断。所以「在本地 dev 一切正常」不能替代pnpm e2e:dist。 autoplay/instant只对 DEV 与 webdriver 开放。readDebugOptions()里写得很直白:
export function readDebugOptions(): DebugOptions | undefined {
if (!(import.meta.env.DEV || (typeof navigator !== 'undefined' && navigator.webdriver === true))) {
return undefined;
}
// …读 ?autoplay=1 / ?instant=1
}
也就是说在线分享链接里加 ?autoplay=1 不会自动通关——这是有意的,避免「一条链接跳完全部关卡」。
- 存档都在本机,两边不互通。 在线版与本地版各自的 IndexedDB 是独立的,换浏览器/换端口/清缓存都会看到另一份档。
3.2 环境要求与安装
版本要求只有两条,但都是硬性的:
| 项 | 要求 | 从哪里体现 |
|---|---|---|
| Node.js | 24.x | docs/02 的基线;CI 与本地一致 |
| pnpm | 11.22.0 | 根 package.json 的 "packageManager": "pnpm@11.22.0" |
| 浏览器 | 桌面 Chrome / Edge,或平板浏览器 | e2e 用 Chromium 1024×768 视口 |
仓库里没有 .nvmrc,也没有 engines 字段。这不是遗漏:packageManager 字段本身就会让 Corepack 在 install 时校验并切换到 11.22.0,写错版本会直接失败。所以文档里不需要再教「请安装 pnpm x.y.z」,只需要保证 Corepack 可用(Node 24 自带)。
安装就是一条命令,在仓库根目录执行:
git clone https://github.com/znlgis/mathmindforge.git
cd mathmindforge
pnpm install
pnpm-lock.yaml 已入库,install 会按锁文件装。注意 pnpm-workspace.yaml 里的 allowBuilds 白名单:
packages:
- 'apps/*'
- 'packages/*'
- 'games/*'
allowBuilds:
esbuild: true
'@tailwindcss/oxide': true
sharp: true
只有 esbuild、Tailwind 的 oxide 原生绑定与 sharp 被允许执行安装脚本,其余依赖一律不跑。这是 pnpm 11 的默认安全姿态,不要为了方便去放开整个世界。
第一次 install 之后值得先做一件事:确认没有任何包需要构建。 12 个 workspace 包(apps/site、10 个 packages/*、games/forge)全部把 exports 指向 .ts 源码,没有 build 脚本、没有 dist 目录。所以下面这条命令应该只看到根脚本,看不到每个包的构建产物:
ls packages/templates
# src package.json ← 没有 dist、没有 build 脚本
这是整个工程最省心的一点:改 packages/ 里任意一行源码,刷新浏览器即可,不需要「先构建库」。
3.3 首次启动:dev 5192 与 preview 5193
开发服务器:
pnpm dev
# VITE v8.3.4 ready in ~300 ms
# ➜ Local: http://127.0.0.1:5192/
根 package.json 里 dev 不是直接跑 Vite,而是 pnpm --filter @mmf/site dev——产品入口只有 apps/site。端口与主机写在 apps/site/vite.config.ts:
server: { host: '127.0.0.1', port: 5192, strictPort: true },
preview: { host: '127.0.0.1', port: 5193, strictPort: true },
strictPort 意味着端口被占时直接报错,而不是悄悄换到 5193。这是有意的:Playwright 的 webServer 会探测这个 URL,如果 Vite 自己漂到别的端口,e2e 可能连上一次残留的服务,测出一份过期的绿。
预览生产产物需要先构建:
pnpm build # 产物在 apps/site/dist
pnpm preview # http://127.0.0.1:5193
preview 用的是同一份 apps/site/vite.config.ts,所以 base: './' 生效——产物里资源引用是相对路径,你可以把整个 dist 目录拷到任何静态目录(甚至直接双击 dist/index.html)打开。
三个常见启动问题:
| 现象 | 原因 | 处理 |
|---|---|---|
Port 5192 is already in use |
上一次 dev 没关干净 | 杀掉占用进程,不要改端口 |
| 页面白屏但 dev 终端无报错 | 多半是浏览器缓存了旧模块 | 硬刷新(Ctrl+Shift+R) |
Cannot find module '@mmf/xxx' |
没在仓库根目录 install | 回到根目录 pnpm install,不要在单包里装 |
3.4 五分钟体验路径:星图 → 第一关 → 工具 → 完成面板
打开 http://127.0.0.1:5192,按下面这条路径走一遍,就能把前四章讲的所有机制亲眼看一次。括号里是界面元素的 data-testid,e2e 用的也是它们。
① 工坊大厅(#/) → 点「进入工坊 →」(home-start)。大厅还有「继续」按钮(home-continue)直接跳到推荐的那一关,以及日志 / 徽章 / 报告三个入口(home-journal、home-badges、home-report)。报告入口在门后:要长按 3 秒(adult-hold)才解锁。
② 思维星图(#/forge)。默认按学段分组(forge-view-band),可切到按主线分组(forge-view-thread)。顶部是「今天先玩这一关」的推荐(forge-recommended)。20 张关卡卡片每张都带 level-card-<id>,进入按钮是 level-enter-<id>。
③ 第一关(#/play/k-structure-pattern-path)。这一关是「模式修补匠」:一条 12 格的积木路,中间几格被踩乱,玩家要用模式卡定出重复单元、再点画布上的缺口把字母补回去。
进入后先看三块地方:
- 顶部情境条(
level-mission)——这关的intro与prompt,一句话说清要干什么; - 左侧画布(
level-canvas)——Phaser 渲染,格子可点; - 右侧工具台(
level-dock)——按内容声明的tools渲染,每张工具卡是tool-card-<kind>,卡头tool-card-toggle-<kind>-<index>。工具台是手风琴:同一时刻只展开一张卡,默认开第一张。
④ 用工具。这一关的 tools 正好是 pattern + construct,必用产物是 pattern-rule 与 repair-set,证据计划只有 hypothesis 与 repair 两项。真实操作是两条输入路径:在画布上点缺口把字母补回去(这一关有 3 个缺口),以及用模式卡声明重复单元(候选规律 chip 点一次即可声明)。两件事做完后点「完成」(level-submit);旁边是「重来」(level-reset)。
⑤ 看结构反馈。故意只填一块就点「完成」:你会看到失败横幅(failure-banner),内容是这一关为 pattern-repair-incomplete 写好的反馈——不是「错了」,而是「还差哪几块」。另一种提示是操作拒绝(action-error),出现在动作本身不成立的时候:比如在没拼出重复单元时点「用这条规律」,或者去点一个没坏的格子。两者对应第 04 章要讲的 Verdict.failure 与 ActionResult.ok。
⑥ 完成面板。缺口补齐、规律声明、submit 通过之后弹出 completion-panel(一个真正的 role="dialog"),上面三行事实:
用了 N 件工具 | 通过 M 个检查点 | 印章 K 枚
下面可能还有新徽章(completion-badges)与变体通行证(variant-passport,逐个变体标「通过 / 失效」)。三个出口:「下一关 →」(completion-next,只在还有下一关时出现)、「再玩一次」(completion-replay)、「回星图」(completion-back)。
⑦ 收尾看两页:思维日志(#/journal,journal-view)应该出现刚才那一关的一条记录;徽章墙(#/badges,badges-view)会出现模式捕手——它由「完成 k-structure-pattern-path」这条证据规则授予(见 packages/evidence/src/badges.ts 的 LEVEL_BADGES)。「观察者」要等你在两个关卡留下观察记录之后才会亮,不会一关就发。
走完这一遍,你会看到这个项目最重要的三条事实:画布与工具台同时在用(双视图)、操作永远先动工具再提交(动作驱动)、完成面板只讲事实不讲分数(无评分)。
3.5 构建产物结构与体积预算
pnpm build 之后,apps/site/dist 里是一批命名规则非常明确的文件(实测 61 个文件、共 1518.9 KB):
apps/site/dist/
├─ index.html # 生产构建里含 CSP meta
└─ assets/
├─ index-<hash>.js index-<hash>.css # 首屏:外壳、路由、视图懒加载的入口
├─ WorkshopView-<hash>.js JournalView-… ReportView-… BadgesView-…
├─ level-<levelId>-<hash>.js × 20 # 每关的关卡模块(可能含 Phaser 场景)
├─ content-<levelId>-<hash>.js × 20 # 每关的详情 JSON
├─ vendor-phaser-<hash>.js # 裁剪版 Phaser,全仓唯一一份
└─ 共享 chunk:pattern / rule / construct / simulate / experiment / argue /
schemas / runtime / ids / localize / progress / rng / src
文件名不是哈希乱码,而是可读的语义名。apps/site/vite.config.ts 的 chunkFileNames 用三条例正则把模块身份写进文件名:
chunkFileNames(chunk) {
const id = chunk.facadeModuleId ?? '';
const level = /[\\/]games[\\/]forge[\\/]src[\\/]levels[\\/]([^\\/]+)\.ts$/.exec(id);
if (level) return 'assets/level-' + level[1] + '-[hash].js';
const detail = /[\\/]content[\\/]src[\\/]details[\\/]([^\\/]+)\.level\.json$/.exec(id);
if (detail) return 'assets/content-' + detail[1] + '-[hash].js';
if (chunk.name.includes('phaser')) return 'assets/vendor-phaser-[hash].js';
return 'assets/[name]-[hash].js';
}
为什么值得这么做:体积门禁(scripts/check-size.mjs)要能指着文件名说「vendor-phaser 220.3 KB gzip,预算是 280 KB」,e2e 也要能证明「进入这一关只额外下了这两个 chunk」。哈希化的通用名会让这些检查变成猜谜。
预算表(硬编码在 check-size.mjs 的 BUDGETS 里):
| 项 | 上限 | 实测(2026-11-04) |
|---|---|---|
| 首屏 JS + CSS(gzip) | 85 KB | 69.5 KB |
| 首屏 JS + CSS(原始) | 260 KB | 203.6 KB |
| 单个懒加载 chunk(gzip) | 280 KB | 220.3 KB(vendor-phaser) |
| 单个懒加载 chunk(原始) | 1100 KB | 849.5 KB(vendor-phaser) |
| dist 原始大小 | 4000 KB | 1518.9 KB |
| 单个非代码资源 / 合计 | 200 KB / 600 KB | 0(零位图) |
预算不是越宽松越好,改预算必须先写原因——这是第 18、19 章的内容。这里只要记住一个判断方法:看到首屏变大,先查是不是有人 import 了 @mmf/content/full 或 phaser,因为那两个是唯一能让首屏忽然膨胀一截的操作,而且 lint 与体积门禁会分别在编译期与构建后各拦一次。
3.6 验证:pnpm verify 的八个门与 e2e 的三条链
pnpm verify 是一条命令,但它不是一个步骤。scripts/verify.mjs 里就是一个数组:
const STEPS = [
{ name: 'typecheck', command: 'pnpm typecheck' },
{ name: 'lint', command: 'pnpm lint' },
{ name: 'test', command: 'pnpm test' },
{ name: 'content:check', command: 'pnpm content:check' },
{ name: 'audit:levels', command: 'pnpm audit:levels' },
{ name: 'pack:check', command: 'pnpm pack:check' },
{ name: 'build', command: 'pnpm build' },
];
前七个用 Promise.all 并行跑,各自输出带颜色前缀([typecheck]、[lint]…);全部通过后才串行跑第八步 size:
const results = await Promise.all(STEPS.map((step, index) => run(step.name, step.command, COLORS[index % COLORS.length])));
const failed = results.filter((result) => result.code !== 0);
if (failed.length > 0) { /* 报错并退出 1 */ }
const size = await run('size', 'pnpm size', COLORS[0]);
if (size.code !== 0) { /* 体积门禁失败 */ }
size 必须串行,原因只有一句:并行读 dist 会读到上一轮产物。build 与 size 同时跑,等于在比对一个还没写完的目录——大概率假绿。
八个门各管什么:
| 门 | 命令 | 拦住什么 |
|---|---|---|
| typecheck | vue-tsc --noEmit |
类型错误(含 .vue);Record<ShippedLevelId, …> 少一条就红 |
| lint | eslint . |
风格 + 三条架构约束(纯逻辑无随机/时钟、平台出口唯一、外壳不拖重物) |
| test | vitest run |
纯逻辑单测:模板判定、工具规则、证据聚合、存档迁移、路由/seed/CSP 等(实测 369 条 / 25 个文件) |
| content:check | tsx scripts/content-check.ts |
内容语义:id 唯一、摘要/详情一致、英文字段零中文、变体轴合法、反馈覆盖失败类、禁词 |
| audit:levels | tsx scripts/audit-levels.ts |
反刷题:A1 无工具提交必败、A1b 空壳产物被结构拒绝、A2 非选择动作、A3 ≥2 条不同解、A4 负例触发失败类、A6 变体与迁移门、A8 autoplay 真的走到 passed |
| pack:check | node scripts/check-packaging.mjs |
工程体检:脚本齐全、workspace 存在、Phaser 裁剪产物与 LICENSE 就位 |
| build | pnpm build |
构建失败、chunk 超 chunkSizeWarningLimit 的警告 |
| size | node scripts/check-size.mjs |
首屏 / 单 chunk / dist / 资源四类预算,以及「产物结构体检」(见下) |
最后那项「产物结构体检」值得单独说一句。check-size.mjs 在算体积之前会先剥掉 HTML 注释再检查两件事:产物里有没有可执行的 <script type="module">、有没有 CSP meta。这是为了拦一次真实事故——早期 CSP 注入用正则替换 </head>,而 index.html 的注释里恰好有这串字面量,结果脚本标签被一起塞进注释:dev 的 e2e 全绿,生产产物却是白屏。把结构检查放进体积门禁,假绿就在这一步暴露。
3.6.1 e2e 的两套配置
pnpm e2e 跑 dev 服务器;pnpm e2e:dist 先构建、再对产物跑:
"e2e": "playwright test",
"e2e:dist": "pnpm build && playwright test --config playwright.preview.config.ts"
两份配置只差 baseURL 与 webServer:
// playwright.config.ts(dev)
use: { baseURL: 'http://127.0.0.1:5192', viewport: { width: 1024, height: 768 } },
webServer: { command: 'pnpm dev', url: 'http://127.0.0.1:5192', reuseExistingServer: !process.env.CI, timeout: 120_000 },
// playwright.preview.config.ts(生产产物)
use: { baseURL: 'http://127.0.0.1:5193', viewport: { width: 1024, height: 768 } },
webServer: { command: 'pnpm preview', url: 'http://127.0.0.1:5193', reuseExistingServer: !process.env.CI, timeout: 120_000 },
共同点是几条刻意的保守设置:workers: 1、fullyParallel: false(存档是共享的本机状态,并行会互相踩)、timeout: 90_000、expect.timeout: 20_000、失败保留 trace 与截图、CI 上重试 2 次。
两条都必须跑,因为 dev 测不到生产注入。这是发布检查清单里写死的阻断规则之一:pnpm verify / pnpm e2e / pnpm e2e:dist 任一失败都不发版。
3.6.2 调试桥:e2e 怎么看进度
e2e 不靠猜 DOM 结构判断「这一关过了没」,而是读 window.__mmf(只在 DEV / webdriver 暴露):
interface MmfDebugBridge {
events: string[]; // 'level:start:<id>'、'run:fail:<class>'、'level:complete:<id>' …
snapshot: RunSnapshot | null;
save: SaveData;
lastError?: string;
}
事件是扁平字符串,断言不依赖 payload 类型,所以用例里可以写 await waitEvent('level:complete:k-structure-pattern-path')。这也解释了第 01 章那条纪律:level:complete 必须在写盘之后才发,否则 e2e 会把「事件到了」误当成「存档已就绪」。
自己手动查也很方便:在浏览器控制台输入 __mmf.events 看最近的事件流,__mmf.snapshot 看当前运行时快照。
3.7 存档位置与「清白开局」
所有数据都在本机,键名与结构可以精确到字符串(packages/platform/src/web.ts):
export const MMF_DB_NAME = 'mmf';
export const MMF_KV_STORE = 'kv';
export const SAVE_KEY = 'save:local-default';
export const EVIDENCE_KEY = 'evidence:local-default';
export const AGGREGATE_KEY = 'evagg:local-default';
export const MAX_EVIDENCE_EVENTS = 3000;
const PREFERENCE_PREFIX = 'mmf:pref:';
对照着看:
| 存了什么 | 存在哪 | 说明 |
|---|---|---|
存档 SaveData |
IndexedDB mmf / kv / 键 save:local-default |
关卡进度、印章、工具使用、徽章、思维日志 |
| 证据事件 | 同上 / 键 evidence:local-default |
append-only,上限 3000 条 |
| 证据聚合 | 同上 / 键 evagg:local-default |
裁剪时把计数并入这里,所以清日志不会让徽章倒退 |
| 设备偏好 | localStorage / 键名 mmf:pref: + locale / muted / reducedMotion / hints |
语言、声音、动效、提示;不进档案 |
三个实用结论:
- 「清白开局」最稳的做法是清站点数据。 打开 DevTools → Application → 删除 IndexedDB 里的
mmf数据库,以及 localStorage 里mmf:pref:开头的键。刷新之后就是首次进入的状态。 - 刷新页面不会丢进度。 完成一关时会先
await progress.completeLevel(...)再发level:complete,刷新后读档能看到完成状态。如果完成面板上出现「这一关的进度没有写进存档」,那是写盘失败(隐私模式/配额满最常见),这一条的界面提示是有意保留的。 - 清档入口目前没有挂在界面上。
apps/site/src/stores/progress.ts里暴露了clear()(内部调ProgressManager.clear(),把存档、证据、聚合一起清),但目前没有视图调用它;报告页提供的是打印(report-print)、导出证据包(report-export)与只读导入(report-import)。写文档或提需求时不要把「长按 3 秒清档」当成已有功能——长按 3 秒只用于解锁成人报告。
顺便记一下成人门的语义,因为它容易被误读成「隐藏功能」:报告页在未解锁时渲染 report-gate,玩家要按住按钮 3 秒(adult-hold)才进 report-view。它防的不是孩子,而是防止「报告」变成孩子主路径上的一屏内容——报告是给家长/教师看的,里面只有工具使用、修正链与下一步建议,没有正确率、刷题量与排名。
3.8 本章要点
- 两条起跑线:在线版(https://znlgis.github.io/mathmindforge/)用来看效果,本地仓库用来改代码;在线版无
autoplay、无 CSP 之外的调试口、存档与本机不互通。 - 环境只有两条硬要求:Node 24.x 与 pnpm 11.22.0(
packageManager强制);pnpm install之后没有「构建库」这一步,12 个包全部exports直连源码。 pnpm dev固定127.0.0.1:5192、pnpm preview固定127.0.0.1:5193,都开strictPort;端口被占要杀掉旧进程,不要改端口。- 五分钟路径:大厅 → 星图 → 第一关 → 模式卡/构造块 → 失败横幅 → 完成面板 → 日志与徽章;每一步都能对上
data-testid。 - 产物按语义命名:
level-<id>、content-<id>、vendor-phaser;预算写死在check-size.mjs,实测首屏 69.5 KB gzip、dist 1518.9 KB。 pnpm verify= 七个门并行 +size串行;size不能并行,否则会读到上一轮dist。- e2e 有 dev 与生产产物两套配置(5192 / 5193),两条都必须跑;进度用调试桥的扁平事件串断言,不用 DOM 猜。
- 存档在本机 IndexedDB
mmf(save:/evidence:/evagg:三个键)+ localStoragemmf:pref:;想回到首次状态就清站点数据。