docs(06): create plan 06-17 realistic map save/load perf supplement

This commit is contained in:
unanmed 2026-09-16 15:49:53 +08:00
parent a19979ff75
commit 92bddd6c74
2 changed files with 365 additions and 1 deletions

View File

@ -210,7 +210,7 @@ Plans:
3. 测试在本地可运行且全部通过
4. 测试由 AI 编写并运行,通过验证后可提交
**Plans**: 9/9 原计划 executed replanned (D-28;旧 06-01/06-02 执行结果标记 superseded,按同号重跑;数据端切片,非数据 render/legacy 覆盖延后) + gap-fill 06-10..06-15 (D-46;人工评审缺口补测,只补测试不改生产代码;15/15 executed) + perf 06-16 (性能测试补充;只加测试与配置,不改生产代码)
**Plans**: 9/9 原计划 executed replanned (D-28;旧 06-01/06-02 执行结果标记 superseded,按同号重跑;数据端切片,非数据 render/legacy 覆盖延后) + gap-fill 06-10..06-15 (D-46;人工评审缺口补测,只补测试不改生产代码;15/15 executed) + perf 06-16 (性能测试补充;只加测试与配置,不改生产代码) + perf 06-17 (真实地图存读档性能补充;移入 13 张真实地图夹具,只加测试与夹具)
Plans:
@ -258,6 +258,10 @@ Plans:
- [x] 06-16-PLAN.md — perf supplement: `vitest.perf.config.ts` + `test:perf` script (isolated from `pnpm test:ci`); 18 cases = ② critical calc (`1000/10000/50000`) + ① enemy-context `buildup` (N = `50/200/1000`) + ③ hero attribute recalc (M = `10/100/1000`) + ④ CoreState save/load round trip (`10/100/1000` items × `NoCompression`/`LowCompression`/`HighCompression`); warmup 3 + 20 samples → median/min/p95 via `console.table`, **zero assertions**, results recorded in `06-16-SUMMARY.md`
**Wave 7** *(realistic map save/load perf supplement, user-authorized; appended after 06-16; reuses the same isolated `test:perf` lane; one fixture move + one new `*.perf.ts`, no production edits, no new dependencies)*
- [ ] 06-17-PLAN.md — realistic map save/load perf: move the root `floors.json` (13 real 13×13 maps) into `packages-user/data-state/test/fixtures/`; new `packages-user/data-state/test/saveablesReal.perf.ts` measuring `存档`/`读档`/`往返` (save-only / load-only / round trip) for map scale `1/5/13` × `NoCompression`/`LowCompression`/`HighCompression` (27 rows) with a fixed realistic side load (50 flags, 20 hero modifiers incl. 4 from equipped items, 4 equipped instances, 20 item kinds, 1000 replay steps); cleared live map (`2/3/4/6` → `0`) vs original `compareWith` reference so `HighCompression` stores changed rows; warmup 3 + 20 samples → median/min/p95 via `console.table`, **zero assertions**, results recorded in `06-17-SUMMARY.md`
### Phase 7: 数据端缺陷修复
**Goal**: 修复 Phase 6 单元测试暴露的数据端疑似缺陷,使正确预期用例转绿,且仅限数据端、不涉及渲染端

View File

@ -0,0 +1,360 @@
---
phase: 06-unit-tests
plan: 17
type: execute
wave: 7
depends_on: [06-16]
files_modified:
- packages-user/data-state/test/fixtures/floors.json
- packages-user/data-state/test/saveablesReal.perf.ts
files_deleted:
- floors.json
autonomous: true
requirements: [TEST-01]
estimate:
tokens: 52000
raw_tokens: 52000
tasks: 3
confidence: low
must_haves:
truths:
- "TEST-01 的**真实地图存读档性能补充**:复用 06-16 已建好的 perf 通道(`vitest.perf.config.ts` + `pnpm test:perf`),新增 1 个 `*.perf.ts`(`saveablesReal.perf.ts`),`pnpm test:perf` 由 4 文件 / 18 行变为 5 文件 / **45 行**(18 + 新 27 行),全程无断言失败、无 `[WARNING Code` / `[ERROR Code` 噪声(用户已批准此设计)。"
- "夹具来源是**真实数据**:仓库根的 `floors.json`(17,292 字节、CRLF、13 个条目、全部 13×13、实测图块编号 1..6)被移入 `packages-user/data-state/test/fixtures/floors.json`(内容一字不改),仓库根副本删除;数据集只用于测试夹具,无任何脚本引用它。"
- "清除规则(用户裁决):live 地图矩阵把 `2` 普通门 / `3` 资源 / `4` 怪物 / `6` 机关门 置 `0`,**保留** `0` 空地 / `1` 墙壁 / `5` 入口;`compareWith` 的参考矩阵是**原始**真实矩阵(含 2/3/4/6)。地图注册覆盖数据集中出现过的全部非零编号(`1..6`),且该编号集合与地图数量无关(各 scale 的图块注册完全一致)。"
- "diff 存储语义(用户明确指定):live = 清除后矩阵、参考 = 原始矩阵,因此 HighCompression 走「与参考逐行比较后只存变化行」的分支;**据实记录**:LowCompression 在本夹具下因 `layerDirty && !isEqualToRef()` 回落为整图 `fullMap`(`mapLayer.ts` 的 `saveLowCompression` 没有行差异分支),SUMMARY 不得把 Low 描述成行差异。"
- "规模与计时:地图数量 `1 / 5 / 13`(确定性取前 N 张)× 压缩档 `NoCompression` / `LowCompression` / `HighCompression`,每个组合输出**三行**——`存档`(仅 `saveState`)、`读档`(仅 `loadState`)、`往返`(save + load)——共 `3 × 3 × 3 = 27` 行,列为 `case` / `scale` / `median ms` / `min ms` / `p95 ms`。"
- "固定真实侧负载(**每个 case 逐字相同**,与地图数量无关):50 个 flag 条目(`setFieldValue`);勇士 **20 个修饰器**(其中 4 个由 4 件已装备实例的 `equip.value` 各贡献 1 个,另 16 个手写 `ValueModifier`);**4 件装备实例**(3 种装备编号,其中一种两件,装备到 4 个互不重复的数字槽位);**20 种道具**(3 种装备 + 17 种消耗品,消耗品件数各不相同);1000 步混合录像(`record` 覆盖移动/瞬移/用道具/装备/卸下);怪物只建与参考一致的基线、不模拟任何改动;地图点事件保持为空(不模拟触发器)。"
- "测量口径与 06-16 完全一致:每个 case 预热 3 次不计时 + 采样 20 次,取排序后下中位数(`samples[10]`)、`min`(`samples[0]`)、`p95`(`samples[Math.ceil(20 * 0.95) - 1]`),三者 `Number(v.toFixed(3))`;计时只用 `globalThis.performance.now()`,`afterAll` 统一 `console.table`;**全文件零断言调用**(耗时只记录,绝不导致用例失败)。"
- "通道与门禁零变化:**不修改** `vitest.perf.config.ts`、`package.json`、`vite.config.ts`、`tsconfig*.json`(新文件被既有 `include: ['**/*.perf.ts']` 自动收集);**不修改** 06-16 的 `saveables.perf.ts`;`pnpm exec vitest list --filesOnly`(默认配置)输出不含 `.perf.ts`,`pnpm test:ci` 仍为 66 文件 / 680 passed / 1 skipped。"
- "质量门禁(D-44,文件级):改动文件 `eslint --fix` 后 `eslint <改动文件>` 0 错误;`saveablesReal.perf.ts` 在 `pnpm exec vue-tsc --noEmit` 输出中按文件路径过滤 0 类型错误;`pnpm test:ci` 全绿。"
- "阶段化(D-43):本计划按 阶段 1(构件:夹具迁移 + 单张真实地图 × NoCompression 的存档/读档/往返)→ 阶段 2(组合:固定真实侧负载 + 地图数量 1/5/13,仍 NoCompression)→ 阶段 3(完整:补 Low/High 压缩,跑满 27 行并汇总)执行,每阶段跑绿后才进入下一阶段;不使用 tracer 任务(D-43 明确取消 tracer-first)。出现阻断性问题按 D-05/D-07 暂停汇报、等确认。"
- "执行节奏(D-33):本计划执行前先向用户确认;执行完成后暂停,用中文分条简要汇报耗时结果与异常(如耗时逼近 `testTimeout`、Low/High 的耗时关系是否符合预期)。"
artifacts:
- path: packages-user/data-state/test/fixtures/floors.json
provides: "13 张真实魔塔地图夹具(13×13,图块编号 0..6);由仓库根 `floors.json` 原样移入,根副本删除"
- path: packages-user/data-state/test/saveablesReal.perf.ts
provides: "真实地图存读档耗时表(27 个 case:地图数量 1/5/13 × 三档压缩 × 存档/读档/往返),含文件内联的清除规则、图块注册、侧负载夹具与 `measureCase`"
- path: .planning/phases/06-unit-tests/06-17-SUMMARY.md
provides: "27 行耗时数值表 + 测量参数 + Low/High 分支差异说明 + D-44 门禁结果 + 实现风险记录(不落盘基线 JSON)"
key_links:
- "JSON 导入是硬前提之一:`tsconfig.json` 已有 `resolveJsonModule: true`(L11),且 `include` 覆盖 `packages-user/**/*.ts`,故 `import floorDataset from './fixtures/floors.json'` 可被 `vue-tsc` 类型检查、也可被 vite 直接解析(perf config 不加载 vue 插件,但 JSON 属 vite 内建支持);**不要**改用 `fs.readFileSync`(会把文件 IO 混进 Node 夹具、并与 D-02「不读真实游戏数据文件」以外的写法不一致)。"
- "`IMapRawData.map` 是 `Record<number, number[]>` 的**扁平**数组,而 `floors.json` 的 `map` 是 `number[][]`(13 行);必须 `flat()` 后再注入,否则 `MapLayer.setMapRef` 因 `array.length !== width * height` 走 `logger.warn(123)` 并直接返回(参考矩阵长度也必须同为 169)。"
- "`fromRaw`(`mapState.ts:190`)自己推导高度并要求 `map.length % width === 0`(L170/L207);宽度取 `entry.map[0].length` 可保证自洽,`size` 字段按设计忽略。`layerAlias[0]` 必须是字符串(用 `'bg'`,不要用 `'event'`,否则会顺带设置事件图层);`events[0]` 必须是对象(用 `{}`,即不模拟触发器的「未改动」状态)。"
- "`MapLayer.compareWith`(`mapLayer.ts:607`)只接受**首次**参考并把 `layerDirty` 置为 `!isEqualToRef()`;`saveHighCompression`(L780)只有在 `layerDirty` 且存在 `refArray` 时才写 `rows`;`loadLowCompression`/`loadHighCompression`(L872/L895)在缺少 `refArray` 时走 `logger.warn(124)`。故「live = 清除矩阵、参考 = 原始矩阵」既让图层保持脏(High 走行差异),也保证读档有参考可叠加。"
- "`MapState.compareWith`(`mapState.ts:384`)有一次性守卫 `if (this.compared) return;`,且 `createMap` 在 `compared` 为真后把新楼层直接标记为全脏(L258-260)→ 必须**先建完全部 N 张地图,再调用且只调用一次** `compareWith`;参考表要一次性覆盖这 N 张楼层,缺失的楼层会被 `markDirty(true)`、其图层参考为空。"
- "`MapState.loadState`(L409)在 `compression !== NoCompression && !compared` 时 `logger.error(55)` 并直接返回 → Low/High 两个压缩档**必须**有 `compareWith` 基线,否则测到的不是读档而是空转;`MapState.saveState`(L400)跳过非 active 楼层 → 每张注入的地图都要 `setActiveStatus(true)`。"
- "`HeroEquipsStore.add`(`equipStore.ts:184`)依赖 `state.tileStore.num(item)` 与 `state.itemStore.getData(num)`,两者缺失即返回 `-1` → 注册顺序必须是「`tileStore.addTile` → `itemStore.addItem` → `equipment.add`」;`HeroEquipsStore.loadState`(L267)在存档装备列表为空时 `logger.error(58)` → 固定侧负载必须始终至少含 1 件装备实例(本计划恒为 4 件)。"
- "20 个修饰器的构成:`EquipmentState`(`equipStore.ts:15-55`)把 `equip.value` 的每个条目变成一个 `ValueModifier`,经 `HeroEquipment.loadEquipEffect`(`equipment.ts:80-85`)→ `attribute.addModifier(name, modifier, true)` 挂到勇士属性;因此「每种装备只给 1 个 `value` 条目」时,4 件实例恰好贡献 4 个修饰器,手写 16 个即凑满 20。同时 `HeroEquipment.equip` 用数字槽位 0..3 且 `raw.equip.slots = [0,1,2,3]` 才能**同时**装备 4 件(同一槽位会被 `unequip` 自动替换成只剩 1 件)。"
- "`equip.value` 的类型是 `Map<SelectKey<THero, number>, number>`:直接写 `new Map([['atk', 1]])` 会被推断为 `Map<string, number>` 而类型不兼容;沿用 `hero/saveLoad.test.ts` 的既有写法——参数用 `[HeroKey, number][]`(`type HeroKey = SelectKey<IHeroAttr, number>`,`SelectKey` 是 `src/types/declaration/util.d.ts` 的全局环境类型,无需 import)再 `new Map(value)`。"
- "`EnemyManager.compareWith`(`manager.ts:200`)后 `saveState` 只导出 `dirtySet`(L245);与参考一致的模板不会变脏,因此「不模拟怪物改动」= 存档里没有怪物条目、也不会触发 117/118/119。`record`(`replay/system.ts:63`)只写录像数组、不校验命令是否注册,但 CoreState 构造时已注册 0..7 全部命令,故 1000 步混合命令合法。"
- "**实现风险(须如实记录,不得静默处理)**:`HeroState.loadState`(`state.ts:177-189`)会新建 `HeroAttribute` 并赋给 `this.attribute`,而 `HeroEquipment` 持有的是**构造时**传入的 attribute 引用(`state.ts:71` → `equipment.ts:21-26`),故读档时 `equip.loadState` → `loadEquipEffect` 会把装备修饰器挂到被替换掉的旧属性对象上。既有 `hero/saveLoad.test.ts` 从未断言「读档后装备加成仍在最终属性里」,故该引用不一致目前未被测试覆盖。本计划**只记录不断言**,不得据此修改任何生产代码;若执行者按代码阅读确认,按 D-06 追加 `06-TEST-FINDINGS.md` 条目(现象/最小复现/影响面/建议方向/严重度)并在 SUMMARY 中标注,交由用户裁决。"
- "`floors.json` 在仓库根当前是**未跟踪**文件(`git status` 显示 `?? floors.json`),故「删除根副本」不会出现在提交 diff 里,但工作区必须不再存在它;移动后的 `packages-user/data-state/test/fixtures/floors.json` 是新增跟踪文件。"
prohibitions:
- "不创建或修改任何生产/核心源码(`packages/**`、`packages-user/*/src/**` 下的非 `*.perf.ts` 文件、`src/**`)"
- "不修改 `vitest.perf.config.ts`、`package.json`、`vite.config.ts`、`tsconfig*.json`(新 perf 文件由既有 glob 自动收集,无需改配置/脚本)"
- "不修改 06-16 的 `packages-user/data-state/test/saveables.perf.ts`,不修改任何既有 `*.test.ts`,不修改 06-01..06-16 的 PLAN/SUMMARY"
- "不安装任何依赖(无新增 devDependencies;vitest 已在 devDependencies)"
- "不在 perf 文件内写任何断言调用(0 个;用户裁决「只记录、不断言」)"
- "不使用 fake timers、不使用 `it.skip`/`it.todo`、不使用 `process.hrtime` 等其它计时源"
- "不裁剪、不重排、不重新格式化 `floors.json` 的数据(保持 13 张原样;清除只在内存态进行)"
- "不落盘基线 JSON;不把耗时写入任何门禁脚本;不改动 `test`/`test:ci` 语义"
- "不为让 perf 跑绿而放宽告警、调小规模或调大超时(逼近 `testTimeout` 时按 D-07 暂停汇报)"
---
<objective>
按用户裁决为 Phase 6 追加**真实地图规模的存读档性能补充**:把用户提供的 13 张真实魔塔地图(13×13)落为测试夹具,并新建一个与既有 06-16 通道同构的 `*.perf.ts`,测量 `CoreState` 在真实地图内容下的**存档**、**读档**与**往返**耗时。
测量矩阵(用户裁决):地图数量 `1 / 5 / 13` × 压缩档 `NoCompression` / `LowCompression` / `HighCompression` × 计时项 `存档` / `读档` / `往返` = **27 个 case**,每个 case 预热 3 次(不计时)+ 采样 20 次,输出 `console.table`(列 `case` / `scale` / `median ms` / `min ms` / `p95 ms`);**不做任何断言**——耗时只记录,绝不让用例因慢而失败。
固定真实侧负载(每个 case 逐字相同,构造合理):50 个 flag 条目;勇士 20 个修饰器(含 4 件已装备实例贡献的 4 个);4 件装备实例(3 种装备编号);20 种道具(3 种装备 + 17 种消耗品,件数各不相同);1000 步混合录像;怪物只建与参考一致的基线(不模拟改动);地图点事件保持为空(不模拟触发器)。
**diff 存储语义(用户明确指定)**:每张注入地图的 live 内容 = **清除后**矩阵(`2/3/4/6` → `0`,保留 `0/1/5`),`compareWith` 参考 = **原始**真实矩阵(含 `2/3/4/6`)。实现取等价写法之一:`fromRaw(清除后)` 作 live + `compareWith(原始)` 作参考(本计划采用此写法并记录该选择)。这样 HighCompression 会走「逐行比较、只存变化行」的路径,即真实游戏的主读档负载。
Purpose: 06-16 的 ④ 只用「2×2 合成地图 + 件数负载」测存读档,回答不了「一张真实的 13×13 地图、含门/资源/怪物/机关门差异时,存档与读档分别要多久」。地图矩阵是 `HighCompression` 行差异路径的唯一驱动源,而这正是真实游戏里最主要的读档耗时来源。本计划用最小改动(移入一个已存在的夹具文件 + 新建一个 perf 文件)把这组数据固定下来,供后续回归对比;同时因为 `*.perf.ts` 仍不被默认 include 匹配,`pnpm test:ci` 的语义、收集文件数与结果一字不变。
Output: `packages-user/data-state/test/fixtures/floors.json`(真实地图夹具)、`packages-user/data-state/test/saveablesReal.perf.ts`(27 个 case 的耗时表),以及承载数值表的 `06-17-SUMMARY.md`。
阶段结构(D-43):阶段 1 构件(夹具迁移 + 单张真实地图 × NoCompression 的存档/读档/往返)→ 阶段 2 组合(固定真实侧负载 + 地图数量 1/5/13,仍 NoCompression)→ 阶段 3 完整(补 Low/High 压缩,跑满 27 行并汇总)。每阶段跑绿后才进入下一阶段。
</objective>
<execution_context>
@C:/Users/book/.config/opencode/gsd-core/workflows/execute-plan.md
@C:/Users/book/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/06-unit-tests/06-CONTEXT.md
@.planning/phases/06-unit-tests/06-16-PLAN.md
@.planning/phases/06-unit-tests/06-16-SUMMARY.md
@packages-user/data-state/test/saveables.perf.ts
@packages-user/data-state/test/saveablesRoundTrip.test.ts
@packages-user/data-base/src/map/mapState.ts
@packages-user/data-base/src/map/gameMap.ts
@packages-user/data-base/src/map/mapLayer.ts
@packages-user/data-base/src/hero/equipStore.ts
@packages-user/data-base/src/hero/equipment.ts
@packages-user/data-base/src/hero/items.ts
@packages-user/data-base/src/hero/state.ts
@packages-user/data-common/src/store/types.ts
@packages-user/data-common/src/replay/system.ts
@packages-user/data-state/src/core.ts
@tsconfig.json
@dev.md
</context>
<execution_rules>
- 只新增 `packages-user/data-state/test/fixtures/floors.json` 与 `packages-user/data-state/test/saveablesReal.perf.ts`,并删除仓库根的 `floors.json`;**绝不修改任何生产/核心源码**(`packages/**`、`packages-user/*/src/**` 下的非 `*.perf.ts` 文件、`src/**`)。
- **不改** `vitest.perf.config.ts`/`package.json`/`vite.config.ts`/`tsconfig*.json`:新 perf 文件由既有 `test.include: ['**/*.perf.ts']` 自动收集,`pnpm test:perf` 无需任何配置改动。
- **不改** 06-16 的 `saveables.perf.ts`(新文件是它的兄弟文件,不是它的替代);**不改**任何既有 `*.test.ts` 与 06-01..06-16 的 PLAN/SUMMARY。
- 新文件一律 `CRLF` 换行、4 空格缩进、单引号、行宽 80(`dev.md` + `.prettierrc`);`floors.json` 移入时保持原样(已是 CRLF、17,292 字节)。
- 每个新的顶层结构(`describe` / 测量助手 / fixture 工厂 / 常量组)前写中文单行注释说明用途;每个 `it(...)` 前写中文单行注释说明覆盖内容(`dev.md` 注释规范)。
- **不使用 `import type`、不写连续 `as`**(`dev.md`);能不用 `as` 就不用 —— 夹具的类型缺口优先用「逐字段复制进显式接口」的写法解决,而不是断言。
- 测量助手与夹具**文件内联复用 06-16 的形态**(D-02/D-03):不新建跨文件共享 fixture/factory/helper,不引用其它 `*.perf.ts` 的 helper。
- **零断言(用户裁决)**:perf 文件不得出现任何断言调用;耗时只进表格与 SUMMARY;用例内不得因为耗时抛出或跳过。
- 每个 case 单独一个 `it`(一个「地图数量 × 压缩档 × 计时项」一个 `it`,共 27 个),避免某个慢 case 拖垮同文件其它 case 的超时归属;`afterAll` 里统一 `console.table(records)`。
- 测量助手参数固定(与 06-16 逐字一致):`WARMUP_RUNS = 3`(不计时)、`SAMPLE_RUNS = 20`、下中位数 `samples[10]`、`p95 = samples[Math.ceil(20 * 0.95) - 1] = samples[18]`、`min = samples[0]`,三者统一 `Number(v.toFixed(3))`;计时只用 `globalThis.performance.now()`。
- 夹具构建(含 `compareWith` 基线)必须在预热循环**之前**完成,不得把夹具构建时间算进耗时;`读档` 用的快照也在预热前取一次(各 `loadState` 实现只读取存档对象、不回写,故同一快照可跨样本复用)。
- 未被使用的 `for...of` 迭代变量用 `_` 前缀(`@typescript-eslint/no-unused-vars` 的 `varsIgnorePattern: '^_'`),否则会报 eslint 错误。
- 夹具必须**无告警噪声**:地图链路不得触发 `warn 123`/`warn 124`/`error 55`,侧负载不得触发 `warn 108`/`warn 117`/`error 58`/`error 59`。运行输出里出现 `[WARNING Code` / `[ERROR Code` 即说明夹具有问题——**调整夹具,不要放宽**。
- **执行协议(D-33):执行本计划前必须先向用户确认;执行完成后暂停,用中文分条简要汇报本计划结果;详细结果写入共用的 `06-TEST-FINDINGS.md`(无新增条目时注明「无新增条目」)。**
- **阶段门禁(D-43):三个阶段按 构件 → 组合 → 完整 顺序执行,每阶段跑绿后才进入下一阶段;若某阶段发现阻断性问题(例如某 case 因 `testTimeout` 失败、或 perf 文件被 `pnpm test:ci` 收集),暂停并按 D-05/D-07 向用户汇报、等确认,不得跳过或弱化。**
- **质量门禁(D-44,每个阶段执行后、提交前必过)**:对改动文件运行 `eslint --fix`,并确保 `eslint <改动文件>` **0 错误**;对**本计划改动的 perf 文件**运行 `pnpm exec vue-tsc --noEmit`,按输出文件路径过滤确保 0 类型错误(全局既有无关错误,如 `client-modules`/`legacy-plugin-data`,不在本门禁范围);`pnpm test:ci` **全绿**。任一不通过则**不得提交**。
- **不得静默改规模或改超时**:若 `13` 地图的某个 case 实测逼近/超过 `testTimeout`(30000ms,与 `vite.config.ts` 一致),不得私自调小规模或调大 timeout —— 按 D-07 暂停汇报,由用户裁决。
</execution_rules>
<tasks>
<task type="auto">
<name>阶段 1(构件级):移入 13 张真实地图夹具,落地「单张真实地图 × NoCompression」的存档/读档/往返三行</name>
<files>packages-user/data-state/test/fixtures/floors.json, packages-user/data-state/test/saveablesReal.perf.ts</files>
<read_first>
floors.json(仓库根,17,292 字节、CRLF;结构 `{datasetId, data: {<name>: {map: number[][], size: number[], val, symmetry, outerWall, roomCount, highDegBranchCount}}}`;实测 13 个条目、全部 13×13;实测出现过的图块编号为 `0..6`,其中 `1`(墙壁)与 `5`(入口)每张图都有,`2/3/4` 多数有,`6` 仅 3 张有)
packages-user/data-state/test/saveables.perf.ts(L14–38 `vi.hoisted` stub;L40–45 `WARMUP_RUNS`/`SAMPLE_RUNS`/`ITEM_SCALES`;L62–111 `PerfRecord` + `measureCase`(预热 3 + 采样 20 + 下中位数 + p95 + `toFixed(3)`);L204–206 `afterAll(() => { console.table(records); })` —— 新文件必须与之同构,且**不得修改本文件**)
packages-user/data-base/src/map/mapState.ts(L190–247 `fromRaw`:要求 `map` 为 `Record<number, number[]>` 的**扁平**数组、`map.length % width === 0`、`layerAlias[zIndex]` 为字符串、`events[zIndex]` 为对象;L249–263 `createMap`(`compared` 为真时新楼层直接脏);L384–396 `compareWith`(一次性守卫);L400–407 `saveState`(只存 active 楼层);L409–425 `loadState`(Low/High 且 `!compared` 时 `logger.error(55)` 并返回))
packages-user/data-base/src/map/gameMap.ts(L59–70 `addLayer`(图层 `zIndex` 默认 0);L168–177 `compareWith`(逐图层取 `data.get(layer.zIndex)`,缺失则 `markDirty(true)`);L192–203 `saveState`(丢弃空图层存档))
packages-user/data-base/src/map/mapLayer.ts(L371–391 `setMapRef`(长度不等则 `logger.warn(123)` 并返回);L607–611 `compareWith`(只接受首次参考,`layerDirty = !isEqualToRef()`);L722–735 `diffRows`;L754–775 `saveLowCompression`(脏且与参考不同 → 整图 `fullMap`);L780–812 `saveHighCompression`(脏且有参考 → `rows` 行差异);L872–920 `loadLowCompression`/`loadHighCompression`(无 `refArray` 则 `logger.warn(124)`))
packages-user/data-base/src/map/staticTile.ts(L19–25 `num`/`raw`(未注册编号返回 null、不告警);L36–39 `shouldSave`(点事件未变则静态块不入存档))
packages-user/data-state/test/saveablesRoundTrip.test.ts(L20–44 stub;L131–140 `maps.createMap` + `maps.compareWith` 基线写法)
tsconfig.json(L11 `resolveJsonModule: true`;L22–34 `include` 覆盖 `packages-user/**/*.ts`)
dev.md(CRLF、4 空格、单引号、禁 `import type`、禁连续 `as`、注释规范、`_` 前缀)
</read_first>
<action>
本任务分三块,全部属于「只加夹具与测试」:
**① 夹具迁移(唯一的数据文件改动)**
- 把仓库根的 `floors.json` **原样**移到 `packages-user/data-state/test/fixtures/floors.json`:内容与换行一字不改(CRLF、17,292 字节、13 个条目、全部 13×13),不裁剪、不重排、不重新格式化。
- 随后删除仓库根副本(`Remove-Item -LiteralPath floors.json`)。根副本当前是**未跟踪**文件,删除不会出现在提交 diff 中,但工作区必须不再存在它(`Test-Path floors.json` 必须为假)。
- 该 JSON 只作为测试夹具被新 perf 文件通过相对路径导入;不要把数据集内容复制进 `.ts` 源码(保持「真实数据」可追溯)。
**② 新建 `packages-user/data-state/test/saveablesReal.perf.ts`**
文件首行中文注释说明用途(基于 13 张真实魔塔地图的 CoreState 存读档性能测量、只记录不断言)。结构:
- `vi.hoisted(() => { ... })` 逐字镜像 `saveables.perf.ts` L14–38(`vi.stubGlobal('main', { replayChecking: true })`、`vi.stubGlobal('location', { origin: 'http://localhost' })`、`Map.prototype.getOrInsert`/`getOrInsertComputed` 补丁)。
- 静态 import:`{ afterAll, describe, it, vi } from 'vitest'`;`{ ItemCategory, SaveCompression, TileType, type IEnemyAttr, type IHeroAttr, type IItemRawData, type IMapRawData } from '@user/data-common'`;`{ Enemy, ValueModifier } from '@user/data-base'`;`{ CoreState, createCoreState } from '../src/core'`;夹具 `import floorDataset from './fixtures/floors.json'`;类型 `{ type IGameMap } from '@user/data-base'` 如需(按最小用量,未使用的导入必须删除,否则 D-44 的 eslint 门禁不通过)。**不使用 `import type` 语法**(`dev.md`)。
- 常量:`WARMUP_RUNS = 3`、`SAMPLE_RUNS = 20`、`MAP_SCALES = [1, 5, 13] as const`(阶段 1 的循环只用 `1`,常量可先按 `1` 起步并注明后续阶段扩展)、`CLEAR_CODES: readonly number[] = [2, 3, 4, 6]`(**必须显式标注 `readonly number[]`,不要写成 `as const`** —— 06-16 曾因 `as const` 把元素收窄为字面量联合,导致 `.includes(number)` 报 TS2345)、以及 06-16 形态的压缩档常量(`interface CompressionCase { readonly label: string; readonly value: SaveCompression }` + `COMPRESSIONS`,阶段 1 只含 `NoCompression`)。
- 夹具读取(**不引入任何 `as`**):声明内联接口 `interface IFloorEntry { readonly map: number[][]; readonly size: number[] }`;用 `for (const [name, entry] of Object.entries(floorDataset.data))` 把**只需要的字段**逐字段复制进 `const FLOOR_DATA: Record<string, IFloorEntry>`(即显式忽略 `val`/`symmetry`/`outerWall`/`roomCount`/`highDegBranchCount`,满足「只使用 map 字段」);`const FLOOR_NAMES: readonly string[] = Object.keys(FLOOR_DATA)` —— JSON 声明顺序即稳定顺序,「前 N 张」因此是确定性的选择。
- 图块注册(编号集合与地图数量无关):先遍历**全部 13 个条目**的原始矩阵收集出现过的非零编号并升序去重,得到 `TILE_CODES: readonly number[]`(实测为 `1..6`;显式标注为 `readonly number[]`,理由同上),再对每个编号调用一次 `state.tileStore.addTile({ num, id, events: {}, type: TileType.Terrain, pass: { onlyEvents: false, inPass: 15, outPass: 15 }, eventPass: true })`,`id` 用 `perf-tile-<num>` 这类互不冲突的字符串(`TileStore.addTile` 仅在编号或 id 冲突时 `logger.warn(133/134)`,每个编号只注册一次即不触发)。
**据实记录(不得当成告警消除手段)**:`StaticTile.raw()` 对未注册编号返回 `null` 且不告警,故注册的动机是夹具真实性(真实游戏里 `1..6` 都有定义)与「原始参考矩阵含 2/3/4/5/6」的一致性,而不是消除某条既有告警。
- 矩阵工具(文件内联,D-02/D-03):`toFlat(matrix: number[][]): number[]` 用 `matrix.flat()`(`IMapRawData.map` 要求扁平数组);`clearCodes(flat: readonly number[]): number[]` 把命中 `CLEAR_CODES` 的值置 `0`(**保留** `0` 空地 / `1` 墙壁 / `5` 入口)。
- 单张地图的 raw 构造(由选中的条目生成):`floorId` 用数据集里的条目名(如 `'hhzjmt::MT161'`);`width` 用 `entry.map[0].length`(与矩阵自洽,天然满足 `length % width === 0`;`size` 按设计忽略);`map: { 0: clearedFlat }`(图层纵深 0);`layerAlias: { 0: 'bg' }`(**不要用 `'event'`**,避免顺带设置事件图层);`events: { 0: {} }`(不模拟触发器/点事件,与用户裁决一致)。live 内容 = **清除后**矩阵,参考 = **原始**矩阵。
- 夹具工厂 `createRealMapFixture(mapCount: number): CoreState`(每个 case 调一次,构建阶段**不参与计时**):`createCoreState()` → 注册图块 → 对前 `mapCount` 张地图逐个 `state.maps.fromRaw(raw)`(`fromRaw` 内部创建图层并写入清除后的矩阵;如需要图层引用,用其返回值而非再调 `addLayer`)→ 逐个 `setActiveStatus(true)`(`MapState.saveState` 跳过非 active 楼层)→ **全部地图建完后只调用一次** `state.maps.compareWith(reference)`,其中 `reference = new Map<string, Map<number, Uint32Array>>`,每个 floorId 对应 `new Map([[0, new Uint32Array(originalFlat)]])`(`compareWith` 有一次性守卫,且 `compared` 为真后 `createMap` 会把新楼层标记为全脏,故「先建图、后一次 compareWith」的顺序不可颠倒)。
- 阶段 1 **不做任何侧负载**(flags / 勇士 / 装备 / 道具 / 录像 / 怪物全部留空,怪物也不建基线),以确保「构件级」只测地图链路本身(Low/High 尚未进入)。
- 测量助手逐字沿用 06-16 口径(文件内联、不跨文件共享):`interface PerfRecord { case: string; scale: string; 'median ms': number; 'min ms': number; 'p95 ms': number }`、模块级 `const records: PerfRecord[] = []`、`function measureCase(caseName: string, scale: string, run: () => void): PerfRecord` —— 先跑 `WARMUP_RUNS` 次不计时;再 `SAMPLE_RUNS` 次,每次用 `const start = globalThis.performance.now(); run(); samples.push(globalThis.performance.now() - start);`;`samples.sort((left, right) => left - right)` 后取 `median = samples[SAMPLE_RUNS / 2]`(下中位数)、`min = samples[0]`、`p95 = samples[Math.ceil(SAMPLE_RUNS * 0.95) - 1]`,三者 `Number(v.toFixed(3))`;`records.push(record)` 后 `return`。
- `afterAll(() => { console.table(records); })` 打印整表。
- 阶段 1 的 3 个 case(`describe('真实地图存读档性能')` 内;`scale` 列展示为 `<mapCount>/<compression.label>`;每个 `it` 前写一行中文注释说明覆盖内容):
1. `存档`:单样本 = `state.saveState(compression.value)`;
2. `读档`:`it` 内**不计时**地先取一次快照 `const snapshot = state.saveState(compression.value)`,单样本 = `state.loadState(snapshot, compression.value)`(各 `loadState` 实现只读取存档对象、不回写,故同一快照可跨样本复用);
3. `往返`:单样本 = 同一压缩档下 `saveState` + `loadState`。
- 全文件**不得出现任何断言调用**(用户裁决:只记录、不断言);不使用 fake timers;不使用 `it.skip`/`it.todo`。
- 若某个 case 实测逼近或超过 `testTimeout`(30000ms),**不得**调小规模或调大 timeout —— 按 D-07 暂停汇报、等用户裁决。
**③ 提交前自查(可选、不得留在仓库里)**
如需要确认 diff 行的确非空(即 High 压缩真的走了行差异),可在**提交前**临时新建一个 scratch 文件打印 `saveState(HighCompression)` 结果里的 `rows.size`,确认后**删除**该文件;最终仓库不得保留任何临时文件。若嫌麻烦,可直接依据 `mapLayer.ts` 的 `saveHighCompression` 分支条件与 `key_links` 的推导静态确认。
</action>
<verify>
<automated>pnpm test:perf(须成功收集 5 个 `*.perf.ts`,共 18 + 3 = 21 行 case 表,列为 `case`/`scale`/`median ms`/`min ms`/`p95 ms`;无断言失败;输出中不出现 `[WARNING Code` / `[ERROR Code`);pnpm exec vitest list --filesOnly(默认配置;输出不得含 `.perf.ts`);PowerShell 复核 `Test-Path packages-user/data-state/test/fixtures/floors.json` 为真、`Test-Path floors.json` 为假</automated>
<fails_when>根目录 `floors.json` 未删除、或新夹具文件缺失/内容被改动(字节数不是 17292、条目数不是 13);或 `saveablesReal.perf.ts` 未生成、或被 `pnpm test:perf` 遗漏;或表格缺 `median ms`/`min ms`/`p95 ms` 任一列;或文件内出现断言调用;或运行输出出现 `[WARNING Code 123]`(参考矩阵/活矩阵长度与 `width × height` 不符,说明未 `flat()` 或宽度取值不对)、`[WARNING Code 124]`(缺少 `compareWith` 参考)、`[ERROR Code 55]`(Low/High 缺基线)、`[WARNING Code 121]`(floorId 重复)之一;或 `pnpm exec vitest list --filesOnly` 输出中出现 `.perf.ts`;或本阶段改动了任何生产源码、`vitest.perf.config.ts`、`package.json`、`vite.config.ts`、`tsconfig*.json`、`saveables.perf.ts`</fails_when>
<acceptance_criteria>
- `packages-user/data-state/test/fixtures/floors.json` 存在且为根 `floors.json` 的原样副本(17,292 字节、CRLF、13 个 13×13 条目);仓库根 `floors.json` 已删除。
- 新增 `packages-user/data-state/test/saveablesReal.perf.ts`:`vi.hoisted` stub + 静态 JSON 导入 + 内联 `IFloorEntry`/`FLOOR_DATA`/`TILE_CODES`/`clearCodes`/`createRealMapFixture` + 内联 `measureCase`(预热 3 + 采样 20 + 下中位数/min/p95 + `toFixed(3)`)+ `afterAll` 打印 `console.table`;`MAP_SCALES` 已声明、阶段 1 只跑 `1`;`COMPRESSIONS` 阶段 1 只含 `NoCompression`。
- `pnpm test:perf` 收集 5 个 perf 文件,打印 21 行表格(新增的 3 行 `case` 为 `存档`/`读档`/`往返`,`scale` 为 `1/NoCompression`),无断言失败、无 warn/error 码噪声。
- 5 个 case 的单样本分别是「仅 `saveState`」「仅 `loadState`(快照在预热前取一次)」「save + load」;夹具构建与快照获取都在预热循环之前。
- 全文件 0 个断言调用;未使用 fake timers、`it.skip`;测量助手文件内联(无跨文件共享)。
- `pnpm exec vitest list --filesOnly`(默认配置)输出不含 `.perf.ts`;`pnpm test:ci` 全绿且收集文件数与基线一致(66 文件 / 680 passed / 1 skipped)。
- D-44 门禁通过:改动文件 `eslint --fix` 后 `eslint` 0 错误(`console.table` 只触发 `no-console` 的 warn,不是 error);`saveablesReal.perf.ts` 在 `pnpm exec vue-tsc --noEmit` 输出中按路径过滤 0 类型错误;`pnpm test:ci` 全绿。
- 除 `packages-user/data-state/test/fixtures/floors.json`(新增)与 `packages-user/data-state/test/saveablesReal.perf.ts`(新增)外无任何文件改动(`git status` 复核;根 `floors.json` 为未跟踪文件,其删除不体现在 diff 中)。
</acceptance_criteria>
</verify>
<done>夹具迁移完成(新夹具在、根副本不在)、3 行表格打印成功、无告警噪声、`test:ci` 不受影响后,才进入阶段 2。若出现 `warn 123/124`、`error 55` 或某 case 超时,暂停并按 D-07 汇报、等确认(不得调规模/超时、不得忽略告警)。</done>
</task>
<task type="auto">
<name>阶段 2(组合级):加入固定真实侧负载并把地图数量扩到 1/5/13(仍 NoCompression)</name>
<files>packages-user/data-state/test/saveablesReal.perf.ts</files>
<read_first>
本文件阶段 1 的最终内容(`vi.hoisted` stub、`measureCase`、`createRealMapFixture`、阶段 1 的 3 个 `it`)—— 本阶段在其上扩展,不重建
packages-user/data-state/test/saveables.perf.ts(L113–124 `createEnemy`;L126–154 `createItemRaw`;L156–172 `registerItem`(`addTile` → `itemStore.addItem`);L179–202 件数夹具的注册顺序)
packages-user/data-base/src/hero/equipStore.ts(L184–195 `add` 的失败返回值 `-1` 与 `nextUid`;L15–55 `EquipmentState` 由 `equip.value`/`equip.percentage` 生成修饰器;L259–288 `saveState`/`loadState`(装备列表为空时 `logger.error(58)`))
packages-user/data-base/src/hero/equipment.ts(L32–72 `canEquipTo`(数字槽位必须包含在 `raw.equip.slots` 中,槽位已占用返回 `NeedReplace`);L152–197 `equip`(同槽位旧装备会被 `unequip` 替换);L336–346 `loadState`)
packages-user/data-base/src/hero/items.ts(L74–109 `addItem`(`Equipment` → `equipment.add`;`Constant`/`Consumable` → 分表计数并支持一次多件))
packages-user/data-base/src/hero/state.ts(L106–130 `registerModifier`/`createAndInsertModifier`;L148–195 `saveState`/`loadState`)
packages-user/data-base/src/hero/attribute.ts(L180–201 `addModifier`(同一个修饰器实例已绑定 owner 时 `logger.warn(108)`);L231–239 `markDirty`/`markModifierDirty`)
packages-user/data-base/src/hero/saveLoad.test.ts(L64 `type HeroKey = SelectKey<IHeroAttr, number>`;L103–127 `createEquipItem` 的 `slots`/`value`/`percentage` 参数写法 —— 非空 `equip.value` 的权威写法)
packages-user/data-base/src/enemy/manager.ts(L200–214 `compareWith`;L245–253 `saveState`(只导出 `dirtySet`);L276–289 `updateDirty`)
packages-user/data-common/src/replay/system.ts(L63–68 `record(code, ...params)`)
packages-user/data-common/src/replay/types.ts(`ReplayCommandCode`:`0..3` 为 Up/Right/Down/Left 无参数、`4` Teleport、`5` UseItem、`6` Equip、`7` Unequip;`ReplayParamValue = number | string | boolean | bigint`)
packages-user/data-state/src/shared.ts(L31–42 `HERO_DEFAULT_ATTRIBUTE` 的字段名:`name` 为字符串,数值字段为 `hp`/`hpmax`/`atk`/`def`/`mdef`/`mana`/`manamax`/`money`/`exp`)
packages-user/data-common/src/store/types.ts(L141–209 `ItemCategory` 与 `IItemRawData`(`effect` 与 `equip` 都是必填))
</read_first>
<action>
在阶段 1 的骨架上加入**固定的真实侧负载**(每个 case 逐字相同),并把地图数量扩到 `1 / 5 / 13`(压缩档仍只跑 `NoCompression`,共 3 × 1 × 3 = **9 行**)。
**① 常量与类型**
- `FLAG_COUNT = 50`;`EQUIP_ITEM_NUMS: readonly number[] = [3000, 3001, 3002]`(3 种装备编号);`EQUIP_INSTANCE_NUMS: readonly number[] = [3000, 3001, 3002, 3000]`(4 件实例,其中一种两件);`EQUIPPED_SLOTS: readonly number[] = [0, 1, 2, 3]`;`OTHER_ITEM_BASE = 3020`、`OTHER_ITEM_KINDS = 17`(编号 `3020..3036`)→ 装备 3 种 + 其它 17 种 = **20 种道具**;`MANUAL_MODIFIER_COUNT = 16`(+ 装备贡献的 4 = **20 个修饰器**);`REPLAY_STEPS = 1000`。
- `type HeroKey = SelectKey<IHeroAttr, number>;`(`SelectKey` 是 `src/types/declaration/util.d.ts` 的全局环境类型,无需 import)。
- `const HERO_ATTR_NAMES: readonly (keyof IHeroAttr)[] = ['hp', 'hpmax', 'atk', 'def', 'mdef', 'mana', 'manamax', 'money', 'exp'];`(手写修饰器在这 9 个数值属性间轮转分布)。**不要写成 `as const`**(避免字面量收窄影响 `createAndInsertModifier` 的入参兼容)。
**② 道具工厂(文件内联,D-02)**
- `createItemRaw(num: number, id: string, category: ItemCategory, value: [HeroKey, number][] = []): IItemRawData<IHeroAttr>`:返回完整定义(`effect` 与 `equip` 两个必填字段都要给;`equip.slots` 用 `EQUIPPED_SLOTS`、`animate: 'sword'`、`value: new Map(value)`、`percentage: new Map()`、`loadEvent`/`unloadEvent` 为 `null`)。**必须走 `[HeroKey, number][]` 参数再 `new Map(value)`**,不要直接写 `new Map([['atk', 1]])`(会被推断成 `Map<string, number>`,与 `Map<SelectKey<IHeroAttr, number>, number>` 不兼容,D-44 类型门禁会失败)。
- 装备种类每种**恰好 1 个** `value` 条目(例如 `[['atk', 1]]`):这样每个装备实例贡献 **1** 个修饰器,4 件实例恰好 4 个,配合 16 个手写修饰器凑满 20 个(数量关系写进文件注释,便于人工核对)。
- `registerItem(state: CoreState, item: IItemRawData<IHeroAttr>): void`:先 `state.tileStore.addTile({ num: item.num, id: item.id, events: {}, type: TileType.Item, pass: { onlyEvents: false, inPass: 15, outPass: 15 }, eventPass: true })`,再 `state.itemStore.addItem(item)`。**顺序不可颠倒**:`HeroEquipsStore.add` 依赖 `tileStore.num` + `itemStore.getData`,缺失即返回 `-1` 且装备不会入库。
**③ 侧负载函数 `seedSideLoad(state: CoreState): void`(每个 case 完整执行一遍)**
1. flags:`for (let i = 0; i < FLAG_COUNT; i++) state.flags.setFieldValue('perf-flag-' + i, i);`(50 个条目,值取不同程度以模拟真实 flag 噪声)。
2. 道具定义:注册 `EQUIP_ITEM_NUMS`(`ItemCategory.Equipment`,各带 1 个 `value` 条目)与编号 `3020 + i` 的 17 个 `ItemCategory.Consumable`。
3. 装备实例:对 `EQUIP_INSTANCE_NUMS` 依次 `const uid = state.hero.items.equipment.add(num);`,再 `state.hero.equip.equip(uid, EQUIPPED_SLOTS[i]);` —— 因为 `raw.equip.slots = [0,1,2,3]` 且槽位互不重复,4 件可**同时**装备;**不要**把 4 件都塞进同一个槽位(`HeroEquipment.equip` 会把旧装备 `unequip` 掉,最终只剩 1 件)。装备的 `value` 条目经 `EquipmentState` → `loadEquipEffect` → `attribute.addModifier(name, modifier, true)` 挂到勇士属性上,即 4 个修饰器。
4. 其它道具条目:对 17 种消耗品 `state.hero.items.addItem(3020 + i, 1 + (i % 5));`(件数各不相同,模拟真实背包)。
5. 手写修饰器:`state.hero.registerModifier('@system/value', () => new ValueModifier(5));` 后 `for (let i = 0; i < MANUAL_MODIFIER_COUNT; i++) state.hero.createAndInsertModifier('@system/value', HERO_ATTR_NAMES[i % HERO_ATTR_NAMES.length]);`(每次都是新实例,避免 `logger.warn(108)`)。
6. 录像(**最后写入**,避免 `equip()` 内部的 `record` 混进这 1000 步):`for (let i = 0; i < REPLAY_STEPS; i++)` 按 `code = i % 8` 调 `state.replaySystem.record(code, ...params)`,参数与命令语义匹配(`0..3` 移动无参数;`4` 瞬移 `[2, 3]`;`5` 用道具 `[3000 + i % 20]`;`6` 装备 `[uid, 0, true]`;`7` 卸下 `[0]`),共 1000 步。
7. 怪物(**不模拟任何改动**):`state.enemyManager.addPrefab(createEnemy());` + `state.enemyManager.compareWith(new Map([[1, createEnemy()]]));` —— 与参考一致的模板不会进入 `dirtySet`,故存档里没有怪物条目、也不触发 `117/118/119`。`createEnemy` 内联合成 `Enemy<IEnemyAttr>`(`hp`/`atk`/`def`/`money`/`exp`/`point`/`guard: new Set()`,镜像 `saveables.perf.ts` L113–124)。
- 地图点事件保持 `events: { 0: {} }`(不模拟触发器)。
**④ 接入夹具与扩展规模**
- `createRealMapFixture(mapCount)` 调整为:`createCoreState()` → 注册图块 → `seedSideLoad(state)` → 建前 `mapCount` 张真实地图(`fromRaw` + `setActiveStatus(true)`)→ 一次性 `compareWith(原始参考)`。侧负载与地图注入次序无关,但每个 case 都必须完整执行,保证 27 个 case 的侧负载逐字相同。
- `MAP_SCALES` 扩为 `[1, 5, 13] as const`(确定性取「第 1 张 / 前 5 张 / 全部 13 张」);`COMPRESSIONS` 本阶段**保持只含 `NoCompression`**。
- 结果应为 **9 行** case 表(3 个地图数量 × 3 个计时项),每行仍是独立 `it`,每个 `it` 前的中文注释写明覆盖的规模与计时项。
- 结构不变:`measureCase` 口径、`console.table`、零断言、无 fake timers、无 `it.skip`。
- 若 `13` 一档的某个 case 实测逼近或超过 `testTimeout`(30000ms),**不得**改规模或超时 —— 按 D-07 暂停汇报、等用户裁决。
</action>
<verify>
<automated>pnpm test:perf(须收集 5 个 `*.perf.ts`,共 18 + 9 = 27 行 case 表;无断言失败;输出中不出现 `[WARNING Code` / `[ERROR Code`);pnpm exec vitest list --filesOnly(默认配置;输出仍不含 `.perf.ts`);pnpm test:ci(全绿)</automated>
<fails_when>侧负载缺项(flags 不是 50、道具不是 20 种、装备不是 4 件实例/3 种编号、手写修饰器不是 16 个、录像不是 1000 步);或 `MAP_SCALES` 不是 `[1, 5, 13]`;或 `pnpm test:perf` 未跑到 9 行新增 case;或运行输出出现 `[WARNING Code 108]`(同一修饰器实例被重复挂载)、`[WARNING Code 117]`(`compareWith` 被调用两次)、`[WARNING Code 121]`(floorId 重复)、`[ERROR Code 58]`(装备列表为空)、`[ERROR Code 59]`(道具定义缺失,说明注册顺序颠倒)、`[WARNING Code 123/124]`、`[ERROR Code 55]` 之一;或 `state.hero.equip.equip` 只装备成功 1 件(槽位重复);或文件内出现断言调用;或生产源码/配置/既有 perf 文件被改动</fails_when>
<acceptance_criteria>
- 新增固定侧负载:50 个 flag(`setFieldValue`)、20 个修饰器(4 个来自 4 件已装备实例 + 16 个手写 `ValueModifier`,分布在 9 个数值属性)、4 件装备实例(3 种编号,装备到 4 个互不重复的数字槽位)、20 种道具(3 种装备 + 17 种消耗品,消耗品件数 `1..5` 不同)、1000 步混合录像(`code = i % 8`,参数与命令语义匹配)、怪物只有与参考一致的基线、地图点事件为空。
- `createRealMapFixture` 的构建顺序为「注册图块 → 侧负载 → 建 N 张地图 → 一次 `compareWith`」,且构建全部发生在预热循环之前。
- `MAP_SCALES = [1, 5, 13]`;`COMPRESSIONS` 本阶段只含 `NoCompression`;`pnpm test:perf` 输出 27 行(18 既有 + 9 新增),无断言失败、无 warn/error 码噪声。
- 全文件 0 个断言调用;`measureCase` 与夹具仍为文件内联;未使用 fake timers / `it.skip`。
- `pnpm exec vitest list --filesOnly`(默认配置)输出不含 `.perf.ts`;`pnpm test:ci` 全绿且收集文件数不变。
- D-44 门禁通过:改动文件 `eslint --fix` 后 `eslint` 0 错误;`saveablesReal.perf.ts` 在 `pnpm exec vue-tsc --noEmit` 输出中按路径过滤 0 类型错误(特别注意 `[HeroKey, number][]` 的写法,避免 `Map<string, number>` 推断)。
</acceptance_criteria>
</verify>
<done>9 行新增表格打印成功、侧负载项数与 20 个修饰器/20 种道具的关系在文件注释中可核对、无告警噪声、`test:ci` 不受影响后,才进入阶段 3。若某 case 超时或出现告警码,暂停并按 D-07 汇报、等确认(不得调规模/超时、不得忽略告警)。</done>
</task>
<task type="auto">
<name>阶段 3(完整级):补 Low/High 压缩跑满 27 行,并把数值表与实现风险写入 06-17-SUMMARY.md</name>
<files>packages-user/data-state/test/saveablesReal.perf.ts</files>
<read_first>
本文件阶段 2 的最终内容(侧负载、`createRealMapFixture`、9 个 `it`)
packages-user/data-base/src/map/mapLayer.ts(L722–735 `diffRows`;L754–775 `saveLowCompression`(脏且与参考不同 → 整图 `fullMap`,**没有**行差异分支);L780–812 `saveHighCompression`(脏且有参考 → 行差异);L872–920 `loadLowCompression`/`loadHighCompression`(用 `refArray` + 差异行重建))
packages-user/data-base/src/hero/equipStore.ts(L104–164 装备存档的三档分支:NoCompression 存全量、Low/High 存与原始定义的差异)
packages-user/data-common/src/store/types.ts(L300–311 `IMapRawData` 的扁平数组约定)
.planning/phases/06-unit-tests/06-CONTEXT.md(D-03 内联夹具、D-33 执行节奏、D-43 阶段化、D-44 门禁)
</read_first>
<action>
在阶段 2 基础上补齐压缩档维度,跑满矩阵并汇总结果。
**① 扩展压缩档**
- 把 `COMPRESSIONS` 扩为三档(沿用 06-16 的 `CompressionCase` 形态与顺序):`NoCompression` → `LowCompression` → `HighCompression`。
- 结果矩阵变为 `3`(地图数量 `1/5/13`)× `3`(压缩档)× `3`(计时项 `存档`/`读档`/`往返`)= **27 行**;`it` 的生成顺序建议「外层地图数量 → 中层压缩档 → 内层三个计时项」,保持表格顺序稳定、便于人工对比。
- 不改动 `measureCase`、夹具构建顺序、侧负载内容与 06-16 的既有文件;不新增任何断言。
- 若某一档(尤其 `13/HighCompression`)实测逼近或超过 `testTimeout`(30000ms),**不得**调小规模或调大 timeout —— 按 D-07 暂停汇报、等用户裁决。
**② 跑满并取数**
- 运行 `pnpm test:perf`,确认收集 5 个 perf 文件、共 `18 + 27 = 45` 行表格、无断言失败、运行日志中 `[WARNING Code` / `[ERROR Code` 出现次数为 0。
- 把 27 行新表(`case` / `scale` / `median ms` / `min ms` / `p95 ms`)原样抄入 `06-17-SUMMARY.md`;同时保留 06-16 的 18 行作为对照说明(可只引用 `06-16-SUMMARY.md`,不必重复抄表)。
- 复核 `pnpm exec vitest list --filesOnly`(默认配置)输出不含 `.perf.ts`,并记录 `pnpm test:ci` 的文件数 / passed / skipped 与基线一致(66 文件 / 680 passed / 1 skipped)。
**③ SUMMARY 必须如实写明的内容(不得美化)**
- **分支差异**:HighCompression 走 `saveHighCompression` 的行差异(`diffRows`),LowCompression 在本夹具下因 `layerDirty && !isEqualToRef()` **回落为整图 `fullMap`**(`saveLowCompression` 没有行差异分支),NoCompression 始终整图 —— 不要把 Low 描述成「行差异」。
- **diff 存储语义**:live = 清除后矩阵(`2/3/4/6` → `0`,保留 `0/1/5`)、参考 = 原始真实矩阵;这解释了 High 档的行差异规模(每张地图的差异集中在含门/资源/怪物/机关门的格子所在行)。
- **测量口径**:预热 3 不计时 + 采样 20 取中位数、`min`/`p95` 口径、计时源 `globalThis.performance.now()`、运行环境(Node 版本 / 平台 / vitest 版本)、`testTimeout` 30000ms 与是否有 case 逼近它。
- **三行语义**:`存档` = 仅 `saveState`;`读档` = 仅 `loadState`(快照在预热前取一次,各 `loadState` 只读取存档对象、不回写);`往返` = save + load。
- **固定侧负载的逐项说明**(50 flags / 20 修饰器(含 4 件装备贡献的 4 个)/ 4 件装备实例 / 20 种道具 / 1000 步录像 / 怪物与点事件未改动)。
- **实现风险如实记录**:`HeroState.loadState` 新建 `HeroAttribute` 后,`HeroEquipment` 仍持有构造时传入的 attribute 引用(`state.ts:71` → `equipment.ts:21-26`),读档时装备修饰器会挂到被替换掉的旧属性上;因此「20 个修饰器」只是**夹具构建期**的数量不变量,本计划不对读档后的修饰器数量做任何断言。若执行者按代码阅读确认该引用不一致,按 D-06 追加 `06-TEST-FINDINGS.md` 条目(现象、最小复现、影响面、建议修复方向、严重度)并在 SUMMARY 中标注,**不得修改生产代码**(D-05/D-07:只报告、等用户裁决)。
- **不落盘基线 JSON、不新增耗时门禁**:本计划的数字仅供后续回归对比时人工取用。
- **告警/错误码**:若运行期出现任何 `[WARNING Code` / `[ERROR Code`,按 D-05 追加 `06-TEST-FINDINGS.md` 的 `#06-17-N` 并在 SUMMARY 中显式标注;无异常时注明「无新增条目」。
</action>
<verify>
<automated>pnpm test:perf(须收集 5 个 `*.perf.ts`,共 45 行 case 表,其中新增 27 行覆盖 3 地图数量 × 3 压缩档 × 3 计时项;无断言失败;输出中不出现 `[WARNING Code` / `[ERROR Code`);pnpm exec vitest list --filesOnly(默认配置;输出不含 `.perf.ts`);pnpm test:ci(全绿);pnpm exec vue-tsc --noEmit(按 `saveablesReal.perf.ts` 过滤 0 条)</automated>
<fails_when>`pnpm test:perf` 不到 45 行或新增不足 27 行(漏压缩档/漏计时项);或出现 `[WARNING Code 123/124/108/117/121]`、`[ERROR Code 55/58/59]` 任一;或某 case 因 `testTimeout` 失败而执行者私自改规模/超时;或 `pnpm test:ci` 收集文件数/结果与基线不一致(如 `.perf.ts` 被默认通道收集);或 `06-17-SUMMARY.md` 缺失数值表,或把 LowCompression 描述成行差异,或未记录 `HeroEquipment` 引用不一致这一实现风险;或本计划改动/新增了声明之外的文件(生产源码、配置、既有 perf/test 文件、依赖)</fails_when>
<acceptance_criteria>
- `pnpm test:perf` 一次运行收集 5 个 perf 文件、打印 **45 行**表格(06-16 的 18 行 + 本计划新增 27 行);新增 27 行的 `case` 为 `存档`/`读档`/`往返`,`scale` 覆盖 `1/5/13` × `NoCompression`/`LowCompression`/`HighCompression`。
- 运行日志中 `[WARNING Code` / `[ERROR Code` 出现次数为 0;全文件 0 个断言调用。
- `06-17-SUMMARY.md` 含 27 行数值表、测量参数(预热 3 / 采样 20 / 下中位数 / min / p95 / `toFixed(3)` / 计时源 / 环境)、三行语义说明、固定侧负载逐项说明、**分支差异如实说明(High 行差异、Low 回落整图)**、`HeroEquipment` 引用不一致的实现风险记录、`test:ci` 结果对照与「不落盘基线 JSON、不做耗时断言」说明;findings 无新增时注明「无新增条目」。
- `pnpm exec vitest list --filesOnly`(默认配置)输出不含 `.perf.ts`;`pnpm test:ci` 全绿且收集文件数与基线一致(66 文件 / 680 passed / 1 skipped)。
- D-44 门禁通过:改动文件 `eslint` 0 错误;`saveablesReal.perf.ts` 在 `pnpm exec vue-tsc --noEmit` 输出中按路径过滤 0 类型错误。
- `git status` 仅含本计划的 3 个文件(夹具新增、perf 文件新增、SUMMARY 新增/规划产物);无生产源码改动、无新依赖、无基线 JSON 落盘。
</acceptance_criteria>
</verify>
<done>45 行表格(新增 27 行)打印成功且零告警噪声、`test:ci` 不受影响、`06-17-SUMMARY.md` 落盘并如实记录 Low/High 分支差异与实现风险;D-44 三项门禁通过。随后暂停,用中文分条汇报耗时结果(含是否有 case 逼近 `testTimeout`、High 行差异是否符合预期)。</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| none | 仅新增仓库内 Node Vitest 性能测量文件与一个 JSON 测试夹具;无运行时输入面、无网络、无 DOM、无 `IndexedDB` 使用(`saveSystem.init` 只在未触发的 `loading.once('coreInit')` 回调里)。结果只进 `console.table` 与 SUMMARY。 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-06-17-01 | Tampering | `vitest.perf.config.ts` 之外的改动把 perf 文件并入默认收集通道 | medium | mitigate | 本计划**不改** `vitest.perf.config.ts`/`package.json`/`vite.config.ts`;每个阶段的 `<verify>` 都跑 `pnpm exec vitest list --filesOnly` 确认默认配置输出不含 `.perf.ts`,并复核 `pnpm test:ci` 收集文件数(66)与结果不变。 |
| T-06-17-02 | Repudiation | 用「跑绿」冒充性能数据(如空转、夹具建进计时区间、读档因缺参考而早退) | medium | mitigate | 夹具构建与读档快照都在预热前完成;`读档` 必须走有参考的 Low/High 分支(否则 `error 55` + 直接返回 → 由「日志不得出现 error 55」的判据拦截);`往返` 必须同时调用 `saveState` 与 `loadState`;禁止任何断言与 `it.skip`。 |
| T-06-17-03 | Denial of service | 最大规模 case(`13` 张地图 × HighCompression × 23 次采样)触发 `testTimeout`,把计划判据变成随机失败 | medium | mitigate | 每个 case 独立 `it`(超时归因清晰);夹具构建不计时;单样本只做一次 save/load。若某个 case 逼近/超过 30000ms,按 D-07 暂停汇报,**不得**静默调规模或调大 timeout。 |
| T-06-17-04 | Information disclosure | 把用户提供的真实地图数据纳入仓库 | low | accept | 用户已明确批准把 `floors.json` 移入 `test/fixtures/`;数据为 13 张 13×13 的关卡矩阵(无明显敏感字段),且该文件此前已存在于用户工作区。 |
| T-06-17-05 | Tampering | 为让 perf 跑绿而修改生产源码、既有测试或放宽告警 | medium | mitigate | 明确禁止改动 `packages*/**`(非 `*.perf.ts`)、配置、既有 `*.perf.ts`/`*.test.ts` 与 06-01..06-16 产物;运行日志出现任何 warn/error 码即判失败并要求调整夹具(不得放宽);`git status` + D-44 门禁在每阶段提交前复核。 |
| T-06-17-06 | Elevation of privilege | 夹具通过真实存档系统/`IndexedDB` 路径产生副作用 | low | accept | 夹具只调用 `CoreState` 数据层方法(`saveState`/`loadState`/地图与勇士 API),`saveSystem.init` 从未被触发,Node 环境无 `IndexedDB` 依赖。 |
| T-06-17-SC | Tampering | 包安装(npm/pnpm) | high | mitigate | 明确不引入任何新依赖(vitest 4.0.18 已在 devDependencies);若确需安装,停止并先走包合法性 checkpoint。 |
</threat_model>
<verification>
- `pnpm test:perf` 一次运行收集 **5** 个 `*.perf.ts`、打印 **45** 行 `console.table`(列 `case`/`scale`/`median ms`/`min ms`/`p95 ms`)并成功退出;输出中不出现 `[WARNING Code` / `[ERROR Code`。
- 新增 27 行覆盖 `1/5/13` 地图数量 × `NoCompression`/`LowCompression`/`HighCompression` × `存档`/`读档`/`往返`;每个 case 预热 3 + 采样 20、取中位数/最小值/p95;全文件 0 个断言调用。
- 夹具来源可追溯:`packages-user/data-state/test/fixtures/floors.json` 为根 `floors.json` 的原样副本(17,292 字节、CRLF、13 个 13×13 条目),根副本已删除;清除规则(`2/3/4/6` → `0`,保留 `0/1/5`)与「live = 清除矩阵、参考 = 原始矩阵」在文件内可见并有中文注释。
- 固定侧负载在每个 case 完全相同:50 flags / 20 修饰器(含 4 件装备贡献的 4 个)/ 4 件装备实例(3 种编号)/ 20 种道具 / 1000 步录像;怪物与地图点事件未模拟改动。
- `pnpm exec vitest list --filesOnly`(默认 `vite.config.ts` 配置)输出**不含**任何 `.perf.ts`;`pnpm test:ci` 全绿且为 66 文件 / 680 passed / 1 skipped。
- `git status` 仅显示本计划的 3 个文件(`test/fixtures/floors.json`、`test/saveablesReal.perf.ts`、`06-17-SUMMARY.md`)与规划产物;无生产源码改动、无配置改动、无新依赖、无基线 JSON 落盘。
- D-44 文件级门禁:改动文件 `eslint --fix` 后 `eslint <改动文件>` 0 错误;`saveablesReal.perf.ts` 在 `pnpm exec vue-tsc --noEmit` 输出中按路径过滤 0 类型错误;`pnpm test:ci` 全绿。
- **执行协议**:执行前必须先向用户确认;执行后暂停并用中文分条汇报(含 Low/High 分支差异、是否有 case 逼近 `testTimeout`、`HeroEquipment` 引用不一致的确认结论)。
</verification>
<success_criteria>
- 用户批准的真实地图存读档测量全部落地且口径与 06-16 一致:27 个新 case(3 地图数量 × 3 压缩档 × 3 计时项),预热 3 次不计时 + 采样 20 次取中位数,输出 `console.table`(`case`/`scale`/`median ms`/`min ms`/`p95 ms`),**零断言**。
- 夹具为真实数据:`floors.json` 原样移入 `test/fixtures/`、根副本删除;live = 清除矩阵(`2/3/4/6` → `0`,保留 `0/1/5`)、参考 = 原始矩阵;HighCompression 走行差异路径,LowCompression 的整图回落被**如实记录**。
- 固定侧负载在每个 case 完全相同:50 flags / 20 修饰器(含 4 件装备贡献的 4 个)/ 4 件装备实例 / 20 种道具 / 1000 步录像;怪物与点事件未改动。
- 通道与门禁零变化:`vitest.perf.config.ts`、`package.json`、`vite.config.ts`、`tsconfig*.json`、既有 `*.perf.ts`/`*.test.ts` 一字未改;`pnpm test:ci` 的语义、收集文件数与结果不变。
- `06-17-SUMMARY.md` 内含 27 行数值表、测量参数、三行语义、侧负载逐项说明、分支差异与实现风险记录;**不落盘基线 JSON**、不新增耗时门禁。
- D-44 文件级三项门禁(eslint 0 错误 / 文件级 vue-tsc 0 类型错误 / `pnpm test:ci` 全绿)在每个阶段提交前通过。
</success_criteria>
<artifacts_this_plan_produces>
Fixture (created by move; root copy deleted):
- `packages-user/data-state/test/fixtures/floors.json` — 13 张真实魔塔地图(13×13,图块编号 `0` 空地 / `1` 墙壁 / `2` 普通门 / `3` 资源 / `4` 怪物 / `5` 入口 / `6` 机关门),由仓库根 `floors.json` 原样移入
- `floors.json`(仓库根,删除)— 未跟踪的暂存副本,移入后从工作区删除
Perf test (created):
- `packages-user/data-state/test/saveablesReal.perf.ts` — 27 个 case:地图数量 `1/5/13` × `NoCompression`/`LowCompression`/`HighCompression` × `存档`/`读档`/`往返`;文件内联清除规则、图块注册、固定侧负载夹具、`measureCase`(预热 3 + 采样 20 + 中位数/min/p95),零断言
Planning artifacts:
- `.planning/phases/06-unit-tests/06-17-SUMMARY.md` — 27 行耗时数值表 + 测量参数 + 分支差异如实说明(High 行差异 / Low 整图回落)+ 侧负载逐项说明 + D-44 门禁结果 + 实现风险记录(不落盘基线 JSON)
- `.planning/ROADMAP.md` — Phase 6 的 Wave 7 小节与 Plans 计数登记「真实地图存读档性能补充(06-17)」
</artifacts_this_plan_produces>
<output>
完成时创建 `.planning/phases/06-unit-tests/06-17-SUMMARY.md`,其中必须包含:真实 `pnpm test:perf` 运行输出的 **27 行新增数值表**(`case`/`scale`/`median ms`/`min ms`/`p95 ms`,以 markdown 表格呈现,`scale` 形如 `13/HighCompression`)、测量参数说明(预热 3 次不计时、采样 20 次取中位数、`min`/`p95` 口径、计时源 `globalThis.performance.now()`、运行环境 Node 版本/平台/vitest 版本)、三行语义(`存档`/`读档`/`往返`)说明、固定侧负载逐项说明、**Low 与 High 分支差异的如实说明**、`HeroEquipment` 引用不一致这一实现风险的记录(含是否已按 D-06 追加 `06-TEST-FINDINGS.md` 条目)、`pnpm test:ci` 66 文件 / 680 passed / 1 skipped 的对照结论,以及「本计划不落盘基线 JSON、不做任何耗时断言」的说明。若某 case 逼近 `testTimeout` 或出现告警/错误码噪声,按 D-05 追加 `06-TEST-FINDINGS.md` 的 `#06-17-N` 并在 SUMMARY 中显式标注;无异常时注明「无新增条目」。执行完成后暂停并用中文分条汇报。
</output>