第02章 - 技术栈与工程结构
2.1 版本基线与锁版本纪律
这个项目的技术栈不是「挑最新」,而是「挑一套互相验证过、且能被 CI 固定下来的组合」。根 package.json 把版本全部写成精确值(没有一个 ^),并且用 packageManager 锁定包管理器:
{
"name": "mathmindforge",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.22.0",
"devDependencies": {
"typescript": "5.9.3",
"vite": "8.3.4",
"vitest": "5.0.4",
"vue-tsc": "3.3.13",
"eslint": "10.12.0",
"typescript-eslint": "8.71.2",
"eslint-plugin-vue": "10.11.1",
"@playwright/test": "1.64.0",
"tailwindcss": "4.3.3",
"@tailwindcss/vite": "4.3.3",
"tsx": "4.23.15"
}
}
完整的基线(与 docs/02-技术架构.md §1 一致,本仓库实测版本):
| 层 | 选型 | 版本 | 说明 |
|---|---|---|---|
| 运行时 | Node.js | 24.x | CI 与本地一致 |
| 包管理 | pnpm workspace | 11.22.0 | packageManager 锁定 |
| 语言 | TypeScript | 5.9.3(strict) | 全仓 strict,含 .vue |
| 应用外壳 | Vue 3 + Pinia | 3.5.44 / 4.0.3 | 工坊、星图、工具台、报告 |
| 样式 | Tailwind CSS | 4.3.3(@tailwindcss/vite) |
设计 token 走 @theme |
| 构建 | Vite | 8.3.4 | 每关独立 chunk;Phaser 独立 vendor chunk |
| 画布引擎 | Phaser | 4.2.1(裁剪版) | 只出现在需要画布的关卡 chunk |
| 内容校验 | zod | 4.6.7 | 构建期校验,不进首屏 |
| 单测 | Vitest | 5.0.4 | Node 环境,纯逻辑 |
| 端到端 | Playwright | 1.64.0 | 单套配置,视口 1024×768 |
| Lint | eslint / typescript-eslint / eslint-plugin-vue | 10.12.0 / 8.71.2 / 10.11.1 | flat config + 架构约束规则 |
| 类型检查 | vue-tsc | 3.3.13 | --noEmit |
| 脚本运行器 | tsx | 4.23.15 | 内容体检 / 审计 CLI |
有两件事这张表没写,但必须知道:
- 原生壳的版本是「计划」不是「现状」。
docs/02里列的 Tauri CLI 2.12.2 与 Capacitor 8.5.3 目前没有任何工程文件落地——仓库里只有apps/site,apps/desktop与apps/mobile尚未创建。写需求时不要把「已支持 EXE/APK」当成事实。 - Phaser 是裁剪版 vendor,不是 npm 依赖的完整包。
packages/templates与packages/canvas-kit的package.json里写着"phaser": "4.2.1",但构建时 Vite 的 alias 会把它换成交付在仓库里的packages/canvas-kit/vendor/phaser-trim.esm.js(见 2.6)。
至于为什么要把版本钉到这个程度,理由不是「怕新东西」,而是三条具体的工程后果:
第一,门禁要可比。 pnpm verify 的输出要能在 CI 上复现。类型检查、lint 规则集、单测、体积极限全都依赖工具版本;版本漂移会让「昨天绿今天红」变成常态,而这份项目把七个门禁并行、体积门禁串行地串成一条 pnpm verify(见第 03 章 §3.6),漂移的代价是整条链失去诊断价值。
第二,体积预算是硬编码的。 scripts/check-size.mjs 里写着 initialKb: 85、chunkKb: 280、distKb: 4000。打包器换一个小版本就可能改变 chunk 切分与压缩结果,首屏 69.5 KB 这种数字一旦不明原因地跳变,预算就不再是预算。
第三,玩法代码与工具链要分开评审。 docs/02 §1 的版本纪律说得直接:升级任何一个版本都单独一个 PR,跑全量 pnpm verify + e2e + size;不允许「顺手升级」混在玩法 PR 里。这条纪律和「内容 PR 与引擎 PR 分开」是同一种思路:把「行为变更」与「依赖变更」拆开,出问题时才能二分。
需要特别留意的是 packageManager 字段的作用:它不是注释,pnpm 会据此校验并切换到指定版本(配合 Corepack)。所以 clone 之后不需要在文档里写「请安装 pnpm 11.22.0」——写错版本会直接被拒。
2.2 pnpm workspace 布局与 exports 直连源码
workspace 只有三行,但决定了整个仓库的目录语义:
packages:
- 'apps/*'
- 'packages/*'
- 'games/*'
allowBuilds:
esbuild: true
'@tailwindcss/oxide': true
sharp: true
apps/*:可独立启动的应用(当前只有apps/site);packages/*:库(契约、内容、模板、工具、画布、证据、存档、平台、i18n);games/*:玩法包(当前只有games/forge,一个包承载 20 关)。
allowBuilds 是 pnpm 11 对「安装期执行脚本」的白名单:只放行 esbuild、Tailwind 的 oxide 原生绑定与 sharp,其余依赖不允许在 install 时跑任意脚本。这是供应链安全的最小配置。
最关键的工程选择:包没有构建步骤,exports 直接指向 .ts 源码。
// packages/content/package.json
{
"name": "@mmf/content",
"type": "module",
"exports": {
".": "./src/index.ts",
"./ids": "./src/ids.ts",
"./full": "./src/full.ts"
},
"dependencies": { "zod": "4.6.7" }
}
packages/templates 把这条思路推到极致——它把一个包拆成二十多个子路径:
"exports": {
".": "./src/index.ts",
"./core": "./src/core.ts",
"./layout": "./src/layout.ts",
"./shared": "./src/shared/index.ts",
"./pattern": "./src/pattern/index.ts",
"./pattern.logic": "./src/pattern/logic.ts",
"./pattern.runtime": "./src/pattern/runtime.ts",
"./rule": "./src/rule/index.ts",
"./schemas": "./src/schemas.ts",
"./verifiers": "./src/verifiers.ts",
"./argue.runtime": "./src/argue/runtime.ts"
}
这样做的收益与代价都很具体:
| 收益 | 代价 |
|---|---|
| 没有「改一行要重建库」的循环,改完源码刷新即生效 | 消费方必须走 exports 声明过的路径,不能深层相对导入 |
| 子路径即代码分割点:一个关卡只 import 自己那个模板的 runtime | exports 表要手写维护,漏一条就只能在类型层面用到它 |
测试与 CLI(tsx / Vitest)读的是同一份源码 |
需要打包器与 TS 都能解析 .ts 的 exports(由 bundler 解析模式解决,见 2.4) |
为什么这件事对体积是决定性的:@mmf/templates 里放着六个模板的逻辑、schema 与运行时代码。如果所有关卡都从 barrel 里 import,打包器会把六个模板一起放进共享 chunk;现在每关从 @mmf/templates/<template>.runtime 取自己那一份,@mmf/templates 主 barrel 只留给 Node 侧测试与类型引用。同一招在 @mmf/content 上是三条出口:.(外壳用,含摘要与 loadLevel)、./ids(只要常量的地方)、./full(只给 CLI 与测试,含 zod schema 与全部详情)。
三条出口的纪律由 lint 兜住:apps/site/src/** 里 import @mmf/content/full 直接报错,因为一句 import 就能让「详情懒加载」作废,而体积门禁要到打包后才发现。
2.3 每个包的职责与依赖方向
包的划分不是按「文件多少」切,而是按能不能脱离浏览器跑切。下表按依赖顺序从底往上排:
| 包 | 职责 | 依赖 |
|---|---|---|
@mmf/content |
id 常量、20 关摘要与详情 JSON、zod schema、loadLevel、localize |
zod |
@mmf/game-kit |
契约:LevelModule / LevelContext / LevelRuntime / LevelAction / Verdict / RunSnapshot / 存档与证据类型 / PlatformServices 接口 |
@mmf/content(仅类型与常量) |
@mmf/toolkit |
纯逻辑:模式规则、种子 RNG、统计(Wilson 区间等) | content、game-kit |
@mmf/evidence |
证据聚合、四维画像、徽章规则、轨迹、报告与本地化(纯函数) | content、game-kit |
@mmf/progress |
ProgressManager、存档、迁移、日志 |
content、game-kit、evidence |
@mmf/platform |
Web 实现:IndexedDB、音频、TTS、文件、偏好、生命周期 | game-kit、progress |
@mmf/i18n |
界面文案(zh 常驻 + en 表)、Translator / t(key, params) |
content |
@mmf/canvas-kit |
Phaser 启动器、BaseScene、主题、绘制助手、裁剪版 vendor |
game-kit、phaser |
@mmf/templates |
六个模板:schema / logic / runtime / scene / layout | content、game-kit、toolkit、canvas-kit、i18n、zod、phaser |
@mmf/toolkit-ui |
Vue:ToolDock、六件工具面板、论证板 |
game-kit、toolkit、i18n、vue |
@mmf/game-forge |
20 个关卡入口(一关一个子路径导出) | content、game-kit、templates |
@mmf/site |
唯一产品入口:外壳、路由、视图、stores | 上面几乎全部 + vue、pinia |
依赖图(docs/02 §3.1,只允许箭头方向):
apps/site ──→ game-forge(registry 懒加载), toolkit-ui, evidence, progress, platform, i18n, content, game-kit
apps/site ──→ canvas-kit:只通过 game-forge 的异步关卡 chunk 间接使用;外壳本身不 import Phaser
game-forge ──→ templates, content, game-kit
templates ──→ toolkit, canvas-kit, game-kit, content/ids, i18n, zod
toolkit-ui ──→ toolkit, game-kit, i18n, vue
canvas-kit ──→ game-kit, i18n, phaser
toolkit ──→ game-kit, content/ids
evidence ──→ game-kit, content/ids
progress ──→ game-kit, content/ids, evidence
platform ──→ game-kit, progress
content ──→ zod
game-kit ──→ content/ids(仅类型与常量)
i18n ──→ content/ids
禁止的方向写得同样明确,因为它们每一条都对应一次真实事故:
content依赖toolkit:会让内容体检把题型逻辑拖进构建期;game-kit依赖 Vue / Phaser / zod:契约层一旦有渲染或校验依赖,纯逻辑单测就跑不起来;toolkit依赖 Vue / Phaser:审计 CLI 在 Node 里跑,import 到 DOM 就炸;apps/site直接 importphaser或@mmf/content/full:首屏体积与懒加载双双失守。
为什么 toolkit 与 toolkit-ui 要拆成两个包?因为六件工具的判定必须能在 Node 里被 audit:levels 审计,而它们的界面是 Vue。混在一起会把 Vue/DOM 拖进纯逻辑测试与审计 CLI。同理,templates 的 *.logic.ts 可以 import toolkit,但不该 import 画布场景。
2.4 TypeScript:strict,以及「刻意没有 paths」
tsconfig.json 只有一份,全仓共用(没有各包独立的 tsconfig)。核心配置:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"incremental": true,
"jsx": "preserve",
"types": ["vite/client", "node"]
},
"include": [
"packages/*/src/**/*.ts",
"packages/*/src/**/*.vue",
"games/*/src/**/*.ts",
"apps/site/src/**/*.ts",
"apps/site/src/**/*.vue",
"e2e/**/*.ts",
"scripts/**/*.ts",
"*.config.ts",
"apps/*/*.config.ts"
],
"exclude": ["**/node_modules", "**/dist", "**/vendor"]
}
几个值得逐条解释的选择:
strict+noUnusedLocals+noUnusedParameters。 后者看着烦,但它是「删代码」的护栏:重构后残留的变量会立刻红,而不是变成一颗哑弹。moduleResolution: "bundler"。 这是让import { loadLevel } from '@mmf/content'能解析到./src/index.ts的关键——它按package.json的exports找入口,并允许无扩展名导入。若改成node16,所有直连.ts的 exports 都会失配。verbatimModuleSyntax+isolatedModules。 强制把纯类型导入写成import type。这不是洁癖:import type会被类型擦除,而普通 import 会留下运行时依赖。项目里多处靠这一点保住 chunk 边界(例如只取常量与类型的地方)。allowImportingTsExtensions。 只为一处例外开口:apps/site/vite.config.ts要写import { CSP_TAG } from './src/shell/csp.ts'。原因是 Vite 8 的 native config loader 不支持无扩展名的相对导入,启动时会警告。配置注释里写得很清楚:只放行不强制,其余相对导入保持原样。noEmit+incremental。 类型检查只产出.tsbuildinfo(放在node_modules/.cache),产物交给 Vite;pnpm typecheck就是vue-tsc --noEmit -p tsconfig.json。
没有 paths 别名,是刻意为之。 这里不用 "@mmf/*": ["packages/*/src"] 这种映射,而是让 pnpm 在 node_modules 里建符号链接,TS 通过每个包 package.json 的 exports 找到 ./src/*.ts。好处是「一份真相」:运行时(Vite / Node)、类型检查(TS)与测试(Vitest)走的是同一套解析规则,不存在「类型能过、跑起来找不到」的双份配置。代价是子路径必须写进 exports——但这正好也是代码分割的清单,漏写会立刻暴露。
2.5 ESLint flat config:把架构约束写成规则
eslint.config.js 是 flat config(数组,无 .eslintrc 层级)。它先铺三个通用配置:
export default tseslint.config(
{ ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**',
'**/playwright-report/**', '**/test-results/**', '**/vendor/**', '.scratch/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
{ files: ['**/*.vue'], languageOptions: { parserOptions: { parser: tseslint.parser } } },
// …
);
然后是三段架构约束——它们不是风格规则,而是第 01 章那三条铁律的可执行版本:
// ① 纯逻辑层:不许随机、不许时钟、不许 Phaser/Vue
{
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。' },
]}],
},
},
// ② 平台出口唯一:游戏/模板/外壳不许直接碰浏览器存储与网络
{
files: ['packages/{toolkit,templates,games}/**/*.ts', 'apps/site/src/**/*.{ts,vue}'],
rules: {
'no-restricted-globals': ['error', 'fetch', 'XMLHttpRequest', 'WebSocket', 'localStorage', 'indexedDB'],
},
},
// ③ 外壳不许把重东西拖进首屏
{
files: ['apps/site/src/**/*.{ts,vue}'],
rules: {
'no-restricted-imports': ['error', { paths: [
{ name: '@mmf/content/full', message: '外壳不许直接引入完整内容:用 loadLevel()。' },
{ name: 'phaser', message: '外壳不许直接 import Phaser:它只属于关卡 chunk。' },
]}],
},
},
三处细节:
localStorage只对游戏/工具/外壳禁用,packages/platform内部是允许的。 这就是「平台出口唯一」的工程含义——不是全仓禁用,而是只留一个豁免区。evidence与progress没有被列进第 ① 段的files里,但它们同样是纯逻辑包。规则的覆盖边界是「当前最容易被写坏的地方」,不是全部;新加纯逻辑包时要记得扩这条files。.vue也吃这套规则。 因为pluginVue.configs['flat/recommended']的languageOptions.parserOptions.parser指到了tseslint.parser,所以apps/site/src/**/*.vue里的<script setup>同样受no-restricted-globals管辖。
此外还有一组为 scripts/ 与配置文件准备的白名单全局变量(console、process、Buffer、setTimeout 等),因为它们是 Node 脚本而不是浏览器代码。风格类规则则做了减法:vue/multi-word-component-names、vue/max-attributes-per-line 等关掉,@typescript-eslint/no-unused-vars 放行 _ 前缀参数。
2.6 Vite:相对 base、chunk 命名与构建期 CSP
apps/site/vite.config.ts 只有 56 行,但每一段都在解决一个具体问题。
import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import type { Plugin } from 'vite';
// 显式带 .ts:Vite 8 的 native config loader 不支持无扩展名相对导入(dev 启动时会警告)。
import { CSP_TAG } from './src/shell/csp.ts';
const phaserTrim = fileURLToPath(
new URL('../../packages/canvas-kit/vendor/phaser-trim.esm.js', import.meta.url),
);
export default defineConfig({
base: './',
plugins: [vue(), tailwindcss(), cspPlugin()],
resolve: { alias: [{ find: /^phaser$/, replacement: phaserTrim }] },
server: { host: '127.0.0.1', port: 5192, strictPort: true },
preview: { host: '127.0.0.1', port: 5193, strictPort: true },
build: {
target: 'es2022',
chunkSizeWarningLimit: 1100,
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: 'vendor-phaser',
test: /node_modules[\\/]phaser[\\/]|canvas-kit[\\/]vendor[\\/]phaser-trim/,
},
],
},
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';
},
},
},
},
});
base: './'。 产物里的资源引用全是相对路径,于是 apps/site/dist 可以放在任意子目录(GitHub Pages 的项目页、U 盘、Tauri 的 frontendDist、Capacitor 的 webDir),不需要在四处分别改 base。代价是不能用绝对路径写死资源。
Phaser alias。 源码里大家都写 import ... from 'phaser',构建时被换成仓库里的裁剪产物;TS 类型仍从官方 phaser 包解析。这样「引擎只在关卡 chunk 里」这件事只需要一处配置就能维持。注意裁剪产物是一次性生成后入库的,仓库里没有 pnpm phaser:trim 这类脚本,升级 Phaser 时要按 packages/canvas-kit/vendor/README.md 手工重建。
chunk 命名。 三条例正则把「哪个模块」直接写进文件名:assets/level-<id>-[hash].js、assets/content-<id>-[hash].js、assets/vendor-phaser-[hash].js。这不是为了好看——体积门禁与 e2e 都要靠文件名定位「这一关的 chunk 有多大」「首屏引用了哪些文件」,哈希化的通用名会把这些检查变成猜谜。
vendor 分组。 codeSplitting.groups 把 Phaser 与裁剪产物单独归到 vendor-phaser,保证它只进一个 chunk、只被需要画布的关卡引用,而不是被每个关卡各打一份。chunkSizeWarningLimit: 1100 则是对着 check-size.mjs 的「单个懒加载 chunk 原始 ≤ 1100 KB」预算设的,让构建警告与门禁阈值同源。
CSP 只在构建期注入。 插件本体只有六行:
/**
* 只在构建产物注入 CSP(策略在 src/shell/csp.ts,有单测兜住)。
* 用标签描述符而不是字符串替换:见 csp.ts 里 `CSP_TAG` 的注释。
*/
function cspPlugin(): Plugin {
return {
name: 'mmf-csp',
apply: 'build',
transformIndexHtml: () => [CSP_TAG],
};
}
apply: 'build' 是关键:dev 的 HMR 要连 ws://127.0.0.1:5192,而生产策略里 connect-src 'none' 会把热更新直接掐断。策略本身写在 apps/site/src/shell/csp.ts,并且刻意用 Vite 的标签描述符({ tag, attrs, injectTo })而不是字符串替换 </head>:
export const CSP_DIRECTIVES = [
`default-src 'self' file:`,
`script-src 'self' file:`,
`style-src 'self' 'unsafe-inline' file:`,
`img-src 'self' data: blob: file:`,
`font-src 'self' file:`,
`connect-src 'none'`,
`media-src 'self' file:`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'none'`,
] as const;
这里记录一次真实事故:早期用正则替换 </head>,而 index.html 的注释里恰好出现这个字面量,结果注入内容连同伴随的 <script type="module"> 一起被塞进了注释——dev 的 e2e 全绿,生产产物却是白屏。现在 scripts/check-size.mjs 会先剥掉注释再检查「有没有可执行 module script」「有没有 CSP meta」,把这类假绿在体积门禁那一步就暴露出来。
2.7 端口约定与命令面
两个端口是固定的,都绑定回环地址并开启 strictPort:
| 命令 | 端口 | 用途 | 为什么必须固定 |
|---|---|---|---|
pnpm dev |
http://127.0.0.1:5192 |
Vite 开发服务器 | playwright.config.ts 的 baseURL 与 webServer.url 写死它 |
pnpm preview |
http://127.0.0.1:5193 |
预览生产产物 | playwright.preview.config.ts 用它对产物跑 e2e |
strictPort: true 的含义是「端口被占就报错,不要自动换一个」。这是有意的:E2E 的 webServer 配置会探测这个 URL,如果 Vite 悄悄跑到 5193,Playwright 可能连上了上一次残留的服务,测出一份过期的绿。
根 package.json 的命令面把所有动作收在一处,前缀全部是 workspace 过滤:
"scripts": {
"dev": "pnpm --filter @mmf/site dev",
"build": "pnpm --filter @mmf/site build",
"preview": "pnpm --filter @mmf/site preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"lint": "eslint . --concurrency auto --cache --cache-location node_modules/.cache/eslint",
"test": "vitest run",
"content:check": "tsx scripts/content-check.ts",
"audit:levels": "tsx scripts/audit-levels.ts",
"pack:check": "node scripts/check-packaging.mjs",
"size": "node scripts/check-size.mjs",
"verify": "node scripts/verify.mjs",
"e2e": "playwright test",
"e2e:dist": "pnpm build && playwright test --config playwright.preview.config.ts"
}
注意 dev / build / preview 都不是在根目录跑 Vite,而是 pnpm --filter @mmf/site——根目录没有 index.html,产品入口只有 apps/site 一个。e2e:dist 则先 build 再跑 preview 配置,因为「生产产物」与「dev 服务器」的差异(CSP、chunk 切分、相对 base)只有这条命令能覆盖。
这套命令面在第 03 章会变成一条可执行的体验路径,第 18 章会拆成质量门禁的八个步骤。
2.8 本章要点
- 版本全部写成精确值,
packageManager锁 pnpm 11.22.0;升级任何版本都单独一个 PR 并跑全量门禁。 - workspace 三行:
apps/*(应用)、packages/*(库)、games/*(玩法包);allowBuilds白名单只放行三个需要原生构建的依赖。 - 包没有构建步骤,
exports直连.ts;子路径既是解析入口也是代码分割点,漏写一条就进不了运行时。 - 依赖方向是硬约束:
game-kit不依赖 Vue/Phaser/zod,toolkit不依赖 Vue/Phaser,外壳不直接 import Phaser 与content/full。 tsconfig全仓一份、strict、moduleResolution: bundler、verbatimModuleSyntax;刻意没有paths,跨包解析靠 pnpm 符号链接 +exports。eslint.config.js三段架构规则分别守住「纯逻辑无随机/无时钟/无框架」「平台出口唯一」「外壳不拖重物」。- Vite:
base: './'、Phaser alias 指向裁剪产物、chunk 名带关卡 id、vendor 分组独立、CSP 只在apply: 'build'时注入。 - 端口固定 5192(dev)/ 5193(preview)并开
strictPort,因为 e2e 的baseURL与webServer就写的是它们。