feat: 完成数据端数据类型的构建

This commit is contained in:
unanmed 2026-09-20 19:11:43 +08:00
parent c0a58706d9
commit d12790280a
30 changed files with 885 additions and 52 deletions

View File

@ -0,0 +1,468 @@
---
phase: 04-render-adaptation
plan: 5
type: execute
wave: 5
depends_on:
- 04-01
files_modified:
- packages-user/client-modules/src/render/ui/main.tsx
- packages-user/client-modules/src/render/map/renderer.ts
autonomous: false
requirements:
- REND-01
- REND-02
estimate:
tokens: 51000
raw_tokens: 51000
tasks: 4
confidence: low
must_haves:
truths:
- "**本计划的第一个 block 点**是 Task 0 的 `blocking-human` 汇报关卡:先向用户汇报要解决的问题(04-01 只读对账中仅剩的两条 ① 错配 `#04-01-M-06` 与 `#04-01-M-09`)、大致解决方案(M-06 把 `main.tsx` 的 9 处 `getHeroStatusOn(...)` 改为经 `client` 读新数据端属性接口 `client.hero.attribute.getFinalAttribute(...)`;M-09 新增逐图层钩子类 `RendererLayerHook`,把 `onUpdateArea` / `onUpdateBlock` 转发到 `renderer.updateLayerArea(layer, ...)` / `renderer.updateLayerBlock(layer, ...)`)、**拟新增 / 删除的成员命名清单**(唯一已获批准的新增名是 `RendererLayerHook`;其余拟名 `renderer` / `layer` / `layerHooks` / `syncLayerHooks` 须当场获批或改由用户指定)、**必须移除 `main.tsx:28` 的 `// @ts-expect-error 需要重构` 编译器指令**这一项单独列出(移除未使用的 `@ts-expect-error` 指令本身会产生新诊断 TS2578,故「删符号保指令」不可行;该行属既有注释,须用户批准)、范围边界与禁令、验证方式与运行时不可得的诚实降级,然后必须**停下等待**用户明确回复「可以执行」四个字;收到该回复前不得执行任何任务、不得派发任何执行子代理。无论触发方式(`/gsd-execute-phase`、`/gsd-manager`、`/gsd-autonomous`、子代理/执行器派发)或配置模式(`yolo`、`workflow.auto_advance`)如何,均不得自动放行(AGENTS.md)"
- "**M-06 落地(`#04-01-M-06`):** `packages-user/client-modules/src/render/ui/main.tsx` 中 `getHeroStatusOn` 以词边界判定计数为 **0**(import 与 9 处调用全部消失),改为 `client.hero.attribute.getFinalAttribute('<key>')` 且该调用形态出现 **9** 次,键依次为 `atk` / `hp` / `hpmax` / `mana` / `manamax` / `def` / `mdef` / `money` / `exp`(与 `IHeroAttr` 的九个数值键一一对应,`data-common/src/types.ts:23-44`);`import { state } from '@user/data-state';` **保留**(`main.tsx:248` 的 `state.maps` 属 M-08,本计划不动)"
- "**M-06 的编译器指令处置:** `main.tsx:28` 的 `// @ts-expect-error 需要重构` 已移除——该指令唯一作用是压制 `getHeroStatusOn` 未导出的 TS2305;符号删除后指令若保留即变为「未使用」,TypeScript 直接报 TS2578(本计划已在临时目录实测复现:`// @ts-expect-error` 下一行 import 可解析时报 `error TS2578: Unused '@ts-expect-error' directive.`),从而在改动文件上新增诊断、违反「改动文件类型诊断不增加」门禁。移除该行后,`@user/data-state` import 上方不再有 `@ts-expect-error`,且 `import` 之间不再夹注释(顺带符合 `dev.md:55`);`main.tsx:247` 那一行服务于 M-08 `state.maps` 的 `@ts-expect-error 需要重构` **逐字保留**(本计划不动 M-08),故移除后文件内 `@ts-expect-error` 计数由 **2 变为 1**,门禁断言对象是「import 上方那条指令消失」,**不是**「文件内不存在任何 `@ts-expect-error`」"
- "**M-06 范围外零改动:** `main.tsx` 除 import 的 `getHeroStatusOn` 与该 9 处调用外,`core.*` 读取(`:99`、`:103`、`:113`-`:125`、`:127`-`:134`、`:139`、`:155`、`:165`、`:198`、`:207`、`:216`)一律保持原样(`core.*` 属第五阶段,D-12);`:248` 的 `state.maps` → `layerState`(M-08)与 `statistics.tsx`(M-07)一律不动;`client.flags` 用法(`:100`-`:101`、`:121`-`:123`、`:134`)不动"
- "**M-09 落地(`#04-01-M-09`):** `packages-user/client-modules/src/render/map/renderer.ts` 新增模块内(不导出)类 **`RendererLayerHook implements Partial<IMapLayerHooks>`**(用户已批准该类名),实现 `onUpdateArea(x, y, width, height)` 与 `onUpdateBlock(block, x, y)`,分别转发 `this.renderer.updateLayerArea(this.layer, x, y, width, height)` 与 `this.renderer.updateLayerBlock(this.layer, block, x, y)`(对应数据端现行逐图层契约 `data-base/src/map/types.ts:222` / `:230`)"
- "**M-09 的旧错误注册清零:** `renderer.ts` 中 `onUpdateLayerArea` / `onUpdateLayerBlock` 计数为 **0**(`RendererLayerStateHook` 上这两个非契约成员被移除);`RendererLayerStateHook` **保留其 map 级职责** `onChangeBackground` / `onResizeLayer` / `onUpdateLayer`(对应 `IGameMapHooks` 现行成员,`data-base/src/map/types.ts:588-608`),类名不改"
- "**M-09 的注册路径:** 逐图层钩子经 `layer.addHook(new RendererLayerHook(...))` 注册并 `.load()`(与既有 `door.ts:22-23` 的 `MapDoorRenderer` / `MapDoorHook` 范式一致),注册/注销发生在**图层状态变更路径**——`MapRenderer.setLayerState`(`renderer.ts:422`,换地图时同步)与 `MapRenderer.updateLayerList`(`renderer.ts:414`,由 `RendererLayerStateHook.onUpdateLayer` 在 `GameMap.addLayer` / `removeLayer` 后触发);换地图或图层被移除时对旧控制器 `.unload()` 并移出登记,**不泄漏钩子**"
- "**契约面零变更:** 不新增 / 删除任何公共接口成员——`IMapRenderer`(`render/map/types.ts`)不变、`IMapLayerHooks` / `IGameMapHooks` 等数据端契约不变、`RendererLayerHook` 为模块内私有类不导出;不改任何数据端文件与 `packages/**`;不新增依赖、不改 `package.json` / `pnpm-lock.yaml`"
- "**D-23 的显式例外已记录:** 本计划在 M-06 中使用 `client`(`client-modules/src/core.ts` 的全局单例)——这与 D-23「不使用 `client` / `state` 全局单例,若有必须调用之处停下告知用户」相冲突,**但用户已就本增量明确裁定**:原话「先用 client 读,这部分还未完成重构,后续我再进行处理」。故本计划按用户裁定使用 `client`,不因 D-23 停摆;`main.tsx` 的存量 `core.*` 与 `client.*` 混用**不在本计划清理范围**"
- "**验证可运行且不臆造通过:** 改动前先记录 `pnpm exec vue-tsc --noEmit` 的 `client-modules` 基线错误集(当日实际值,逐行原样入 SUMMARY;规划日实测为 **0** 条),改动后该错误集必须与基线**完全一致**,且 `main.tsx` / `renderer.ts` 命中 0 条;`pnpm exec eslint` 对两个被改文件 0 错误;静态门禁(M-06:`getHeroStatusOn`=0 / `getFinalAttribute`=9 / 九键齐 / `state` import 在 / `@user/data-state` import 上方 `@ts-expect-error` 清零且全文件计数为 1(保留 `main.tsx:247` 的 M-08 指令);M-09:`RendererLayerHook` 在、实现两钩子、转发 `this.layer`、`onUpdateLayerArea` / `onUpdateLayerBlock`=0、`RendererLayerStateHook` 三个 map 级成员在、`addHook` 注册在)、CRLF 门禁、基线感知范围门禁(放行两个在范围文件)与并发改动基线门禁全部转绿。渲染端无测试设施、且 `client.ts:151-155` 的 `addHero` 接线仍为注释、`MapExtensionManager.addHero` 无调用点,故运行时渲染无法观察:本计划的实质验证为「编译 + 契约 + 静态」三类机器门禁,另附人工代码复核;**不得**以任何无法运行的门禁冒充通过"
- "用户并发的未提交改动(以 Task 1 起始 `git status --porcelain` 当日实测为准,排除 `packages-user/client-modules/src/render/ui/main.tsx`、`packages-user/client-modules/src/render/map/renderer.ts` 两个在范围文件与 `.planning/phases/04-render-adaptation/` 前缀)在 Task 1 起始记录基线(porcelain 条目 + 内容 sha256 摘要),Task 3 复验逐条与摘要未变;**不回滚 / 不暂存 / 不提交 / 不修改**它们(规划日实测并发项为 `packages-user/data-state/src/types.ts`、`src/content/core.jsonc`、`src/content/client.jsonc`,执行时以当日实测为准)"
- "REND-01 / REND-02 在本 run 后仍保持 Pending:本计划只落地 04-01 对账的两条 ① 错配(`#04-01-M-06` / `#04-01-M-09`),未实施其余渲染适配与移动端 / 桌面端双布局(见 assumptions 的两条 FLAGGED ASSUMPTION)"
artifacts:
- path: packages-user/client-modules/src/render/ui/main.tsx
provides: "状态栏 `updateStatus()` 的 9 个数值属性改为经 `client.hero.attribute.getFinalAttribute(...)` 读取(M-06);`getHeroStatusOn` 与其 `:28` 上方的 `@ts-expect-error` 指令移除(`:247` 服务于 M-08 `state.maps` 的同名指令逐字保留);`state` import 保留;其余 `core.*` 读取原样"
- path: packages-user/client-modules/src/render/map/renderer.ts
provides: "新增模块内类 `RendererLayerHook`(`Partial<IMapLayerHooks>`)把 `onUpdateArea` / `onUpdateBlock` 转发到 `MapRenderer.updateLayerArea` / `updateLayerBlock`;逐图层注册/注销落在 `setLayerState` / `updateLayerList`;`RendererLayerStateHook` 移除 `onUpdateLayerArea` / `onUpdateLayerBlock` 并保留 map 级职责"
key_links:
- "`client`(`client-modules/src/core.ts:6`,`new ClientCore()`)→ `ClientCore extends CoreState`(`client-modules/src/client.ts:47`)→ `CoreState.hero: IHeroState<IHeroAttr>`(`data-state/src/core.ts:86`,`:144` 赋值)→ `IHeroState.attribute: IReadonlyHeroAttribute<IHeroAttr>`(`data-base/src/hero/types.ts:895-896`)→ `getFinalAttribute<K extends keyof IHeroAttr>(name: K): IHeroAttr[K]`(`:106`)"
- "`IHeroAttr` 的九个数值键 `atk` / `hp` / `hpmax` / `mana` / `manamax` / `def` / `mdef` / `money` / `exp`(`data-common/src/types.ts:23-44`)↔ `ILeftHeroStatus` 对应字段类型均为 `number`(`render/ui/statusBar.tsx:17-39`)"
- "`IMapLayerHooks.onUpdateArea(x, y, width, height)`(`data-base/src/map/types.ts:222`)由 `MapLayer.putMapData` 经 `forEachHook` 触发(`mapLayer.ts:299-301`);`IMapLayerHooks.onUpdateBlock(block, x, y)`(`:230`)由 `MapLayer.setBlock` 触发(`mapLayer.ts:242-244`)"
- "`IMapLayer.addHook(hook): IMapLayerHookController` + `.load()` / `.unload()` 范式:既有同类实现 `MapDoorRenderer`(`render/map/extension/door.ts:13,22-23`)↔ 本计划新增的 `RendererLayerHook` 注册"
- "`MapRenderer.updateLayerArea(layer, x, y, w, h)`(`renderer.ts:1527`)→ `vertex.updateArea(...)`;`MapRenderer.updateLayerBlock(layer, block, x, y)`(`:1545`)→ `vertex.updateBlock(...)`(此前永不被触发,见 `#04-01-M-09`)"
- "`MapRenderer.setLayerState(map: IGameMap)`(`renderer.ts:422`,由 `render/map/element.ts:20,103` 经 `layerState` 变更驱动)/ `MapRenderer.updateLayerList()`(`:414`)↔ `RendererLayerStateHook.onUpdateLayer`(`:1741`)↔ `GameMap.addLayer` / `removeLayer`(`data-base/src/map/gameMap.ts:59-87`,`onUpdateLayer` 在其间触发)"
- "数据端现行 `IGameMapHooks` 只有 `onChangeBackground?` / `onUpdateLayer?` / `onResizeLayer?`(`data-base/src/map/types.ts:588-608`)——这是 `RendererLayerStateHook` 上 `onUpdateLayerArea` / `onUpdateLayerBlock` 既非契约成员、也永不被调用的根因"
- "`pnpm exec vue-tsc --noEmit` 的 `client-modules` 错误集基线(Task 1 当日实测,逐行入 SUMMARY;规划日实测 0 条)↔ Task 1 / 2 / 3 门禁的一致断言"
assumptions:
- "**FLAGGED ASSUMPTION(specless fallback)— REND-01 edge unresolved:** 未提供 REND-01(渲染端适配新数据层接口)的验收边界;本计划**不发明**阈值,只交付 04-01 对账中 `#04-01-M-06` 与 `#04-01-M-09` 两条 ① 错配的修复。用户须在推进其余渲染适配前补齐 REND-01 的边界判据。"
- "**FLAGGED ASSUMPTION(specless fallback)— REND-02 edge unresolved:** 未提供 REND-02(移动端与桌面端双布局)的验收边界;本计划不涉及双布局实现,也不发明其验收阈值。"
- "**D-23 的用户例外(须原样汇报):** D-23 禁止新增 / 后续代码使用 `client` / `state` 全局单例,并要求「若有必须调用它们的地方,停止执行并告知用户」。本计划 M-06 恰恰要用 `client`,与 D-23 表面冲突;**但用户已就本增量明确裁定**原话:「先用 client 读,这部分还未完成重构,后续我再进行处理」。故按用户裁定执行(Using `client` per user decision),并在 Task 0 汇报中把该例外**单独列出**请用户复核;若用户改口,退出本计划并修订。"
- "**执行前必须复核(未确定项,不猜):** Task 1 步骤 (0a) 必须在动手前打开当日 HEAD 的基准文件逐条确认:① `IHeroAttr` 的九个数值键仍在且类型为 `number`(`data-common/src/types.ts:23-44`);② `IHeroState.attribute: IReadonlyHeroAttribute<THero>` 与 `getFinalAttribute<K extends keyof THero>(name: K): THero[K]` 签名未变(`data-base/src/hero/types.ts:106` / `:896`);③ `CoreState.hero` 仍为公开只读成员且在构造期赋值(`data-state/src/core.ts:86` / `:144`);④ `getHeroStatusOn` 在 `@user/data-state` barrel 与全仓库仍无导出定义(`data-state/src/index.ts:1-9`);⑤ `IHeroAttr` 的键名与 `ILeftHeroStatus` 的字段名逐一对得上(`render/ui/statusBar.tsx:17-39`);⑥ `IMapLayerHooks.onUpdateArea` / `onUpdateBlock` 签名仍为 `(x, y, width, height)` / `(block, x, y)`(`data-base/src/map/types.ts:222` / `:230`),且 `IGameMapHooks` 仍**不含** `onUpdateLayerArea` / `onUpdateLayerBlock`(`:588-608`);⑦ `MapLayer.putMapData` / `setBlock` 仍经 `forEachHook` 触发上述两个钩子(`mapLayer.ts:299-301` / `:242-244`);⑧ `IMapLayer.addHook` 返回 `IMapLayerHookController`(`door.ts:13,22-23` 同范式)且 `GameMap.addLayer` / `removeLayer` 会在图层列表变化时触发 map 级 `onUpdateLayer`(`gameMap.ts:63-65` / `:80-82`)。**任一条不同即停下汇报并修订本计划,不得按记忆或文档示例改代码。**"
- "**`@ts-expect-error` 移除的依据(已实测):** 在仓库外临时目录用本仓 `node_modules/.bin/tsc` 复现——`// @ts-expect-error` 的下一行 import 可正常解析时报 `error TS2578: Unused '@ts-expect-error' directive.`。故 `main.tsx` 删除 `getHeroStatusOn` 后必须同时删除 `:28` 的指令,否则新增诊断。该行是既有注释,按 AGENTS.md 须用户批准(Task 0 单列)。"
- "**成员命名需用户同意(AGENTS.md):** 本计划唯一已获用户批准的**新增名**是逐图层钩子类 `RendererLayerHook`。其余拟名——`RendererLayerHook` 的两个构造器只读字段 `renderer` / `layer`(照 `MapDoorHook` / `MapDoorRenderer` 既有写法)、`MapRenderer` 的新增私有字段 `layerHooks: Map<IMapLayer, IMapLayerHookController>`、新增私有方法 `syncLayerHooks(): void`——**均待 Task 0 用户批准或改名**;未获批准前执行者不得落地这些名字(可停下改由用户指定)。**不删除任何既有命名**(`RendererLayerStateHook` / `MapRenderer` 成员一律保留)。"
- "**运行时 UAT 不可得(诚实降级):** 渲染端无测试设施(`client-modules` 无 `*.test.*`),且 `client.ts:151-155` 的 `addHero` 接线仍为注释、`MapExtensionManager.addHero` 无调用点,故运行时渲染(状态栏数值、地图区域/图块更新后的顶点刷新)无法在渲染环境观察。本计划的验收为 `pnpm exec vue-tsc --noEmit` 的 scoped 类型门禁 + 静态 / 契约门禁 + `pnpm exec eslint` + CRLF + 基线感知范围 / 并发基线门禁 + 人工代码复核;运行时 UAT 顺延到启用接线的增量,**不伪造绿**。"
- "**数据端实现可能被用户并发修改(D-05):** 接口签名视为稳定,实现可变;本计划只读引用数据端接口,不修改任何数据端文件。若执行当日数据端接口签名与规划基线不一致,按 Task 1 步骤 (0a) 停下汇报,不按旧形状适配。"
prohibitions:
- "不得在用户明确回复「可以执行」四个字之前执行本计划的任何任务、或派发任何执行子代理(AGENTS.md 的第一个 block 点约束;不因触发方式或 `yolo` / `auto_advance` 模式而放宽)。"
- "不得把范围扩大到 `#04-01-M-06` / `#04-01-M-09` 之外的任何对账项:`#04-01-M-01`(`action/move.ts`,D-19 用户自行处理)、`#04-01-M-07`(`statistics.tsx` 的 `ItemState`)、`#04-01-M-08`(`main.tsx:248` 的 `state.maps`,用户原话「其他几个都涉及到接口的调整,这部分我自己来」)、`#04-01-M-02..M-05`(勇士本体,已由 04-03 / 04-04 处置)与全部 ③ 多余旧路径(`core.*`,D-12 属第五阶段)一律不得改动。"
- "不得改动 `main.tsx` 中除 import 的 `getHeroStatusOn`、`:28` 的 `@ts-expect-error` 指令与该 9 处调用以外的任何内容:其余 `core.*`(含 `core.status.*` / `core.getNextLvUpNeed()` / `core.getLvName()` / `core.itemCount()` / `core.isReplaying()` / `core.doRegisteredAction()` / `core.status.stepPostfix` / `core.status.lockControl`)与 `:248` 的 `state.maps` 保持原样;`client.flags.*` 用法保持原样。"
- "不得改动 `renderer.ts` 的 `RendererLayerStateHook` 类名与其 map 级成员(`onChangeBackground` / `onResizeLayer` / `onUpdateLayer`);不得改动 `MapRenderer` 的既有公共方法签名(`setLayerState` / `updateLayerList` / `updateLayerArea` / `updateLayerBlock` 等一律不变)。"
- "不得修改任何数据端文件(`packages-user/data-common` / `data-base` / `data-system` / `data-state`)、`packages-user/client-base`、`packages/**`、`src/**`;不得新增 / 修改任何数据端公共接口(D-21:只接现有 `IMapLayerHooks`,不新增数据端接口)。"
- "不得改动 `packages-user/client-modules/src/action/move.ts` 与任何移动控制 API(D-19,用户手动处理)。"
- "不得处理贴图 / 不透明度(D-18)、跟随者(D-22)、`mutate-animate` 存量迁移(D-27)、`client.ts:151-155` 的 `addHero` 接线(本计划不接线)。"
- "不得新增 / 删除任何公共接口成员(含导出的类、方法、字段);`RendererLayerHook` 必须是模块内不导出的内部类。"
- "不得修改任何既有 jsDoc 注释的内容;新增注释必须遵循 `dev.md` 的注释规范(中文、`//` 后一个空格、不写无价值注释、不对构造器加注释)。`main.tsx:28` 指令行的**移除**属本计划经用户批准的例外,不得借此顺手改写其它注释。"
- "不得引入任何未被用户批准的新命名;不得改动 `files_modified` 之外的源码;不得回滚 / 暂存 / 提交用户并发的未提交改动。"
- "不得新增依赖、不得修改 `package.json` / `pnpm-lock.yaml`;出现安装需求即暂停并要求用户确认包合法性。"
- "不得新增测试文件之外的测试设施改动;本计划**不新增任何测试**(渲染端无测试设施),且必须在 SUMMARY 中如实说明该降级。"
- "不得以「无测试设施」为由弱化验证:每条 `<automated>` 必须有 `<fails_when>`;确实无法运行的门禁必须显式声明为不可得并降级为静态 + 人工,不得伪造绿。"
---
<objective>
Phase 4 增量(第五个计划)—— **收口 04-01 只读对账中仅剩的两条 ① 错配**:`#04-01-M-06`(`getHeroStatusOn`)与 `#04-01-M-09`(`IGameMapHooks.onUpdateLayerArea` / `.onUpdateLayerBlock`)。**本计划是代码改动计划**,改动面严格限定为两个文件;其余对账项(`#04-01-M-01` / `M-07` / `M-08` 与全部 ③ 多余旧路径)**不在本计划范围**。
**要解决的问题(两条,全部来自 `04-RENDER-INTERFACE-AUDIT.md`):**
1. **`#04-01-M-06`(`getHeroStatusOn`)**:`packages-user/client-modules/src/render/ui/main.tsx:29` 从 `@user/data-state` import `getHeroStatusOn`(`:28` 带 `// @ts-expect-error 需要重构`),并在 `:104`-`:112` 用它读 9 个属性;但 `@user/data-state` barrel(`data-state/src/index.ts:1-9`)**无此导出**,全仓库亦无定义,故 `data-state` 侧属性读取断链。属性侧新等价接口为 `IHeroState.attribute.getFinalAttribute(...)`(`data-base/src/hero/types.ts:106`)。**用户裁定:先用 `client` 读**(原话「先用 client 读,这部分还未完成重构,后续我再进行处理」)——`client`(`client-modules/src/core.ts:6` 的 `ClientCore` 单例)在 `main.tsx:34` 已 import 并在该文件大量使用;`ClientCore extends CoreState`,`client.hero` 为 `IHeroState<IHeroAttr>`(`data-state/src/core.ts:86`)。
2. **`#04-01-M-09`(图层更新钩子挂错契约)**:`renderer.ts` 的 `RendererLayerStateHook`(`:1730`)把 `onUpdateLayerArea` / `onUpdateLayerBlock`(`:1745` / `:1755`)注册为 `Partial<IGameMapHooks>`,但现行 `IGameMapHooks` 只有 `onChangeBackground?` / `onUpdateLayer?` / `onResizeLayer?`(`data-base/src/map/types.ts:588-608`);「区域更新 / 单点图块更新」已下移到**逐图层**契约 `IMapLayerHooks.onUpdateArea(x, y, width, height)` / `onUpdateBlock(block, x, y)`(`:222` / `:230`),由 `MapLayer.putMapData` / `setBlock` 经 `forEachHook` 触发(`mapLayer.ts:299-301` / `:242-244`)。结果:`MapRenderer.updateLayerArea` / `updateLayerBlock`(`renderer.ts:1527` / `:1545`)**永不被触发**,静态图块 / 区域变更后顶点数组不刷新。**用户已批准新增逐图层钩子类名 `RendererLayerHook`**(须实现 `Partial<IMapLayerHooks>`,负责把 `onUpdateArea` / `onUpdateBlock` 转发给 `renderer.updateLayerArea(layer, ...)` / `renderer.updateLayerBlock(layer, ...)`;`RendererLayerStateHook` 保留其 map 级职责)。
**大致解决方案:**
- **M-06(`main.tsx`)**:删除 `:28` 的 `// @ts-expect-error 需要重构`(已实测:符号删除后该指令变为「未使用」并报 TS2578,故必须移除;属既有注释,Task 0 单列待批准),把 `:29` 的 `import { getHeroStatusOn, state } from '@user/data-state';` 改为 `import { state } from '@user/data-state';`(`state` 保留,`:248` 仍用),并把 `:104`-`:112` 的 9 处 `getHeroStatusOn('<key>')` 逐条改为 `client.hero.attribute.getFinalAttribute('<key>')`。**不动**其余 `core.*`(第五阶段)与 `:248` 的 `state.maps`(M-08)。
- **M-09(`renderer.ts`)**:新增模块内类 `RendererLayerHook implements Partial<IMapLayerHooks>`(构造器持有 `renderer` 与该图层,实现 `onUpdateArea` / `onUpdateBlock` 两条转发);在**图层状态变更路径**(`setLayerState` 与 `updateLayerList`)为 `map.layerList` 的每个图层 `layer.addHook(new RendererLayerHook(...))` 并 `.load()`,保存控制器;换地图或图层被移除时 `.unload()` 并移出登记;从 `RendererLayerStateHook` 删除 `onUpdateLayerArea` / `onUpdateLayerBlock`,保留其 map 级三个成员。**契约面零变更**(`RendererLayerHook` 不导出,`IMapRenderer` / 数据端接口一律不动)。
**执行前置关卡(AGENTS.md,第一个 block 点):** 本计划第一个执行步骤是 Task 0 —— 先向用户汇报「要解决的问题 / 大致解决方案 / **拟新增·删除的成员命名清单**(唯一已批准名 `RendererLayerHook`,其余拟名待批) / **必须移除 `main.tsx:28` 的 `@ts-expect-error` 指令**(含实测依据) / **M-06 使用 `client` 与 D-23 的例外** / 范围边界与禁令 / 验证方式的诚实降级」,然后**停下等待**用户明确回复「可以执行」四个字。收到该回复前,任何任务都不得执行,也不得派发执行子代理;无论触发方式或配置模式如何均不得自动放行。故本计划 `autonomous: false`。
**显式延后(本计划不涉及、不实施):** 其余 ① 错配(`#04-01-M-01` / `M-07` / `M-08`)、全部 ③ 多余旧路径(`core.*`,D-12)、贴图 / 不透明度(D-18)、跟随者(D-22)、`client.ts` 的 `addHero` 接线启用与运行时渲染、双布局 REND-02。
Purpose: 04-01 对账列出的 9 条 ① 错配里,勇士本体相关(`M-02`..`M-05`)已由 04-03 / 04-04 处置,`M-01` / `M-07` / `M-08` 经用户裁定由其自行处理;`M-06` 与 `M-09` 是**用户明确交办给 AI 的两条**。其中 `M-06` 让状态栏属性值回到新数据层接口(渲染适配 REND-01 的最小可见切片),`M-09` 修掉一条「注册了但永不触发」的静默失效——它不会报编译错,却让地图顶点数组在区域 / 图块变更后长期不刷新。
Output: `render/ui/main.tsx` / `render/map/renderer.ts` 的修正改动(两个按关注点拆分的原子提交)+ 会话内可复跑的 scoped 类型门禁、M-06 静态门禁、M-09 静态门禁、`pnpm exec eslint`、CRLF 门禁、基线感知范围门禁与并发改动基线门禁结果 + `.planning/phases/04-render-adaptation/04-05-SUMMARY.md`。Task 0 为只读汇报关卡,不写任何文件。
## Source Audit(多源覆盖审计)
| SOURCE | ID | Feature/Requirement | Plan | Status | Notes |
|--------|----|---------------------|------|--------|-------|
| GOAL | — | Phase 4:「渲染端通过新数据层接口驱动,并同时支持移动端与桌面端布局」 | 04-05 | COVERED(增量·两条 ① 错配收口) | 本 run 只落地 `#04-01-M-06` 与 `#04-01-M-09`;其余渲染适配与双布局属后续增量(REND-02 见 assumptions) |
| REQ | REND-01 | 渲染端适配新数据层接口 | 04-05 | COVERED(子切片;需求保持 Pending) | M-06 把状态栏属性读取从已失效的 `getHeroStatusOn` 改为新数据层 `getFinalAttribute`;M-09 把渲染注册从 `IGameMapHooks` 改到现行逐图层契约 `IMapLayerHooks`。验收边界未提供 → 见 assumptions 的 FLAGGED ASSUMPTION |
| REQ | REND-02 | 渲染端同时支持移动端与桌面端布局 | 04-05 | N/A(本增量不涉及,保持 Pending) | 本 run 范围不含双布局;REND-02 验收边界未提供 → 见 assumptions 的 FLAGGED ASSUMPTION |
| RESEARCH | — | (本阶段无 RESEARCH.md) | — | N/A | Phase 4 未产出 RESEARCH.md;等价输入为 04-01 对账账本与 `04-PATTERNS.md` |
| CONTEXT | D-01 / D-02 | 阶段 4 增量规划;第一步只读对账 | 04-01 | COVERED(前序计划) | 本计划消费其产出(对账账本) |
| CONTEXT | D-03 / D-04 | 被查对象限渲染端两包;数据端仅作接口基准 | 04-05 | COVERED | 本计划只改 `client-modules` 两个文件,不新增数据端接口 |
| CONTEXT | D-05 | 以接口为准,不受数据端实现变动影响 | 04-05 | COVERED | Task 1 步骤 (0a) 复核;签名若变则停下汇报 |
| CONTEXT | D-06 / D-07 | 三类分类与逐条记录口径 | 04-01 | COVERED(前序计划) | 本计划按 `#04-01-M-06` / `#04-01-M-09` 的 ① 错配条目收口 |
| CONTEXT | D-11 | `getHeroStatusOn` 类裸函数多为待删除 | 04-05 | COVERED | M-06 移除其唯一渲染端 import 与 9 处调用(不新增该裸函数) |
| CONTEXT | D-12 | `core.*` 属旧引擎适配,归第五阶段 | 04-05 | COVERED(排除 + 保留门禁) | `main.tsx` 其余 `core.*` 原样保留;prohibitions 明列不得引入 |
| CONTEXT | D-18 | `HeroRendering`(贴图 / 不透明度)暂不处理 | 04-05 | COVERED(排除) | `main.tsx` / `renderer.ts` 均不涉及 |
| CONTEXT | D-19 | 移动控制由用户手动修改 | 04-05 | COVERED(排除) | `action/move.ts` 不在 `files_modified` |
| CONTEXT | D-20 | 勇士渲染完全被动 | 04-05 | COVERED(沿用) | 本计划不新增 / 删除任何公共接口成员 |
| CONTEXT | D-21 | 走现有数据端钩子,不新增数据端公共接口 | 04-05 | COVERED | M-09 只接现行 `IMapLayerHooks.onUpdateArea` / `onUpdateBlock`,不改数据端 |
| CONTEXT | D-22 | 跟随者暂缓 | 04-05 | COVERED(排除) | 不涉及跟随者族 |
| CONTEXT | D-23 | 不使用 `client` / `state` 全局单例(仅约束新增 / 后续) | 04-05 | COVERED(**用户裁定例外**) | M-06 经用户明确裁定使用 `client`(原话「先用 client 读…后续我再进行处理」),Task 0 单列请用户复核 |
| CONTEXT | D-24..D-29 | 勇士渲染修正(04-04 范围) | 04-04 | COVERED(前序计划) | 本计划不重复、不回归其成果 |
## Estimate Basis(估算依据)
`estimate-calibration` 返回 `factor: 1`、`applied: false`、`sample_count: 0`、`confidence: low`(项目无历史样本),故 `tokens == raw_tokens`,并如实标注 `confidence: low`。按「执行者实际触及量」估算(`estimate.tasks: 4` = Task 0 汇报关卡 + 三个工作任务):Task 0 只读汇报并等待用户确认(约 5k,不写文件);Task 1 起始复核数据端现行接口 + 并发 / 类型基线 + SUMMARY 骨架 + `main.tsx` 改动(1 个文件、311 行)+ 门禁(约 18k);Task 2 `renderer.ts` 新增钩子类与注册路径(1 个文件、1780 行,定向读取)+ 门禁(约 14k);Task 3 终验门禁 + 人工复核 + SUMMARY 定稿(约 10k);固定开销约 4k;合计约 51k。
</objective>
<execution_context>
@C:/Users/13194/.config/opencode/gsd-core/workflows/execute-plan.md
@C:/Users/13194/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/04-render-adaptation/04-CONTEXT.md
@.planning/phases/04-render-adaptation/04-RENDER-INTERFACE-AUDIT.md
@.planning/phases/04-render-adaptation/04-PATTERNS.md
@.planning/phases/04-render-adaptation/04-04-PLAN.md
@.planning/phases/04-render-adaptation/04-04-SUMMARY.md
@AGENTS.md
@dev.md
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: 预执行汇报关卡 —— 汇报 `#04-01-M-06` / `#04-01-M-09` 的解决方案、成员命名清单(拟名待批)、`main.tsx:28` `@ts-expect-error` 指令的移除、M-06 使用 `client` 对 D-23 的例外,等待用户回复「可以执行」</name>
<precondition>`.planning/phases/04-render-adaptation/04-RENDER-INTERFACE-AUDIT.md` 内存在 `#04-01-M-06` 与 `#04-01-M-09` 两条 ① 错配记录(以文件内实际存在为准)</precondition>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- AGENTS.md(「任何计划的执行,其第一个 block 点必须是向我汇报计划内容并等待我确认」;只有明确回复「可以执行」四个字之后才允许开始执行;「任何公共、受保护、私有方法、字段等内容的命名必须先向我反馈,我同意后才可以使用」;「不得擅自修改任何已有注释」)
- `.planning/phases/04-render-adaptation/04-RENDER-INTERFACE-AUDIT.md`(`#04-01-M-06` 与 `#04-01-M-09` 两条的锚点、依据、影响与触发序列)
- `.planning/phases/04-render-adaptation/04-CONTEXT.md`(D-11 裸函数待删;D-12 `core.*` 归第五阶段;D-21 只接现有数据端钩子;D-23 禁用单例与其「停摆告知」要求)
- `.planning/phases/04-render-adaptation/04-04-SUMMARY.md`(「运行时 UAT 不可得」的诚实降级先例与门禁风格)
- `packages-user/client-modules/src/render/ui/main.tsx:28-34,98-135,245-249`(现状 import、9 处调用、`state.maps`、`client` 走向)
- `packages-user/client-modules/src/render/map/renderer.ts:83,414-433,1527-1548,1730-1763`(`mapHook` 字段、`setLayerState` / `updateLayerList`、`updateLayerArea` / `updateLayerBlock`、`RendererLayerStateHook`)
- `packages-user/data-base/src/map/types.ts:207-264,588-608`(`IMapLayerHooks.onUpdateArea` / `onUpdateBlock`;`IGameMapHooks` 现行三成员)
- `packages-user/client-modules/src/render/map/extension/door.ts:11-24,63-73`(`layer.addHook` + `IMapLayerHookController` 的既有范式)
</read_first>
<action>
**停下并向用户汇报,等待明确回复后才继续;汇报本身只读,不写任何文件、不改任何生产代码、不派发子代理。**
① **要解决的问题(两条,来自 `04-RENDER-INTERFACE-AUDIT.md`,本计划只做这两条):**
- `#04-01-M-06`:`main.tsx:29` 从 `@user/data-state` import `getHeroStatusOn`(`:28` 带 `// @ts-expect-error 需要重构`),`:104`-`:112` 用它读 9 个属性;但 `data-state` barrel 无此导出、全仓库无定义,属性读取断链(新等价接口 `getFinalAttribute`,`data-base/src/hero/types.ts:106`)。
- `#04-01-M-09`:`renderer.ts` 把「区域更新 / 单点图块更新」钩子注册为 `IGameMapHooks.onUpdateLayerArea` / `.onUpdateLayerBlock`(`:1745` / `:1755`,注册于 `:426`),但 `IGameMapHooks` 已无这两成员(只有 `onChangeBackground?` / `onUpdateLayer?` / `onResizeLayer?`);它们已下移到**逐图层**契约 `IMapLayerHooks.onUpdateArea` / `.onUpdateBlock`(`data-base/src/map/types.ts:222` / `:230`),由 `MapLayer.putMapData` / `setBlock` 经 `forEachHook` 触发。结果 `MapRenderer.updateLayerArea` / `updateLayerBlock`(`:1527` / `:1545`)**永不被触发**——不报编译错,却让静态图块 / 区域变更后顶点数组不刷新(静默失效)。
② **大致解决方案(两个文件):**
- `main.tsx`(M-06):把 `:104`-`:112` 的 9 处 `getHeroStatusOn('<key>')` 逐条改为 **`client.hero.attribute.getFinalAttribute('<key>')`**,键依次为 `atk` / `hp` / `hpmax` / `mana` / `manamax` / `def` / `mdef` / `money` / `exp`;import 收紧为 `import { state } from '@user/data-state';`(`getHeroStatusOn` 删除,`state` 保留——`:248` 的 `state.maps` 属 M-08,本计划不动)。**不动**其余 `core.*`(`:99` / `:103` / `:113`-`:125` / `:127`-`:134` / `:139` / `:155` / `:165` / `:198` / `:207` / `:216`,属第五阶段 D-12),也不动 `client.flags.*` 用法。
- `renderer.ts`(M-09):新增模块内(**不导出**)类 **`RendererLayerHook implements Partial<IMapLayerHooks>`**(用户已批准类名),持有 `renderer` 与该图层,实现 `onUpdateArea(x, y, width, height)` → `this.renderer.updateLayerArea(this.layer, x, y, width, height)`、`onUpdateBlock(block, x, y)` → `this.renderer.updateLayerBlock(this.layer, block, x, y)`;在**图层状态变更路径** `MapRenderer.setLayerState`(换地图)与 `MapRenderer.updateLayerList`(由 `RendererLayerStateHook.onUpdateLayer` 在 `GameMap.addLayer` / `removeLayer` 后触发)为 `map.layerList` 中每个图层 `layer.addHook(new RendererLayerHook(...))` 并 `.load()`(照 `door.ts:22-23` 范式),换地图 / 移除图层时 `.unload()` 并移出登记;从 `RendererLayerStateHook` **删除** `onUpdateLayerArea` / `onUpdateLayerBlock`,**保留**其 map 级职责 `onChangeBackground` / `onResizeLayer` / `onUpdateLayer`(类名不改)。
③ **成员命名清单(AGENTS.md 要求先反馈后使用;请逐项批准或当场指定改法):**
- **已批准(用户先前裁定,本计划直接使用)**:内部类名 **`RendererLayerHook`**。
- **待批准(新增,均为模块内 / 私有,不进任何公共接口)**:
- `RendererLayerHook` 的两个构造器只读字段名:拟 **`renderer`**、**`layer`**(照 `MapDoorHook` / `MapDoorRenderer` 的既有写法)。
- `MapRenderer` 新增**私有字段**名:拟 **`layerHooks`**(类型 `Map<IMapLayer, IMapLayerHookController>`,用于保存逐图层钩子控制器以便 `unload`)。
- `MapRenderer` 新增**私有方法**名:拟 **`syncLayerHooks()`**(对比 `this.map.layerList` 与已登记控制器,补注册 / 注销)。
- **不删除任何既有命名**:`RendererLayerStateHook`、`MapRenderer` 现有成员、`IMapRenderer` 契约成员一律保留;`RendererLayerHook` 不导出,**不新增 / 删除任何公共接口成员**。
- 若用户给出的名字与本计划拟名不同:按用户指定的名字落地,本计划其余部分不变;若用户要求换方案(例如不用 `Map` 登记而用别的机制),执行者退出本计划、修订 `04-05-PLAN.md` 后重新汇报。
④ **必须移除 `main.tsx:28` 的 `// @ts-expect-error 需要重构`(单独列出待批准,属既有注释):**
- 该指令唯一作用是压制 `getHeroStatusOn` 未导出(TS2305)。删除该符号后,指令下一行不再报错,TypeScript 转而报 **TS2578 `Unused '@ts-expect-error' directive.`**(本计划已在仓库外临时目录用本仓 `node_modules/.bin/tsc` **实测复现**),这会在改动文件上**新增诊断**,直接违反「改动文件类型诊断不增加」门禁——故「删符号、留指令」不可行。
- 因此本计划必须删除 `:28` 这一行。按 AGENTS.md「不得擅自修改任何已有注释」,此项**单独提请用户批准**;删除后 `@user/data-state` import 上方不再有 `@ts-expect-error`(文件内仅剩 `:247` 那一条服务于 M-08 `state.maps` 的指令,**逐字保留,不得顺手删除**),且 `import` 之间不再夹注释(顺带符合 `dev.md:55`)。除此之外**不改写任何其它注释**。
⑤ **M-06 使用 `client` 与 D-23 的例外(必须原样告知):**
- D-23 规定「不使用 `client` / `state` 全局单例;若有必须调用它们的地方,**停止执行并告知用户**,不得自行选择绕过方案」(适用新增 / 后续改动)。
- 本计划 M-06 恰恰要使用 `client`。用户已就本增量**明确裁定**,原话:「**先用 client 读,这部分还未完成重构,后续我再进行处理**」;`client` 在 `main.tsx:34` 已 import 并在该文件大量使用(`:37`、`:100`-`:101`、`:121`-`:123`、`:134`)。
- 请用户在本关卡确认该例外成立(确认后即按 D-23 的「用户已决定解决方案」路径执行,不停摆);若用户改口,执行者退出本计划并修订。
⑥ **范围边界与禁令(须原样告知):** 只改 `packages-user/client-modules/src/render/ui/main.tsx` 与 `packages-user/client-modules/src/render/map/renderer.ts` 两个文件;**不碰**其余对账项(`#04-01-M-01` 属 D-19 用户自行处理;`#04-01-M-07`、`#04-01-M-08` 用户原话「其他几个都涉及到接口的调整,这部分我自己来」;`#04-01-M-02..M-05` 已由 04-03 / 04-04 处置;全部 ③ 多余旧路径 `core.*` 属第五阶段 D-12);不改任何数据端文件与 `packages/**` / `src/**`;不新增数据端公共接口(D-21);不动贴图 / 不透明度(D-18)、跟随者(D-22)、`action/move.ts` 与移动控制(D-19)、`client.ts:151-155` 的 `addHero` 接线;不新增依赖;不回滚 / 暂存 / 提交用户并发未提交改动。
⑦ **验证方式与诚实降级(须原样告知):** 渲染端**无测试设施**(`client-modules` 无 `*.test.*`),且 `client.ts:151-155` 的 `addHero` 接线仍为注释、`MapExtensionManager.addHero` 无调用点,故**运行时渲染无法观察**(状态栏数值、地图区域 / 图块更新后的顶点刷新都跑不起来)。本计划的验收 = **scoped 类型门禁**(`pnpm exec vue-tsc --noEmit`:`main.tsx` / `renderer.ts` 0 条,且 `client-modules` 错误集与当日记录基线完全一致)+ **M-06 / M-09 静态门禁** + **`pnpm exec eslint`** + **CRLF 门禁** + **基线感知范围门禁(放行两个在范围文件)与并发改动基线门禁** + **人工代码复核**;另附「该移除的 `@ts-expect-error` 已移除(`@user/data-state` import 上方无残留,`main.tsx:247` 的 M-08 指令保留)、`state` import 保留」的显式断言。**不冒充任何无法运行的门禁,不新增测试文件(无设施),不伪造绿。**
⑧ **关卡性质(必须原样告知用户):** 无论触发方式(`/gsd-execute-phase`、`/gsd-manager`、`/gsd-autonomous`、子代理 / 执行器派发)或配置模式(`.planning/config.json` 的 `yolo`、`workflow.auto_advance`)如何,**执行者 / 子代理都必须在此停下并等待**用户明确回复「可以执行」四个字;未收到这四个字前不得执行任何任务、不得派发执行子代理,也不得以「自动化模式」为由自动放行。
⑨ **失败语义(等同硬门禁,非自动命令):** 用户未回复「可以执行」即继续,或回复的是其它任何内容(含修订意见、沉默、`yolo` / `auto_advance` 下的自动推进、或子代理 / 执行器的自行放行),一律视为**未通过本关卡**:立即停止执行,不派发任何执行子代理,保持只读、不写任何文件,直到用户在对话中明确回复「可以执行」四个字后方可继续。
⑩ **若用户给出修订意见**(含不同意命名 / 不同意移除 `:28` 指令 / 不同意 M-06 使用 `client` / 要求更换方案):退出本计划、修订 `04-05-PLAN.md` 后重新汇报,不得自行改道执行。
</action>
<decision>是否按本计划(两文件收口 `#04-01-M-06` 与 `#04-01-M-09`:`main.tsx` 9 处属性改经 `client.hero.attribute.getFinalAttribute` 读取并移除 `getHeroStatusOn` 与 `:28` 的 `@ts-expect-error` 指令;`renderer.ts` 新增内部类 `RendererLayerHook` 并改到逐图层 `addHook` 注册、移除 `onUpdateLayerArea` / `onUpdateLayerBlock`)开始执行;并确认 ③ 的拟名(`renderer` / `layer` / `layerHooks` / `syncLayerHooks`)、④ 的指令移除、⑤ 的 D-23 例外</decision>
<context>
AGENTS.md 要求任何计划的第一个 block 点必须是向用户汇报计划内容并等待用户确认,且任何新增 / 改写的成员命名必须先经用户同意。本计划的三处判断(新命名、移除既有注释、在 D-23 禁单例的前提下使用 `client`)都属于「接口 / 命名 / 规则设计归用户」的边界;`.planning/config.json` 为 `mode: yolo`,因此该关卡必须显式声明为 `blocking-human` 并置 `autonomous: false`。若不在关卡内定下这三点,执行者会在「加什么字段名」「删不删注释」「用不用 `client`」上自行猜测。
</context>
<options>
<option id="execute">
<name>可以执行</name>
<pros>按本计划落地:M-06 9 处改走 `client.hero.attribute.getFinalAttribute` 并移除 `getHeroStatusOn` 与 `:28` 指令;M-09 新增 `RendererLayerHook` 并改到逐图层注册;命名按 ③ 拟名(`renderer` / `layer` / `layerHooks` / `syncLayerHooks`)</pros>
<cons>`main.tsx` 新引入 `client` 单例读法(D-23 的例外,用户已裁定)</cons>
</option>
<option id="execute-renamed">
<name>可以执行,但改名 / 改细节</name>
<pros>按用户指定的字段 / 方法名与细节执行,避免命名返工</pros>
<cons>无</cons>
</option>
<option id="revise">
<name>修订方案后再执行</name>
<pros>避免带着错误命名或错误接线方案落地(M-09 改的是钩子注册路径,返工成本高)</pros>
<cons>退出本计划并修订 PLAN.md 后重新汇报,增加一轮往返</cons>
</option>
</options>
<resume-signal>回复「可以执行」四个字(如需改名 / 改细节,请在回复中一并给出),或给出修订意见</resume-signal>
<verify>
<human-check>用户在对话中明确回复「可以执行」四个字(人工核对用户回复原文;本关卡为人工关卡,无可运行的 `<automated>` 命令,故不携带 `<fails_when>`;失败语义完整写在 Task 0 的 action ⑨ 与 acceptance_criteria 中)</human-check>
</verify>
<acceptance_criteria>
- 用户回复中明确包含「可以执行」四个字
- 汇报内容涵盖:要解决的问题(`#04-01-M-06` / `#04-01-M-09` 的锚点、依据与影响,含 M-09 的「静默失效」性质)、大致解决方案(两个文件的改法)、成员命名清单(已批准 `RendererLayerHook`;待批 `renderer` / `layer` / `layerHooks` / `syncLayerHooks`)、**必须移除 `main.tsx:28` 的 `@ts-expect-error` 指令**(含 TS2578 实测依据)、**M-06 使用 `client` 对 D-23 的例外**(含用户原话)、范围边界与禁令、验证方式与「运行时渲染不可观察」的诚实降级
- 汇报中已原样告知关卡性质:执行者 / 子代理必须停下等待,不因触发方式或 yolo / auto 模式自动放行
</acceptance_criteria>
<done>用户明确回复「可以执行」(如需改名 / 改细节,已在回复中给出);在此之前未执行任何任务、未修改任何文件、未派发任何执行子代理</done>
</task>
<task type="tracer">
<name>Task 1(tracer):`#04-01-M-06` 端到端最小切片 —— 状态栏 9 个属性从 `getHeroStatusOn` 改经 `client.hero.attribute.getFinalAttribute` 读取,并移除失效 import 与 `:28` 的 `@ts-expect-error` 指令</name>
<files>packages-user/client-modules/src/render/ui/main.tsx, .planning/phases/04-render-adaptation/04-05-SUMMARY.md</files>
<read_first>
- AGENTS.md(注释规则 / 命名规则 / 第一个 block 点)与 dev.md(换行 CRLF;注释规范;`import` 之间不得夹注释)
- packages-user/client-modules/src/render/ui/main.tsx(整文件 311 行;重点 `:28`-`:34` import、`:98`-`:135` `updateStatus()`、`:245`-`:249` `state.maps`)
- packages-user/client-modules/src/render/ui/statusBar.tsx:17-39(`ILeftHeroStatus` 的 9 个数值字段类型,确认与 `IHeroAttr` 键逐一对应)
- packages-user/client-modules/src/core.ts:1-6(`client` 单例来源,**只读**)
- packages-user/client-modules/src/client.ts:47,74,100-101,132-133(`ClientCore extends CoreState`;`client.flags` 既有用法)
- packages-user/data-state/src/core.ts:86,144(`readonly hero: IHeroState<IHeroAttr>` 与其赋值,`client.hero` 的来源)
- packages-user/data-base/src/hero/types.ts:95-140,889-905(`IReadonlyHeroAttribute.getFinalAttribute` 与 `IHeroState.attribute`)
- packages-user/data-common/src/types.ts:23-44(`IHeroAttr` 九键)
- packages-user/data-state/src/index.ts 与 src/ins.ts:11-13(`state` 由 barrel 导出、`getHeroStatusOn` 无导出)
- *.planning/phases/04-render-adaptation/04-04-PLAN.md:286-330*(前序计划的「执行前核对 / 并发基线 / 类型基线」写法,本任务照此执行)
</read_first>
<action>
(0) **执行前核对与基线(只读前置,必须先做):**
(0a) **数据端现行接口复核(消费未确定项,不猜)**:打开当日 HEAD 的基准文件逐条确认,并把结论(每条一行:结论 + `file:line`)写入 `04-05-SUMMARY.md` 的「执行前核对」段:① `IHeroAttr` 仍有九键且类型为 `number`(`packages-user/data-common/src/types.ts:23-44`);② `IReadonlyHeroAttribute.getFinalAttribute<K extends keyof THero>(name: K): THero[K]` 签名未变(`packages-user/data-base/src/hero/types.ts:106`),且 `IHeroState.attribute: IReadonlyHeroAttribute<THero>`(`:896`);③ `CoreState.hero` 为公开只读成员且在构造期赋值(`packages-user/data-state/src/core.ts:86` / `:144`);④ `@user/data-state` barrel 仍无 `getHeroStatusOn` 导出(`packages-user/data-state/src/index.ts:1-9`),且全仓库无该符号定义(只读检索确认);⑤ `ILeftHeroStatus` 的 `hp` / `hpmax` / `mana` / `manamax` / `atk` / `def` / `mdef` / `money` / `exp` 均为 `number`(`render/ui/statusBar.tsx:17-39`);⑥ `client` 由 `packages-user/client-modules/src/core.ts:6` 导出且 `ClientCore extends CoreState`(`client.ts:47`)。**任一不同:立即停止、不写任何代码,向用户汇报差异并修订本计划**(不得按记忆继续)。
(0b) **并发改动基线**:执行下述固定命令(排除即将改动的 `main.tsx` / `renderer.ts` 两个在范围文件与 `.planning/phases/04-render-adaptation/` 前缀),把输出**原样**写入 SUMMARY,格式固定为:
```
<!-- CONCURRENT-BASELINE:START -->
<porcelain 条目逐行>
<!-- CONCURRENT-BASELINE:END -->
基线摘要 sha256=<64hex>
```
```
node -e "const fs=require('fs'),c=require('crypto'),{execSync}=require('child_process');const keep=l=>!/main\.tsx$/.test(l)&&!/map[\\/]renderer\.ts$/.test(l)&&!/\.planning\/phases\/04-render-adaptation\//.test(l);const out=execSync('git status --porcelain',{encoding:'utf8'}).replace(/\s+$/,'');const cur=(out?out.split(/\r?\n/).map(s=>s.replace(/\s+$/,'')):[]).filter(keep);const digest=c.createHash('sha256').update(cur.slice().sort().map(l=>{let h='MISSING';try{h=c.createHash('sha256').update(fs.readFileSync(l.slice(3))).digest('hex')}catch(e){}return l+' '+h}).join('\n')).digest('hex');console.log(cur.join('\n'));console.log('基线摘要 sha256='+digest)"
```
**不得**沿用任何前序 SUMMARY 的旧列表;一律以本次实测为准。**不得回滚 / 暂存 / 提交 / 修改**其中任何条目。
(0c) **类型门禁基线**:执行 `pnpm exec vue-tsc --noEmit`(整仓会以非 0 退出属预期)并把 `client-modules` 的命中行**归一化**(每行形如 `<相对 client-modules/src 的路径>(line,col): error TSxxxx`)后原样写入 SUMMARY,格式固定为:
```
<!-- TYPE-BASELINE:START -->
<归一化后的 client-modules 命中行,逐行>
<!-- TYPE-BASELINE:END -->
```
同时确认两个在范围文件(`render/ui/main.tsx`、`render/map/renderer.ts`)**当前 0 条诊断**。**若当日基线与规划时记录(0 条)不同,以当日实测为准记入 SUMMARY**(说明用户并发改动改变了基线),**不得静默沿用旧值**。若 `pnpm` 被执行策略拦截,改用 `pnpm.cmd exec vue-tsc --noEmit`(等价)。**基线捕获必须证明类型检查确实跑起来了**:记录命令的退出码(整仓非 0 属预期)并确认捕获非空或退出码为 0;若输出为空且退出码不可得(命令未执行 / spawn 失败),**不得**把它记成「0 条基线」,必须停下汇报——空输出与「干净」不可混淆。
(0d) 建立 `04-05-SUMMARY.md` 骨架(含上面三段 + 后续任务要填的空节),保持 CRLF。
(1) **`main.tsx` 的 M-06 改动(逐条对齐;行号为改动前锚点):**
(1a) 删除 `:28` 的整行 `// @ts-expect-error 需要重构`(Task 0 已获用户批准;这是**移除**既有注释,不是改写;不得借此改写其它任何注释)。
(1b) 把 `:29` 的 `import { getHeroStatusOn, state } from '@user/data-state';` 改为 `import { state } from '@user/data-state';`(`state` **必须保留**——`:248` 的 `state.maps` 属 M-08,本计划不动)。
(1c) 9 处替换(`:104`-`:112`,每条只替换右值,行内其余不变):
`:104` `leftStatus.atk = getHeroStatusOn('atk');` → `leftStatus.atk = client.hero.attribute.getFinalAttribute('atk');`
`:105` `hp` → `client.hero.attribute.getFinalAttribute('hp')`
`:106` `hpmax` → `client.hero.attribute.getFinalAttribute('hpmax')`
`:107` `mana` → `client.hero.attribute.getFinalAttribute('mana')`
`:108` `manamax` → `client.hero.attribute.getFinalAttribute('manamax')`
`:109` `def` → `client.hero.attribute.getFinalAttribute('def')`
`:110` `mdef` → `client.hero.attribute.getFinalAttribute('mdef')`
`:111` `money` → `client.hero.attribute.getFinalAttribute('money')`
`:112` `exp` → `client.hero.attribute.getFinalAttribute('exp')`
(1d) **不得改动的事**:`:99` / `:103` 的 `core.status.*`、`:113`-`:120` 的 `core.getNextLvUpNeed()` / `core.itemCount(...)`、`:124` 的 `core.status.floorId`、`:125` 的 `core.getLvName(hero.lv)`(与 `:103` 的 `const hero = core.status.hero;` 一并保留,`hero` 仍被 `:125` 使用)、`:127`-`:132` 的 `core.status.replay.*` / `core.isReplaying()`、`:134` 的 `client.flags`、`:139` 的 `core.status.lockControl`、`:155` / `:165` 的 `core.status.stepPostfix`、`:198` / `:207` / `:216` 的 `core.doRegisteredAction`、`:248` 的 `state.maps` 与 `:247` 的 `// @ts-expect-error 需要重构`(M-08,本计划不动)——**一律逐字保持原样**(D-12 / M-07 / M-08 不在本计划)。
(1e) 本文件新增内容不得引入任何新命名(只出现既有成员访问链 `client.hero.attribute.getFinalAttribute`)。
(2) **本任务提交(验证通过后)**:`git add` 仅 `packages-user/client-modules/src/render/ui/main.tsx`,提交信息 `fix(04-05): read hero status via client attribute API`;**不得**把用户的并发未提交改动纳入提交;**验证未通过不得提交**。
(3) 全程保持 `main.tsx` CRLF;改完执行 `pnpm exec eslint --fix` 对该文件,再 `pnpm exec eslint` 必须 0 错误。
</action>
<reversibility rating="reversible">本任务只把 `main.tsx` 的 9 处属性读取从已失效的 `getHeroStatusOn` 改到 `client.hero.attribute.getFinalAttribute` 并移除一条失效 import 与一条失效的 `@ts-expect-error` 指令;回退只需还原该文件,无契约 / 数据影响,故为 `reversible`。</reversibility>
<verify>
<automated>node -e "const fs=require('fs');const p='packages-user/client-modules/src/render/ui/main.tsx';const s=fs.readFileSync(p,'utf8');const cnt=x=>(s.match(x)||[]).length;const g=cnt(/getHeroStatusOn/g);if(g!==0){console.error('M06 getHeroStatusOn REMAINS '+g);process.exit(1)}const calls=(s.match(/getFinalAttribute\([^)]*\)/g)||[]).map(x=>x.replace(/getFinalAttribute/g,'').replace(/[^A-Za-z]/g,''));const want=['atk','def','exp','hp','hpmax','mana','manamax','mdef','money'];const got=calls.slice().sort().join(',');if(got!==want.join(',')){console.error('M06 ATTR SET MISMATCH got=['+got+'] want=['+want.join(',')+']');process.exit(1)}if(!s.includes('@user/data-state')){console.error('M06 data-state IMPORT LOST');process.exit(1)}if(!s.includes('{ state } from')){console.error('M06 state IMPORT NOT RETAINED');process.exit(1)}const idx=s.indexOf('@user/data-state');if(idx<0){console.error('M06 data-state IMPORT NOT FOUND');process.exit(1)}if(/ts-expect-error/.test(s.slice(Math.max(0,idx-400),idx))){console.error('M06 DIRECTIVE ABOVE data-state IMPORT REMAINS');process.exit(1)}const te=(s.match(/ts-expect-error/g)||[]).length;if(te!==1){console.error('M06 ts-expect-error COUNT '+te+' (expected exactly 1 = the preserved M-08 directive at main.tsx:247)');process.exit(1)}if(!/ts-expect-error[^\r\n]*\r?\n\s*layerState=\{state\.maps\}/.test(s)){console.error('M06 M08 DIRECTIVE/state.maps PAIRING LOST');process.exit(1)}if(!s.includes('core.status')||!s.includes('core.itemCount')){console.error('M06 LEGACY core READS LOST (out of scope)');process.exit(1)}console.log('OK M06: getHeroStatusOn 0; getFinalAttribute 9 keys OK; state import kept; core.* preserved')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "M06 getHeroStatusOn REMAINS"(`getHeroStatusOn` 仍在 `main.tsx`)/ "M06 ATTR SET MISMATCH"(9 处 `getFinalAttribute(...)` 的键集合与 `atk,def,exp,hp,hpmax,mana,manamax,mdef,money` 不一致,或数量不为 9)/ "M06 data-state IMPORT LOST" / "M06 data-state IMPORT NOT FOUND" / "M06 state IMPORT NOT RETAINED"(`state` import 被误删,会连带打断 M-08 的 `:248`)/ "M06 DIRECTIVE ABOVE data-state IMPORT REMAINS"(`getHeroStatusOn` 对应的 `@ts-expect-error`(`@user/data-state` import 上方那条)仍在,将产生 TS2578)/ "M06 ts-expect-error COUNT"(计数不等于 1:为 0 说明把 `main.tsx:247` 的 M-08 指令也删了;≥2 说明 `:28` 未删)/ "M06 M08 DIRECTIVE/state.maps PAIRING LOST"(`:247` 指令与 `state.maps` 的配对被破坏,越界改动 M-08)/ "M06 LEGACY core READS LOST"(越界改动:`core.*` 被顺手迁移)</fails_when>
<automated>node -e "const fs=require('fs'),{execSync}=require('child_process');let out='',status=null;try{out=execSync('pnpm exec vue-tsc --noEmit',{encoding:'utf8',maxBuffer:1<<26,stdio:['ignore','pipe','pipe']});status=0}catch(e){out=String(e.stdout||'')+String(e.stderr||'');status=(typeof e.status==='number')?e.status:null}if(status===null){console.error('TYPE CHECK DID NOT RUN (vue-tsc spawn failed): '+out.slice(0,200));process.exit(1)}const lines=out.split(/\r?\n/).filter(l=>/: error TS\d+/.test(l));if(lines.length===0&&status!==0){console.error('TYPE CHECK EXITED '+status+' WITH NO PARSEABLE DIAGNOSTICS');process.exit(1)}const scope=lines.filter(l=>/render[\\/]ui[\\/]main\.tsx\(\d+,\d+\)|render[\\/]map[\\/]renderer\.ts\(\d+,\d+\)/.test(l));if(scope.length){console.error('IN-SCOPE TYPE ERRORS '+scope.length+'\n'+scope.join('\n'));process.exit(1)}const norm=l=>{const m=l.match(/client-modules[\\/]src[\\/](.+)$/);return m?m[1].replace(/\s+$/,''):null};const cur=lines.map(norm).filter(Boolean).sort();const t=fs.readFileSync('.planning/phases/04-render-adaptation/04-05-SUMMARY.md','utf8');const bm=t.match(/<!-- TYPE-BASELINE:START -->([\s\S]*?)<!-- TYPE-BASELINE:END -->/);if(!bm){console.error('NO TYPE BASELINE');process.exit(1)}const base=bm[1].split(/\r?\n/).map(s=>s.trim()).filter(Boolean).sort();const extra=cur.filter(x=>base.indexOf(x)<0);const missing=base.filter(x=>cur.indexOf(x)<0);if(extra.length||missing.length){console.error('CLIENT-MODULES ERROR SET CHANGED missing=['+missing.join(' ; ')+'] extra=['+extra.join(' ; ')+']');process.exit(1)}console.log('OK in-scope type errors 0; client-modules baseline '+cur.length)"</automated>
<fails_when>non-zero exit, 或 stderr 含 "TYPE CHECK DID NOT RUN"(`vue-tsc` 未跑起来——spawn 失败,此时输出为空,绝不可与「0 条」混淆为通过)/ "TYPE CHECK EXITED"(进程非零退出却没有任何可解析诊断,视为未获可信结果)/ "IN-SCOPE TYPE ERRORS"(`main.tsx` / `renderer.ts` 仍有类型错误,例如 import 上方 `@ts-expect-error` 未删除而报 TS2578)/ "CLIENT-MODULES ERROR SET CHANGED"(渲染端类型错误集相对基线增减)/ "NO TYPE BASELINE"(SUMMARY 未按固定格式记录基线)</fails_when>
<automated>pnpm exec eslint packages-user/client-modules/src/render/ui/main.tsx</automated>
<fails_when>non-zero exit 或输出含 "error"(ESLint / Prettier 违规,含未使用 import / 未使用变量 / 注释规则)</fails_when>
<automated>node -e "const fs=require('fs');const s=fs.readFileSync('packages-user/client-modules/src/render/ui/main.tsx','utf8');const lf=(s.match(/\n/g)||[]).length;const crlf=(s.match(/\r\n/g)||[]).length;if(lf!==crlf){console.error('NON-CRLF LINE ENDINGS main.tsx');process.exit(1)}console.log('OK CRLF')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "NON-CRLF LINE ENDINGS"(出现孤立 LF,违反 dev.md「换行使用 CRLF 格式」)</fails_when>
</verify>
<acceptance_criteria>
- `main.tsx` 的 `getHeroStatusOn` 计数为 0(import 与 9 处调用全部消失),`client.hero.attribute.getFinalAttribute('<key>')` 恰为 9 次且键集合为 `atk, def, exp, hp, hpmax, mana, manamax, mdef, money`
- `:28` 的 `// @ts-expect-error 需要重构` 已移除(`@user/data-state` import 上方不再有该指令);`main.tsx:247` 服务于 M-08 `state.maps` 的同名指令逐字保留(全文件 `@ts-expect-error` 计数由 2 → 1)
- `import { state } from '@user/data-state';` 保留;其余 `core.*`(含 `core.status.*` / `core.getNextLvUpNeed()` / `core.getLvName()` / `core.itemCount()` / `core.isReplaying()` / `core.doRegisteredAction()` / `core.status.stepPostfix` / `core.status.lockControl`)与 `:248` 的 `state.maps`(M-08)、`:247` 的 `@ts-expect-error` 一字未动
- 本任务**未**触碰 `renderer.ts`;未改动任何既有注释(除经批准的 `:28` 整行移除);未新增任何命名
- `04-05-SUMMARY.md` 的「执行前核对」含 (0a) 六条结论 + `file:line`,`CONCURRENT-BASELINE` 段与「基线摘要 sha256」按固定格式记录,`TYPE-BASELINE` 段按固定格式记录
- 四条 `<automated>` 全部转绿(M-06 静态门禁、scoped 类型门禁、eslint、CRLF);本任务改动已以 `fix(04-05): read hero status via client attribute API` 原子提交(仅 `main.tsx`)
</acceptance_criteria>
<done>状态栏 9 个属性全部经 `client.hero.attribute.getFinalAttribute` 读取,`getHeroStatusOn` 与其上方 `:28` 的 `@ts-expect-error` 指令消失(`main.tsx:247` 的 M-08 指令保留)、`state` import 与全部 `core.*` 原样保留;M-06 静态门禁 / scoped 类型门禁 / eslint / CRLF 四条转绿并完成本文件的原子提交</done>
</task>
<task type="auto">
<name>Task 2:`#04-01-M-09` —— 新增逐图层钩子类 `RendererLayerHook`(`Partial<IMapLayerHooks>`)把 `onUpdateArea` / `onUpdateBlock` 转发到 `updateLayerArea` / `updateLayerBlock`,并在图层状态变更路径改为 `layer.addHook` 注册</name>
<files>packages-user/client-modules/src/render/map/renderer.ts</files>
<read_first>
- packages-user/client-modules/src/render/map/renderer.ts(`import` `:33`;`mapHook` 字段 `:83`;`updateLayerList` `:414-420`;`setLayerState` `:422-433`;`updateLayerArea` `:1527-1536`;`updateLayerBlock` `:1545-1548`;`RendererLayerStateHook` `:1730-1763`)
- packages-user/data-base/src/map/types.ts:207-264(`IMapLayerHooks.onResize` / `onUpdateArea` / `onUpdateBlock` / `onOpenDoor` / `onCloseDoor` 与可选动态块钩子;`IMapLayerHookController`)
- packages-user/data-base/src/map/types.ts:588-608(`IGameMapHooks` 现行三成员——`RendererLayerStateHook` 上那两个成员非契约的根因)
- packages-user/data-base/src/map/mapLayer.ts:236-248,290-303(`setBlock` / `putMapData` 经 `forEachHook` 触发 `onUpdateBlock?` / `onUpdateArea?`)
- packages-user/data-base/src/map/gameMap.ts:59-87(`addLayer` / `removeLayer` 在图层列表变化时触发 map 级 `onUpdateLayer`)
- packages-user/client-modules/src/render/map/extension/door.ts:11-24,63-73(`layer.addHook(new MapDoorHook(this))` + `.load()` / `.unload()` 的既有范式,本任务照此写 `RendererLayerHook` 的注册)
- .planning/phases/04-render-adaptation/04-RENDER-INTERFACE-AUDIT.md(`#04-01-M-09` 条目与触发序列)
</read_first>
<action>
(1) **import 扩充(`:33` 同一行)**:在 `import { IGameMap, IGameMapHooks, IMapLayer } from '@user/data-base';` 中追加 `IMapLayerHookController` 与 `IMapLayerHooks`;**不新增 import 语句、不调整既有名字顺序以外的东西**,也不引入本任务用不到的名字(eslint `@typescript-eslint/no-unused-vars` 为 error)。
(2) **新增私有字段(拟名,Task 0 已批准或改名)**:在既有 `private mapHook: IHookController<IGameMapHooks> | null = null;`(`:83`)附近新增 `private layerHooks: Map<IMapLayer, IMapLayerHookController> = new Map();`,并加一行中文说明其用途(新增注释,须符合 dev.md 注释规范;不得改写既有注释)。若 Task 0 改名为指定写法,按用户名字落地。
(3) **新增私有方法(拟名 `syncLayerHooks`,Task 0 已批准或改名)**:语义为「使登记的逐图层钩子集合与 `this.map.layerList` 一致」——先遍历 `this.layerHooks` 的既有登记,凡其图层已不在 `this.map.layerList`(或 `this.map` 已为 `null`)者调用其控制器的 `.unload()` 并从登记中移除;再遍历 `this.map.layerList`,凡尚无登记者执行 `layer.addHook(new RendererLayerHook(this, layer))`、`.load()`、并写入登记。**注意 `this.map` 是 `IGameMap | null`,其 `layerList` 为 `Set<IMapLayer>`(`data-base/src/map/types.ts:625`),登记键类型同为 `IMapLayer`,两者一致,不得为此引入 `any` / `as`**(若编译要求类型收窄,按类型定义写,不得掩盖)。
(4) **接到图层状态变更路径(两处,缺一不可)**:
(4a) `setLayerState`(`:422`):在 `this.map = map;` 之后、`this.sortLayer();` 之前调用 `this.syncLayerHooks();`——换地图时旧地图的逐图层钩子因不在新 `map.layerList` 而被 `.unload()` 并移出登记,新地图的图层被注册。其余行(`this.mapHook?.unload()` / `map.addHook(new RendererLayerStateHook(this))` / `this.mapHook.load()` / `sortLayer` / `resizeLayer` / `layerCount` / `vertex.updateLayerArray()` / `vertex.resizeMap()`)**一律保持原样**。
(4b) `updateLayerList()`(`:414`):在 `if (!this.map) return;` 之后调用 `this.syncLayerHooks();`——覆盖 `GameMap.addLayer` / `removeLayer` 后经 map 级 `onUpdateLayer` 进来的新增 / 移除图层。其余行保持原样。
(5) **新增模块内类 `RendererLayerHook`(用户已批准该类名;不导出)**:紧邻 `RendererLayerStateHook`(`:1730-1763`)定义,声明为 `class RendererLayerHook implements Partial<IMapLayerHooks>`;构造器持有 `renderer: MapRenderer` 与 `layer: IMapLayer` 两个只读成员(字段名拟 `renderer` / `layer`,Task 0 待批);实现 `onUpdateArea(x: number, y: number, width: number, height: number): void` 转发 `this.renderer.updateLayerArea(this.layer, x, y, width, height)`,与 `onUpdateBlock(block: number, x: number, y: number): void` 转发 `this.renderer.updateLayerBlock(this.layer, block, x, y)`。**只实现这两个成员**(其余 `IMapLayerHooks` 成员由 `Partial` 允许缺省;门的开关另有 `MapDoorHook`,动态块钩子未被渲染端消费)。按 dev.md「不对构造器添加注释」不写构造器注释;新增注释须为有信息量的中文单行注释。
(6) **删除旧错误注册**:从 `RendererLayerStateHook` 中删除 `onUpdateLayerArea`(`:1745-1753`)与 `onUpdateLayerBlock`(`:1755-1762`)两个方法;**保留** `onChangeBackground` / `onResizeLayer` / `onUpdateLayer` 三个 map 级成员与类名 `RendererLayerStateHook` 不变。删除的是**代码**(非契约成员),不得改写任何既有注释。
(7) **契约面零变更**:不改 `IMapRenderer`(`render/map/types.ts`)与任何数据端文件;`RendererLayerHook` 不得出现在任何 `export` 中;不新增 / 删除 / 改名 `MapRenderer` 的既有公共成员。
(8) **本任务提交(验证通过后)**:`git add` 仅 `packages-user/client-modules/src/render/map/renderer.ts`,提交信息 `fix(04-05): forward layer area/block updates via IMapLayerHooks`;**不得**把用户的并发未提交改动纳入提交;**验证未通过不得提交**。
(9) 全程保持 `renderer.ts` CRLF;改完执行 `pnpm exec eslint --fix` 对该文件,再 `pnpm exec eslint` 必须 0 错误。
</action>
<reversibility rating="reversible">本任务只在渲染端内部新增一个不导出的钩子类、一个私有登记字段与一个私有同步方法,并把逐图层更新钩子从无效的 map 级注册改到现行 `IMapLayerHooks`;公共契约与数据端零改动,回退只需还原该文件,故为 `reversible`。</reversibility>
<verify>
<automated>node -e "const fs=require('fs');const s=fs.readFileSync('packages-user/client-modules/src/render/map/renderer.ts','utf8');const hasTok=t=>new RegExp('(?<![A-Za-z0-9_])'+t+'(?![A-Za-z0-9_])').test(s);const fail=m=>{console.error('M09 '+m);process.exit(1)};if(!/class RendererLayerHook implements Partial<IMapLayerHooks>/.test(s))fail('RendererLayerHook DECL MISSING');if(!/\.addHook\(new RendererLayerHook\(/.test(s))fail('LAYER HOOK NOT REGISTERED via layer.addHook');if(!/updateLayerArea\(this\.layer/.test(s))fail('onUpdateArea NOT FORWARDING to updateLayerArea');if(!/updateLayerBlock\(this\.layer/.test(s))fail('onUpdateBlock NOT FORWARDING to updateLayerBlock');const legacy=(s.match(/onUpdateLayerArea|onUpdateLayerBlock/g)||[]).length;if(legacy!==0)fail('LEGACY MAP-LEVEL UPDATE HOOKS REMAIN '+legacy);for(const t of ['onChangeBackground','onResizeLayer','onUpdateLayer'])if(!s.includes(t))fail('RendererLayerStateHook MEMBER LOST '+t);if(!hasTok('syncLayerHooks'))fail('SYNC ENTRY NAME NOT FOUND (if renamed at Task 0, substitute the approved name)');const ss=s.slice(s.indexOf('setLayerState('));if(!ss.slice(0,ss.indexOf('\n }')).includes('syncLayerHooks'))fail('setLayerState DOES NOT SYNC LAYER HOOKS');const ul=s.slice(s.indexOf('updateLayerList()'));if(!ul.slice(0,ul.indexOf('\n }')).includes('syncLayerHooks'))fail('updateLayerList DOES NOT SYNC LAYER HOOKS');if(!/\.unload\(\)/.test(s))fail('NO unload for stale layer hooks');if(/export .*RendererLayerHook|export \{[\s\S]*RendererLayerHook[\s\S]*\}/.test(s))fail('RendererLayerHook MUST NOT BE EXPORTED');if(!s.includes('IMapLayerHooks')||!s.includes('IMapLayerHookController'))fail('REQUIRED TYPE IMPORTS MISSING');console.log('OK M09: RendererLayerHook forwards area/block via per-layer addHook; map-level update hooks removed')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "M09 RendererLayerHook DECL MISSING"(未按 `class RendererLayerHook implements Partial<IMapLayerHooks>` 声明)/ "M09 LAYER HOOK NOT REGISTERED via layer.addHook"(未在图层上注册)/ "M09 onUpdateArea NOT FORWARDING to updateLayerArea" / "M09 onUpdateBlock NOT FORWARDING to updateLayerBlock"(转发目标或 `this.layer` 传参不对)/ "M09 LEGACY MAP-LEVEL UPDATE HOOKS REMAIN"(`RendererLayerStateHook` 上旧成员未删)/ "M09 RendererLayerStateHook MEMBER LOST"(map 级三成员被误删)/ "M09 SYNC ENTRY NAME NOT FOUND"(同步入口改名后未同步替换本门禁字符串)/ "M09 setLayerState DOES NOT SYNC LAYER HOOKS" / "M09 updateLayerList DOES NOT SYNC LAYER HOOKS"(注册未发生在图层状态变更路径)/ "M09 NO unload for stale layer hooks"(换地图 / 移除图层时未注销)/ "M09 RendererLayerHook MUST NOT BE EXPORTED" / "M09 REQUIRED TYPE IMPORTS MISSING"</fails_when>
<automated>node -e "const fs=require('fs'),{execSync}=require('child_process');let out='',status=null;try{out=execSync('pnpm exec vue-tsc --noEmit',{encoding:'utf8',maxBuffer:1<<26,stdio:['ignore','pipe','pipe']});status=0}catch(e){out=String(e.stdout||'')+String(e.stderr||'');status=(typeof e.status==='number')?e.status:null}if(status===null){console.error('TYPE CHECK DID NOT RUN (vue-tsc spawn failed): '+out.slice(0,200));process.exit(1)}const lines=out.split(/\r?\n/).filter(l=>/: error TS\d+/.test(l));if(lines.length===0&&status!==0){console.error('TYPE CHECK EXITED '+status+' WITH NO PARSEABLE DIAGNOSTICS');process.exit(1)}const scope=lines.filter(l=>/render[\\/]ui[\\/]main\.tsx\(\d+,\d+\)|render[\\/]map[\\/]renderer\.ts\(\d+,\d+\)/.test(l));if(scope.length){console.error('IN-SCOPE TYPE ERRORS '+scope.length+'\n'+scope.join('\n'));process.exit(1)}const norm=l=>{const m=l.match(/client-modules[\\/]src[\\/](.+)$/);return m?m[1].replace(/\s+$/,''):null};const cur=lines.map(norm).filter(Boolean).sort();const t=fs.readFileSync('.planning/phases/04-render-adaptation/04-05-SUMMARY.md','utf8');const bm=t.match(/<!-- TYPE-BASELINE:START -->([\s\S]*?)<!-- TYPE-BASELINE:END -->/);if(!bm){console.error('NO TYPE BASELINE');process.exit(1)}const base=bm[1].split(/\r?\n/).map(s=>s.trim()).filter(Boolean).sort();const extra=cur.filter(x=>base.indexOf(x)<0);const missing=base.filter(x=>cur.indexOf(x)<0);if(extra.length||missing.length){console.error('CLIENT-MODULES ERROR SET CHANGED missing=['+missing.join(' ; ')+'] extra=['+extra.join(' ; ')+']');process.exit(1)}console.log('OK in-scope type errors 0; client-modules baseline '+cur.length)"</automated>
<fails_when>non-zero exit, 或 stderr 含 "TYPE CHECK DID NOT RUN"(`vue-tsc` 未跑起来——spawn 失败,空输出不得判通过)/ "TYPE CHECK EXITED"(非零退出且无可解析诊断)/ "IN-SCOPE TYPE ERRORS"(`renderer.ts` 新钩子类 / 登记 / 同步逻辑引入类型错误,例如 `any` / `as` 掩盖失败的收窄)/ "CLIENT-MODULES ERROR SET CHANGED" / "NO TYPE BASELINE"</fails_when>
<automated>pnpm exec eslint packages-user/client-modules/src/render/map/renderer.ts</automated>
<fails_when>non-zero exit 或输出含 "error"(含未使用 import / 未使用变量 / Prettier 违规 / 注释规则)</fails_when>
<automated>node -e "const fs=require('fs');const s=fs.readFileSync('packages-user/client-modules/src/render/map/renderer.ts','utf8');const lf=(s.match(/\n/g)||[]).length;const crlf=(s.match(/\r\n/g)||[]).length;if(lf!==crlf){console.error('NON-CRLF LINE ENDINGS renderer.ts lone-lf='+(lf-crlf));process.exit(1)}console.log('OK CRLF')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "NON-CRLF LINE ENDINGS"(出现孤立 LF)</fails_when>
</verify>
<acceptance_criteria>
- `renderer.ts` 存在 `class RendererLayerHook implements Partial<IMapLayerHooks>`(模块内不导出),实现 `onUpdateArea` → `this.renderer.updateLayerArea(this.layer, x, y, width, height)` 与 `onUpdateBlock` → `this.renderer.updateLayerBlock(this.layer, block, x, y)`
- 逐图层注册经 `layer.addHook(new RendererLayerHook(...))` + `.load()`,且发生在 `setLayerState` 与 `updateLayerList` 两条图层状态变更路径上;旧登记在换地图 / 移除图层时 `.unload()` 并移出(不泄漏)
- `RendererLayerStateHook` 不再有 `onUpdateLayerArea` / `onUpdateLayerBlock`(计数 0),仍保留 `onChangeBackground` / `onResizeLayer` / `onUpdateLayer` 与类名
- `IMapRenderer`(`render/map/types.ts`)与任何数据端文件一字未动;`RendererLayerHook` 未被 `export`;`MapRenderer` 既有公共成员未增删改名
- `this.map` 为 `IGameMap | null`,其 `layerList` 为 `Set<IMapLayer>`(`data-base/src/map/types.ts:625`),登记键类型同为 `IMapLayer`;未使用 `any` / `as` 掩盖类型不匹配
- 四条 `<automated>` 全部转绿(M-09 静态门禁、scoped 类型门禁、eslint、CRLF);本任务改动已以 `fix(04-05): forward layer area/block updates via IMapLayerHooks` 原子提交(仅 `renderer.ts`)
</acceptance_criteria>
<done>逐图层钩子 `RendererLayerHook` 就位并经 `layer.addHook` 在图层状态变更路径注册,`onUpdateArea` / `onUpdateBlock` 正确转发到 `updateLayerArea` / `updateLayerBlock`,map 级旧注册清零、map 级职责保留、契约面零变更;四条门禁转绿并完成本文件的原子提交</done>
</task>
<task type="auto">
<name>Task 3:终验门禁(范围 / 并发基线 / 合并静态门禁 / 类型 / eslint / CRLF)+ 人工复核 + `04-05-SUMMARY.md` 定稿</name>
<files>.planning/phases/04-render-adaptation/04-05-SUMMARY.md</files>
<read_first>
- .planning/phases/04-render-adaptation/04-05-SUMMARY.md(Task 1 写入的执行前核对 / 并发基线 / 类型基线三段,以及待填门禁输出节)
- .planning/phases/04-render-adaptation/04-05-PLAN.md(本任务的全部门禁定义与成功标准)
- .planning/phases/04-render-adaptation/04-RENDER-INTERFACE-AUDIT.md(`#04-01-M-06` / `#04-01-M-09` 的原文,用于 SUMMARY 的处置对照)
- .planning/phases/04-render-adaptation/04-04-SUMMARY.md(SUMMARY 结构先例:改动清单 / 处置对照 / 执行前核对 / 基线 / 门禁摘要 / 人工复核 / 未实现项 / 需求 Pending 声明)
- .planning/phases/04-render-adaptation/04-CONTEXT.md(D-11 / D-12 / D-21 / D-23 的裁定原文,用于说明 M-06 使用 `client` 的例外)
</read_first>
<action>
(1) **范围门禁(基线感知,先跑,防止越界改动)**:把当前 `git status --porcelain` 的**生产路径**(`^packages(-user)?/`)与 SUMMARY 中 `CONCURRENT-BASELINE` 段记录的基线条目做差集;只有**基线之外**、且不属于 `render/ui/main.tsx` / `render/map/renderer.ts` 两个在范围文件的生产改动才算越界。特别断言 `action/move.ts`(D-19)、`render/ui/statistics.tsx`(M-07)、`render/map/types.ts`(`IMapRenderer`)、`client-base/**`、`data-common/**`、`data-base/**`、`data-system/**`、`data-state/**`、`packages/**`、`src/**` 均无基线之外的新改动。若出现基线之外的第三个生产文件改动:停止、汇报、修订计划(不得自行扩面)。
(2) **并发改动基线门禁**:用与 Task 1 步骤 (0b) 完全相同的固定命令重算(排除两个在范围文件与 `.planning/phases/04-render-adaptation/` 前缀),断言 porcelain 条目集合与内容摘要 `sha256` 均与 SUMMARY 的 `CONCURRENT-BASELINE` 段记录一致。
(3) **合并静态门禁**:把 Task 1 与 Task 2 的静态断言合并复跑一次(M-06:`getHeroStatusOn` 计数 0 / `client.hero.attribute.getFinalAttribute` 九键齐 / `state` import 在 / `@user/data-state` import 上方无 `@ts-expect-error` 且全文件计数为 1(保留 `main.tsx:247` 的 M-08 指令)/ `core.*` 与 `state.maps` 仍在;M-09:`RendererLayerHook` 声明与转发、`layer.addHook(new RendererLayerHook(`、`onUpdateLayerArea` / `onUpdateLayerBlock` 计数 0、`RendererLayerStateHook` 三成员在、`syncLayerHooks` 在两条路径、`.unload()` 在)。若 Task 0 对拟名有改动,本门禁须使用用户批准的名字(并在此处与 SUMMARY 中记录改名)。
(4) **复跑类型 / eslint / CRLF**:scoped 类型门禁(两个在范围文件 0 条 + `client-modules` 错误集 == `TYPE-BASELINE`)、`pnpm exec eslint` 对两个文件 0 错误、CRLF 门禁(两个源文件)。
(5) **确认两个原子提交存在**:`git log --oneline -5` 应能看到 `fix(04-05): read hero status via client attribute API` 与 `fix(04-05): forward layer area/block updates via IMapLayerHooks`;若因执行环境未按任务提交,把两个改动合并为一个 `fix(04-05): close M-06 and M-09 render interface mismatches` 提交(仅两个在范围文件),并在 SUMMARY 记录该偏差。
(6) **人工复核(写进 SUMMARY 的「人工复核」段,逐条给结论)**:① M-06 的 9 处逐条对位(`atk`/`hp`/`hpmax`/`mana`/`manamax`/`def`/`mdef`/`money`/`exp` → `client.hero.attribute.getFinalAttribute`),并说明语义:读的是**最终属性**(含 Buff / 装备加成),与旧 `getHeroStatusOn` 的「状态栏显示值」定位一致(`data-base/src/hero/types.ts:102-106`);② `:28` 指令移除的必要性(TS2578 实测依据)与 `state` import 保留的理由(`:248` 属 M-08);③ M-06 的范围外零改动(`core.*` 与 `:248` 的 `state.maps` 逐条确认未动);④ M-09 的转发链路:`MapLayer.setBlock` → `onUpdateBlock` → `RendererLayerHook.onUpdateBlock` → `updateLayerBlock` → `vertex.updateBlock`,以及 `MapLayer.putMapData` → `onUpdateArea` → `updateArea` → `vertex.updateArea`;⑤ M-09 的注册路径:`setLayerState` 与 `updateLayerList` 两处同步,说明 `GameMap.addLayer` / `removeLayer` 经 map 级 `onUpdateLayer` 进来后新图层会被补注册、被移除图层会被 `.unload()`;⑥ `RendererLayerStateHook` 保留三个 map 级成员、类名不变;⑦ 契约面零变更(`IMapRenderer` / 任何数据端文件未动;`RendererLayerHook` 未导出;公共成员无增删改名);⑧ 拟名落地情况(`RendererLayerHook` 已批准;`renderer` / `layer` / `layerHooks` / `syncLayerHooks` 是否按 Task 0 批准或改名);⑨ **运行时 UAT 不可得**的理由已写明(渲染端无测试设施;`client.ts:151-155` 的 `addHero` 接线仍为注释、`MapExtensionManager.addHero` 无调用点,故状态栏数值与地图顶点刷新均无法在运行时观察)。
(7) **SUMMARY 定稿**:写 `.planning/phases/04-render-adaptation/04-05-SUMMARY.md`,含:改动两个在范围文件的清单与职责;`#04-01-M-06` / `#04-01-M-09` 的逐条处置对照表(含 M-06 的 9 键映射、`@ts-expect-error` 移除依据、M-09 的钩子契约迁移与注册路径、拟名落地结果);执行前核对结论((0a) 六条,带 `file:line`);执行前基线(`CONCURRENT-BASELINE` 段 + `基线摘要 sha256`)与类型门禁基线(`TYPE-BASELINE` 段,逐行原样);全部机器门禁的实际输出摘要;人工复核九条;**M-06 使用 `client` 对 D-23 的例外说明**(含用户原话);未实现 / 显式延后项清单(其余 ① 错配 `#04-01-M-01` / `M-07` / `M-08`、全部 ③ 多余旧路径、`client.ts` 接线、贴图 / 不透明度 D-18、跟随者 D-22、双布局 REND-02);以及 **REND-01 / REND-02 仍为 Pending** 与运行时 UAT 不可得的明确声明。全文 CRLF。
(8) **提交 SUMMARY**:`docs(04-05): record execution state`,仅含 `04-05-SUMMARY.md`。
(9) **不做的事**:不接线 `client.ts`、不改 `action/move.ts` / `statistics.tsx` / `render/map/types.ts` / 任何数据端文件、不新增测试文件(渲染端无测试设施)、不修任何 ③ 多余旧路径或其它 ① 错配。
</action>
<verify>
<automated>node -e "const fs=require('fs'),{execSync}=require('child_process');let out;try{out=execSync('git status --porcelain',{encoding:'utf8'})}catch(e){console.error('GIT STATUS FAILED '+e.message);process.exit(1)}const allow=['packages-user/client-modules/src/render/ui/main.tsx','packages-user/client-modules/src/render/map/renderer.ts'];const t=fs.readFileSync('.planning/phases/04-render-adaptation/04-05-SUMMARY.md','utf8');const bm=t.match(/<!-- CONCURRENT-BASELINE:START -->([\s\S]*?)<!-- CONCURRENT-BASELINE:END -->/);if(!bm){console.error('NO CONCURRENT BASELINE');process.exit(1)}const base=bm[1].split(/\r?\n/).map(s=>s.replace(/\s+$/,'')).filter(Boolean).map(s=>s.slice(3).replace(/^\"|\"$/g,''));const lines=out.split(/\r?\n/).filter(l=>l.trim().length>0);const prod=lines.map(l=>l.slice(3).replace(/^\"|\"$/g,'')).filter(p=>/^packages(-user)?\//.test(p));const extra=prod.filter(p=>allow.indexOf(p)<0&&base.indexOf(p)<0);if(extra.length){console.error('OUT-OF-SCOPE FILE MODIFIED '+extra.join(' ; '));process.exit(1)}console.log('OK scope (baseline-aware): out-of-scope extra 0')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "OUT-OF-SCOPE FILE MODIFIED"(出现 `files_modified` 之外、且不在并发基线内的生产文件改动——含 `action/move.ts`、`statistics.tsx`、`render/map/types.ts`、任何数据端文件、`packages/**`、`src/**`;基线内的用户并发改动不触发)/ "GIT STATUS FAILED" / "NO CONCURRENT BASELINE"</fails_when>
<automated>node -e "const fs=require('fs'),c=require('crypto'),{execSync}=require('child_process');const keep=l=>!/main\.tsx$/.test(l)&&!/map[\\/]renderer\.ts$/.test(l)&&!/\.planning\/phases\/04-render-adaptation\//.test(l);const out=execSync('git status --porcelain',{encoding:'utf8'}).replace(/\s+$/,'');const cur=(out?out.split(/\r?\n/).map(s=>s.replace(/\s+$/,'')):[]).filter(keep);const digest=c.createHash('sha256').update(cur.slice().sort().map(l=>{let h='MISSING';try{h=c.createHash('sha256').update(fs.readFileSync(l.slice(3))).digest('hex')}catch(e){}return l+' '+h}).join('\n')).digest('hex');const t=fs.readFileSync('.planning/phases/04-render-adaptation/04-05-SUMMARY.md','utf8');const bm=t.match(/<!-- CONCURRENT-BASELINE:START -->([\s\S]*?)<!-- CONCURRENT-BASELINE:END -->/);if(!bm){console.error('NO CONCURRENT BASELINE');process.exit(1)}const rec=bm[1].split(/\r?\n/).map(s=>s.replace(/\s+$/,'')).filter(Boolean).filter(keep);const dm=t.match(/基线摘要[^\r\n]*?sha256=([0-9a-f]{64})/);if(!dm){console.error('NO BASELINE DIGEST');process.exit(1)}const extra=cur.filter(l=>rec.indexOf(l)<0);const gone=rec.filter(l=>cur.indexOf(l)<0);if(extra.length||gone.length){console.error('BASELINE ENTRY SET CHANGED extra=['+extra.join(' ; ')+'] missing=['+gone.join(' ; ')+']');process.exit(1)}if(dm[1]!==digest){console.error('BASELINE DIGEST CHANGED '+dm[1]+' vs '+digest);process.exit(1)}console.log('OK user concurrent edits unchanged entries '+cur.length)"</automated>
<fails_when>non-zero exit, 或 stderr 含 "NO BASELINE DIGEST" / "BASELINE ENTRY SET CHANGED" / "BASELINE DIGEST CHANGED"(用户并发的未提交改动被本计划回滚 / 暂存 / 提交 / 修改)</fails_when>
<automated>node -e "const fs=require('fs');const A='packages-user/client-modules/src/render/ui/main.tsx';const B='packages-user/client-modules/src/render/map/renderer.ts';const a=fs.readFileSync(A,'utf8');const b=fs.readFileSync(B,'utf8');const fail=m=>{console.error('FINAL '+m);process.exit(1)};if((a.match(/getHeroStatusOn/g)||[]).length!==0)fail('M06 getHeroStatusOn REMAINS');const calls=(a.match(/getFinalAttribute\([^)]*\)/g)||[]).map(x=>x.replace(/getFinalAttribute/g,'').replace(/[^A-Za-z]/g,''));const want=['atk','def','exp','hp','hpmax','mana','manamax','mdef','money'].join(',');if(calls.slice().sort().join(',')!==want)fail('M06 ATTR SET MISMATCH');if(!a.includes('{ state } from')||!a.includes('@user/data-state'))fail('M06 state IMPORT LOST');const aidx=a.indexOf('@user/data-state');if(aidx<0)fail('M06 data-state IMPORT NOT FOUND');if(/ts-expect-error/.test(a.slice(Math.max(0,aidx-400),aidx)))fail('M06 DIRECTIVE ABOVE data-state IMPORT REMAINS');const ate=(a.match(/ts-expect-error/g)||[]).length;if(ate!==1)fail('M06 ts-expect-error COUNT '+ate+' (expected exactly 1 = the preserved M-08 directive)');if(!/ts-expect-error[^\r\n]*\r?\n\s*layerState=\{state\.maps\}/.test(a))fail('M06 M08 DIRECTIVE/state.maps PAIRING LOST');if(!a.includes('core.status')||!a.includes('core.itemCount')||!a.includes('state.maps'))fail('M06 OUT-OF-SCOPE core/state EDITS');if(!/class RendererLayerHook implements Partial<IMapLayerHooks>/.test(b))fail('M09 RendererLayerHook DECL MISSING');if(!/\.addHook\(new RendererLayerHook\(/.test(b))fail('M09 LAYER HOOK NOT REGISTERED');if(!/updateLayerArea\(this\.layer/.test(b)||!/updateLayerBlock\(this\.layer/.test(b))fail('M09 FORWARDING WRONG');if((b.match(/onUpdateLayerArea|onUpdateLayerBlock/g)||[]).length!==0)fail('M09 LEGACY MAP-LEVEL HOOKS REMAIN');if(!b.includes('onChangeBackground')||!b.includes('onResizeLayer')||!b.includes('onUpdateLayer'))fail('M09 RendererLayerStateHook MEMBER LOST');if(!b.includes('syncLayerHooks'))fail('M09 SYNC ENTRY MISSING (or renamed - substitute approved name)');if(/export .*RendererLayerHook/.test(b))fail('M09 RendererLayerHook MUST NOT BE EXPORTED');console.log('OK final M06 + M09 static gates')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "FINAL M06 ..." / "FINAL M09 ..."(任一 M-06 / M-09 静态断言未过;含 `syncLayerHooks` 改名后未同步替换门禁字符串的情况)</fails_when>
<automated>node -e "const fs=require('fs'),{execSync}=require('child_process');let out='',status=null;try{out=execSync('pnpm exec vue-tsc --noEmit',{encoding:'utf8',maxBuffer:1<<26,stdio:['ignore','pipe','pipe']});status=0}catch(e){out=String(e.stdout||'')+String(e.stderr||'');status=(typeof e.status==='number')?e.status:null}if(status===null){console.error('TYPE CHECK DID NOT RUN (vue-tsc spawn failed): '+out.slice(0,200));process.exit(1)}const lines=out.split(/\r?\n/).filter(l=>/: error TS\d+/.test(l));if(lines.length===0&&status!==0){console.error('TYPE CHECK EXITED '+status+' WITH NO PARSEABLE DIAGNOSTICS');process.exit(1)}const scope=lines.filter(l=>/render[\\/]ui[\\/]main\.tsx\(\d+,\d+\)|render[\\/]map[\\/]renderer\.ts\(\d+,\d+\)/.test(l));if(scope.length){console.error('IN-SCOPE TYPE ERRORS '+scope.length+'\n'+scope.join('\n'));process.exit(1)}const norm=l=>{const m=l.match(/client-modules[\\/]src[\\/](.+)$/);return m?m[1].replace(/\s+$/,''):null};const cur=lines.map(norm).filter(Boolean).sort();const t=fs.readFileSync('.planning/phases/04-render-adaptation/04-05-SUMMARY.md','utf8');const bm=t.match(/<!-- TYPE-BASELINE:START -->([\s\S]*?)<!-- TYPE-BASELINE:END -->/);if(!bm){console.error('NO TYPE BASELINE');process.exit(1)}const base=bm[1].split(/\r?\n/).map(s=>s.trim()).filter(Boolean).sort();const extra=cur.filter(x=>base.indexOf(x)<0);const missing=base.filter(x=>cur.indexOf(x)<0);if(extra.length||missing.length){console.error('CLIENT-MODULES ERROR SET CHANGED missing=['+missing.join(' ; ')+'] extra=['+extra.join(' ; ')+']');process.exit(1)}console.log('OK final type gate: in-scope 0; client-modules baseline '+cur.length)"</automated>
<fails_when>non-zero exit, 或 stderr 含 "TYPE CHECK DID NOT RUN"(`vue-tsc` 未跑起来——spawn 失败,空输出不得判通过)/ "TYPE CHECK EXITED"(非零退出且无可解析诊断)/ "IN-SCOPE TYPE ERRORS" / "CLIENT-MODULES ERROR SET CHANGED" / "NO TYPE BASELINE"(终验类型门禁未过)</fails_when>
<automated>pnpm exec eslint packages-user/client-modules/src/render/ui/main.tsx packages-user/client-modules/src/render/map/renderer.ts</automated>
<fails_when>non-zero exit 或输出含 "error"</fails_when>
<automated>node -e "const fs=require('fs');const FS=['packages-user/client-modules/src/render/ui/main.tsx','packages-user/client-modules/src/render/map/renderer.ts'];for(const f of FS){const s=fs.readFileSync(f,'utf8');const lf=(s.match(/\n/g)||[]).length;const crlf=(s.match(/\r\n/g)||[]).length;if(lf!==crlf){console.error('NON-CRLF LINE ENDINGS '+f+' lone-lf='+(lf-crlf));process.exit(1)}}console.log('OK CRLF source files')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "NON-CRLF LINE ENDINGS"(两个源文件出现孤立 LF,违反 dev.md「换行使用 CRLF 格式」/ `.prettierrc` 的 `endOfLine: crlf`;SUMMARY 不在本机器门禁范围——工具写出的 markdown 不对其施加此硬门禁,改由人工复核遵循仓库 markdown 惯例)</fails_when>
<automated>node -e "const fs=require('fs');const t=fs.readFileSync('.planning/phases/04-render-adaptation/04-05-SUMMARY.md','utf8');const need=['执行前核对','CONCURRENT-BASELINE:START','TYPE-BASELINE:START','人工复核','REND-01','REND-02','Pending','getHeroStatusOn','getFinalAttribute','ts-expect-error','client','RendererLayerHook','IMapLayerHooks','syncLayerHooks','updateLayerArea','updateLayerBlock','unload','D-23','M-06','M-09'];const miss=need.filter(x=>!t.includes(x));if(miss.length){console.error('SUMMARY MISSING SECTIONS '+miss.join(','));process.exit(1)}console.log('OK summary completeness')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "SUMMARY MISSING SECTIONS"(SUMMARY 缺少执行前核对 / 基线段 / 人工复核 / 需求 Pending 声明 / 运行时不可得说明 / M-06 与 M-09 关键项 / `client` 与 D-23 例外说明)</fails_when>
</verify>
<acceptance_criteria>
- 范围门禁(基线感知)证明基线之外无第三个生产文件改动(尤其 `action/move.ts`、`statistics.tsx`、`render/map/types.ts`、任何数据端文件、`packages/**`、`src/**` 均未动)
- 并发改动基线门禁证明用户并发的未提交改动条目集合与内容摘要 `sha256` 与 Task 1 基线完全一致(未被回滚 / 暂存 / 提交 / 修改)
- 合并静态门禁、scoped 类型门禁、eslint、CRLF 全部转绿;两个在范围文件类型诊断 0 条且 `client-modules` 错误集 == `TYPE-BASELINE`
- 两个原子提交存在(或按 (5) 的合并提交,并在 SUMMARY 记录偏差)
- `04-05-SUMMARY.md` 含:改动清单与职责、`#04-01-M-06` / `#04-01-M-09` 处置对照、执行前核对六条(带 `file:line`)、`CONCURRENT-BASELINE` 段 + `基线摘要 sha256`、`TYPE-BASELINE` 段、门禁输出摘要、人工复核九条、M-06 使用 `client` 对 D-23 的例外说明(含用户原话)、未实现 / 延后项清单、REND-01 / REND-02 仍 Pending 与运行时 UAT 不可得的明确说明;并以 `docs(04-05)` 提交(仅含该 SUMMARY)
- 未新增任何测试文件(渲染端无测试设施),且该降级已在 SUMMARY 中如实说明
</acceptance_criteria>
<done>七类机器门禁全绿、用户并发改动零变化、范围无越界、两个原子提交就位、SUMMARY 定稿并按 `docs(04-05)` 提交;用户可据此继续裁决后续渲染适配增量(`#04-01-M-01` / `M-07` / `M-08`、③ 多余旧路径、接线启用、双布局 REND-02)</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 数据端属性接口 → 渲染端状态栏 | `IHeroState.attribute.getFinalAttribute` 是数据端只读契约;渲染端把它显示到状态栏。误用可变属性对象(`getModifiableAttribute`)或在渲染端改属性会破坏「渲染完全被动」(D-20)的双端分离,本计划只读 `getFinalAttribute` |
| 数据端图层钩子 → 渲染顶点数组 | `IMapLayerHooks.onUpdateArea` / `onUpdateBlock` 是数据端通知渲染端「图块区域变了」的唯一现行通道;渲染端若继续挂在错误的 map 级契约上,顶点数组永不刷新(静默失效),画面与数据不一致 |
| 渲染端契约面 → 下游调用方 | `IMapRenderer` 是 `client-base` / `client-modules` 其它模块消费的公共契约;本计划**零契约变更**,误改 `render/map/types.ts` 或误导出 `RendererLayerHook` 会扩大破坏面 |
| 数据端接口 → 渲染端绑定 | 渲染端只读 import 数据端类型并读取 `client.hero.attribute` / 注册逐图层钩子;本计划不改数据端,接口一旦被用户并发改动,适配即按错误形状落地(D-05) |
| 执行者/子代理 → 执行前置关卡 | AGENTS.md 要求第一个 block 点先汇报并等待用户回复「可以执行」;`yolo` / `auto_advance` 或子代理派发都不是放行理由(Task 0 为 `blocking-human`、`autonomous: false`) |
| 执行者 → 仓库工作树 | 用户会并发手工重构数据端;本计划只允许改两个渲染端文件,且不得触碰用户的未提交改动 |
| 未接线状态 → 验收结论 | `client.ts` 的 `addHero` 接线仍为注释、渲染端无测试设施,运行时渲染不可观察;若不显式声明,「编译通过」会被误读为「状态栏与地图刷新已可用」 |
| 既有注释 → 修改授权 | `main.tsx:28` 的编译器指令必须移除才能不新增诊断,但 AGENTS.md 禁止擅自修改既有注释;该例外只在 Task 0 获用户批准后方可执行 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-04-05-01 | Tampering | `render/map/types.ts` 的 `IMapRenderer` 与数据端 `IMapLayerHooks` / `IGameMapHooks` 契约 | high | mitigate | 契约面零变更:`renderer.ts` 只在模块内新增不导出的 `RendererLayerHook`;范围门禁(基线感知)只放行 `main.tsx` / `renderer.ts`,任何对 `render/map/types.ts` 或数据端文件的改动立即判越界;M-09 静态门禁断言 `RendererLayerHook` 未被 `export`;prohibitions 明列不得新增 / 删除公共接口成员 |
| T-04-05-02 | Repudiation | `@ts-expect-error` 指令移除与 9 处属性读写的可追溯性 | medium | mitigate | 指令移除的必要性由 TS2578 实测记录支撑,并在 Task 0 汇报中单列待用户批准;9 处逐条映射(键名 → `getFinalAttribute`)与拟名落地结果写入 SUMMARY 人工复核段;M-06 静态门禁断言指令清零、`state` import 保留、`core.*` 未被顺带迁移 |
| T-04-05-03 | Elevation of Privilege | 执行前置关卡(AGENTS.md 要求的第一处 block 点) | high | mitigate | Task 0 为 `checkpoint:decision` + `gate="blocking-human"` 且本计划 `autonomous: false`;Task 0 的 action ⑧/⑨ 与 acceptance_criteria 明示未收到「可以执行」四个字即必须停止、不派发执行子代理 |
| T-04-05-04 | Tampering | 用户并发的未提交改动(`packages-user/data-state/src/types.ts`、`src/content/*.jsonc`) | high | mitigate | Task 1 步骤 (0b) 记录 porcelain 条目 + 各文件内容 `sha256` 摘要(写入 SUMMARY 的 `CONCURRENT-BASELINE` 段);Task 3 用同一固定命令复算并断言集合与摘要未变;prohibitions 明确不得回滚 / 暂存 / 提交 / 修改 |
| T-04-05-05 | Spoofing | 「编译通过」被当作「运行时可用」 | medium | mitigate | 运行时 UAT 不可得(渲染端无测试设施、`addHero` 未被接线)作为 truths/assumptions 明写;Task 3 的 SUMMARY 完整性门禁要求出现 `client` / `Pending` / `getFinalAttribute` 等字样与运行时不可得说明;严禁以不可运行的门禁冒充通过 |
| T-04-05-06 | Elevation of Privilege | 范围蔓延(`#04-01-M-01` / `M-07` / `M-08`、③ 多余旧路径 `core.*`、贴图 D-18、跟随者 D-22、`action/move.ts` D-19、`client.ts` 接线) | medium | mitigate | 范围门禁(基线感知)只放行两个在范围文件;M-06 静态门禁断言 `core.*` 与 `state.maps` 仍在(防顺手迁移);prohibitions 逐条列明;需要第三个文件时强制停下汇报并修订计划 |
| T-04-05-07 | Denial of Service | 逐图层钩子登记泄漏(换地图 / 移除图层未 `unload`,钩子随图层累积) | medium | mitigate | `syncLayerHooks` 语义要求先注销不在 `layerList` 的登记再注册新图层;M-09 静态门禁断言 `.unload()` 存在且同步入口同时出现在 `setLayerState` 与 `updateLayerList`;人工复核第 ⑤ 条专门核对增删图层路径。**既有边界(非本计划引入)**:`MapRenderer.destroy()`(`renderer.ts:826`)目前既不注销 `mapHook` 也不注销逐图层钩子;因 `MapRenderer` 生命周期与 GL 上下文同寿、单例 `client` 不再重建,该缺口不构成新增泄漏面,本计划**不**顺手改 `destroy()`(保持与既有 `mapHook` 行为对称,改动超出 M-09 范围),仅在此登记 |
| T-04-05-08 | Information Disclosure | 改动引用的接口名、文件行号与类型签名 | low | accept | 改动只含渲染端源码引用,不含凭据 / 密钥 / 用户数据;无需额外缓解 |
| T-04-05-SC | Tampering | npm / pnpm 依赖安装 | high | accept | 本计划零依赖变更、不修改 `package.json` / `pnpm-lock.yaml`(只用既有 `@user/data-base` / `@user/data-state` workspace 依赖),Package Legitimacy Gate 不触发;若执行中出现安装需求,立即停止并汇报 |
</threat_model>
<verification>
<!-- plan-criteria-allow: R4 - 本计划判据区内的 `|` 全部是 node -e 脚本里 JS 源码的正则交替 / 对象分隔 / 字符串拼接,不是 shell 管道;脚本内 git 调用由 execSync 的 try/catch 捕获并以非零退出失败,不会把失败读成通过。 -->
<!-- planner-discipline-allow: getHeroStatusOn -->
<!-- planner-discipline-allow: onUpdateLayerArea -->
<!-- planner-discipline-allow: onUpdateLayerBlock -->
<!-- planner-discipline-allow: ts-expect-error -->
0. 执行前置关卡(Task 0,AGENTS.md 第一个 block 点):向用户汇报「要解决的问题(`#04-01-M-06` 与 `#04-01-M-09`)/ 大致解决方案(两个文件的改法)/ 成员命名清单(唯一已批准名 `RendererLayerHook`;拟名 `renderer` / `layer` / `layerHooks` / `syncLayerHooks` 待批)/ **必须移除 `main.tsx:28` 的 `@ts-expect-error` 指令**(含 TS2578 实测依据)/ **M-06 使用 `client` 对 D-23 的例外**(含用户原话)/ 范围边界与禁令 / 验证方式的诚实降级」后**停下等待**用户明确回复「可以执行」四个字;未收到前不得执行任何任务、不得派发执行子代理,任何触发方式与 `yolo` / `auto_advance` 模式均不得自动放行(人工核对:用户回复原文)。
1. 执行前核对与基线(Task 1 步骤 0):数据端现行接口复核结论((0a) 六条,含 `IHeroAttr` 九键、`getFinalAttribute` 签名、`CoreState.hero` 赋值、`state` barrel 导出、`ILeftHeroStatus` 字段类型、`ClientCore extends CoreState`,带 `file:line`)写入 SUMMARY;并发改动基线(`CONCURRENT-BASELINE` 段 + `基线摘要 sha256`)与类型门禁基线(`TYPE-BASELINE` 段,逐行原样)写入 SUMMARY;任一与规划不一致即停止并修订计划;**不得沿用任何前序 SUMMARY 的旧并发列表**。
2. scoped 类型门禁(Task 1 / 2 / 3):`pnpm exec vue-tsc --noEmit` 过滤到 `render/ui/main.tsx` 与 `render/map/renderer.ts` 必须 0 条诊断;`client-modules` 错误集必须与 SUMMARY 的 `TYPE-BASELINE` 完全一致(集合比较,顺序无关)。整仓其余既有诊断不属本计划,不得以「整仓非 0 退出」判定失败,也不得漏判两个在范围文件的新诊断(尤其是删除符号后遗留指令所引发的 TS2578)。
3. M-06 静态门禁(Task 1 / 3):`main.tsx` 中 `getHeroStatusOn` 计数 0;`getFinalAttribute(...)` 的参数键集合恰为 `atk, def, exp, hp, hpmax, mana, manamax, mdef, money`(9 个);`@user/data-state` 与 `{ state } from` 仍在;`@user/data-state` import 上方无 `ts-expect-error`,且全文件计数为 1(`main.tsx:247` 的 M-08 指令保留、`:28` 已移除);`core.status` / `core.itemCount` / `state.maps` 仍在(正向保留断言,防越界迁移)。
4. M-09 静态门禁(Task 2 / 3):`renderer.ts` 存在 `class RendererLayerHook implements Partial<IMapLayerHooks>`(未导出);存在 `.addHook(new RendererLayerHook(`;存在转发 `updateLayerArea(this.layer` 与 `updateLayerBlock(this.layer`;`onUpdateLayerArea` / `onUpdateLayerBlock` 计数 0;`onChangeBackground` / `onResizeLayer` / `onUpdateLayer` 三成员仍在;同步入口出现在 `setLayerState` 与 `updateLayerList` 两个方法体内;存在 `.unload()`;`IMapLayerHooks` 与 `IMapLayerHookController` 已 import。
5. 并发改动基线门禁(Task 3):排除两个在范围文件与 `.planning/phases/04-render-adaptation/` 后,porcelain 条目集合与内容 `sha256` 与 SUMMARY 的 `CONCURRENT-BASELINE` 段记录一致(用户改动未被回滚 / 暂存 / 提交 / 修改)。
6. 范围门禁(Task 3,基线感知):将当前 `git status --porcelain` 的生产路径(`^packages(-user)?/`)与 `CONCURRENT-BASELINE` 段做差集;只有基线之外、且不属于 `main.tsx` / `renderer.ts` 的生产改动才判越界;`action/move.ts`、`statistics.tsx`、`render/map/types.ts`、数据端各包、`client-base`、`packages`、`src` 均无基线之外的新改动。
7. ESLint / CRLF 门禁(Task 1 / 2 / 3):`pnpm exec eslint` 对 `main.tsx` / `renderer.ts` 0 错误;两个源文件无孤立 LF(`.prettierrc` 的 `endOfLine: crlf`)。SUMMARY 不施加该硬门禁,改由人工复核遵循仓库 markdown 惯例。
8. 人工复核(Task 3):M-06 的 9 处逐条对位与语义说明(读最终属性与状态栏定位一致);指令移除的必要性与 `state` import 保留的理由;M-06 范围外零改动确认;M-09 的转发链路(`setBlock` → `onUpdateBlock` → `updateLayerBlock` → `vertex.updateBlock`;`putMapData` → `onUpdateArea` → `updateLayerArea` → `vertex.updateArea`);M-09 的注册路径(`setLayerState` / `updateLayerList` 两处同步与 `addLayer` / `removeLayer` 覆盖);`RendererLayerStateHook` 三成员保留;契约面零变更;拟名落地情况;运行时 UAT 不可得的理由已写明。
9. 提交(Task 1 / 2 / 3):`fix(04-05): read hero status via client attribute API`(仅 `main.tsx`)、`fix(04-05): forward layer area/block updates via IMapLayerHooks`(仅 `renderer.ts`)、`docs(04-05): record execution state`(仅 SUMMARY);用户并发改动未被纳入任何提交;若执行环境未按任务提交,按 Task 3 步骤 (5) 合并提交并在 SUMMARY 记录偏差。
</verification>
<success_criteria>
- **执行前置关卡(AGENTS.md):** 本计划第一个 block 点是 Task 0 —— 向用户汇报计划内容、两条错配的锚点与影响、成员命名清单(含唯一已批准名与四项待批拟名)、**必须移除 `main.tsx:28` 的 `@ts-expect-error` 指令**、**M-06 使用 `client` 对 D-23 的例外**后停下等待用户明确回复「可以执行」四个字;未收到前不执行任何任务、不派发执行子代理,任何触发方式与 `yolo` / `auto_advance` 模式均不得自动放行。
- **`#04-01-M-06`:** `main.tsx` 的 `getHeroStatusOn` 彻底消失(import 与 9 处调用),9 个属性经 `client.hero.attribute.getFinalAttribute('<key>')` 读取且键集合为 `atk, hp, hpmax, mana, manamax, def, mdef, money, exp`;`:28` 的 `@ts-expect-error` 指令按用户批准移除;`state` import 保留;其余 `core.*` 与 `:248` 的 `state.maps` 一字未动。
- **`#04-01-M-09`:** `renderer.ts` 新增模块内不导出类 `RendererLayerHook`(`Partial<IMapLayerHooks>`),把 `onUpdateArea` / `onUpdateBlock` 转发到 `renderer.updateLayerArea(layer, ...)` / `renderer.updateLayerBlock(layer, ...)`;逐图层注册经 `layer.addHook(...)` 落在 `setLayerState` 与 `updateLayerList` 两条图层状态变更路径,换地图 / 移除图层时 `.unload()`;`RendererLayerStateHook` 的 `onUpdateLayerArea` / `onUpdateLayerBlock` 清零并保留 map 级三职责;类名不变。
- **契约面与范围:** 不新增 / 删除任何公共接口成员(`IMapRenderer`、数据端接口一律未动);`RendererLayerHook` 未被导出;不新增依赖;`action/move.ts`、`statistics.tsx`、`render/map/types.ts`、数据端各包、`client-base`、`client.ts` 接线、`packages/**`、`src/**` 均未被触碰;用户并发的未提交改动未被回滚 / 暂存 / 提交 / 修改。
- **验证诚实且可运行:** scoped 类型门禁(两个在范围文件 0 条、`client-modules` 错误集 == 基线)、M-06 / M-09 静态门禁、eslint / CRLF 门禁、范围门禁(基线感知)、并发改动基线门禁全部转绿且有实际输出;运行时渲染 UAT 不可得的原因(渲染端无测试设施、`addHero` 未被接线)在摘要中明写,未以任何不可运行的门禁冒充通过,未新增任何测试文件。
- REND-01 / REND-02 保持 Pending(两条 FLAGGED ASSUMPTION),用户可据此继续裁决后续增量(`#04-01-M-01` / `M-07` / `M-08`、③ 多余旧路径、接线启用、双布局 REND-02)。
## Artifacts this phase produces
| 类型 | 路径 | 内容 |
|------|------|------|
| 生产源码(修改) | `packages-user/client-modules/src/render/ui/main.tsx` | `updateStatus()` 的 9 个数值属性改经 `client.hero.attribute.getFinalAttribute(...)` 读取;`getHeroStatusOn` 与其 `:28` 的 `@ts-expect-error` 指令移除;`import { state } from '@user/data-state';` 保留;其余 `core.*` / `client.flags.*` / `:248` 的 `state.maps` 一字未动 |
| 生产源码(修改) | `packages-user/client-modules/src/render/map/renderer.ts` | 新增模块内不导出类 `RendererLayerHook`(`Partial<IMapLayerHooks>`)转发 `onUpdateArea` / `onUpdateBlock` 到 `updateLayerArea` / `updateLayerBlock`;`setLayerState` 与 `updateLayerList` 两条路径同步逐图层登记(`layer.addHook` + `.load()` / `.unload()`);`RendererLayerStateHook` 移除 `onUpdateLayerArea` / `onUpdateLayerBlock` 并保留 `onChangeBackground` / `onResizeLayer` / `onUpdateLayer` |
| 摘要 | `.planning/phases/04-render-adaptation/04-05-SUMMARY.md` | 执行前核对六条(带 `file:line`)、`CONCURRENT-BASELINE` 段 + `基线摘要 sha256`、`TYPE-BASELINE` 段、`#04-01-M-06` / `#04-01-M-09` 处置对照、门禁输出摘要、人工复核九条、M-06 使用 `client` 对 D-23 的例外说明、未实现项清单、REND-01 / REND-02 Pending 声明与运行时 UAT 不可得说明 |
</success_criteria>
<output>
Create `.planning/phases/04-render-adaptation/04-05-SUMMARY.md` when done
</output>

View File

@ -65,7 +65,8 @@ export class ClientCore extends CoreState implements IClientCore {
constructor() {
super({
loadStarter: new WebLoadStarter()
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder'
});
//#region Layer 4

View File

@ -32,8 +32,15 @@ export class EquipmentState<THero> implements IEquipmentState<THero> {
this.modifiers = [];
const equip = item.equip;
this.value = new Map(equip.value);
this.percentage = new Map(equip.percentage);
this.value = new Map(
Object.entries(equip.value) as [SelectKey<THero, number>, number][]
);
this.percentage = new Map(
Object.entries(equip.percentage) as [
SelectKey<THero, number>,
number
][]
);
if (buildModifier) {
this.rebuildModifiers();
@ -81,14 +88,14 @@ export class EquipmentState<THero> implements IEquipmentState<THero> {
const { value, percentage } = this.item.equip;
const valueDiff = new Map<SelectKey<THero, number>, number>();
for (const [name, equipValue] of this.value) {
const base = value.get(name);
const base = value[name];
if (base !== equipValue) {
valueDiff.set(name, equipValue);
}
}
const perDiff = new Map<SelectKey<THero, number>, number>();
for (const [name, equipPer] of this.percentage) {
const base = percentage.get(name);
const base = percentage[name];
if (base !== equipPer) {
perDiff.set(name, equipPer);
}
@ -134,11 +141,15 @@ export class EquipmentState<THero> implements IEquipmentState<THero> {
this.percentage.clear();
// 基准为装备原始定义,再叠加存档中的差异条目
for (const [name, value] of this.item.equip.value) {
this.value.set(name, value);
for (const [name, value] of Object.entries<number>(
this.item.equip.value
)) {
this.value.set(name as SelectKey<THero, number>, value);
}
for (const [name, value] of this.item.equip.percentage) {
this.percentage.set(name, value);
for (const [name, value] of Object.entries<number>(
this.item.equip.percentage
)) {
this.percentage.set(name as SelectKey<THero, number>, value);
}
// 差异内容

View File

@ -84,7 +84,7 @@ export class HeroItems<THero> implements IHeroItems<THero> {
if (raw.category === ItemCategory.Pick) {
for (let i = 0; i < count; i++) {
raw.effect.useEffect(raw);
raw.effect.useEffect?.(raw, this.state);
}
return;
}
@ -104,7 +104,8 @@ export class HeroItems<THero> implements IHeroItems<THero> {
map.delete(num);
}
} else if (count > 0) {
map.set(num, { id: raw.id, num: raw.num, raw, count });
const store = this.state.tileStore;
map.set(num, { id: store.id(raw.num)!, num: raw.num, raw, count });
}
}
@ -124,12 +125,13 @@ export class HeroItems<THero> implements IHeroItems<THero> {
return false;
}
if (!raw.effect.canUse(raw)) return false;
const can = raw.effect.canUse?.(raw, this.state) ?? true;
if (!can) return false;
const replay = this.state.replaySystem;
replay.array.add(ReplayCode.UseItem, [raw.num]);
raw.effect.useEffect(raw);
raw.effect.useEffect?.(raw, this.state);
if (raw.category === ItemCategory.Consumable) {
state.count--;
@ -167,8 +169,9 @@ export class HeroItems<THero> implements IHeroItems<THero> {
for (const save of saves) {
const raw = this.state.itemStore.getData(save.num);
if (!raw) continue;
const store = this.state.tileStore;
map.set(save.num, {
id: raw.id,
id: store.id(raw.num)!,
num: raw.num,
raw,
count: save.count

View File

@ -0,0 +1,23 @@
import { isNil } from 'lodash-es';
import { IEnemyRawData, IEnemyStore, ITileStore } from './types';
import { logger } from '@motajs/common';
export class EnemyStore<THero> implements IEnemyStore<THero> {
/** 以道具图块数字为键的原始道具定义表 */
private readonly dataMap: Map<number, IEnemyRawData<THero>> = new Map();
constructor(readonly tileStore: ITileStore) {}
addEnemy(enemy: IEnemyRawData<THero>): void {
if (this.dataMap.has(enemy.num)) {
logger.warn(181, enemy.num.toString(), 'enemy');
}
this.dataMap.set(enemy.num, enemy);
}
getEnemy(token: number | string): IEnemyRawData<THero> | null {
const num = this.tileStore.num(token);
if (isNil(num)) return null;
return this.dataMap.get(num) ?? null;
}
}

View File

@ -1,18 +1,27 @@
import { IItemRawData, IItemStore, ItemCategory } from './types';
import { isNil } from 'lodash-es';
import { IItemRawData, IItemStore, ItemCategory, ITileStore } from './types';
import { logger } from '@motajs/common';
export class ItemStore<THero> implements IItemStore<THero> {
/** 以道具图块数字为键的原始道具定义表 */
private readonly dataMap: Map<number, IItemRawData<THero>> = new Map();
getData(num: number): IItemRawData<THero> | null {
constructor(readonly tileStore: ITileStore) {}
addItem(data: IItemRawData<THero>): void {
if (this.dataMap.has(data.num)) {
logger.warn(181, data.num.toString(), 'item');
}
this.dataMap.set(data.num, data);
}
getData(token: number | string): IItemRawData<THero> | null {
const num = this.tileStore.num(token);
if (isNil(num)) return null;
return this.dataMap.get(num) ?? null;
}
getCategory(num: number): ItemCategory {
return this.dataMap.get(num)?.category ?? ItemCategory.Unknown;
}
addItem(data: IItemRawData<THero>): void {
this.dataMap.set(data.num, data);
}
}

View File

@ -129,25 +129,27 @@ export const enum ItemCategory {
Equipment
}
export interface IItemEffect<THero> {
export interface IItemEffect<THero, State = unknown> {
/**
* 道具使用事件内容对于 `Pick` 类型,会在拾取时触发;
* 对于 `Constant` 和 `Consumable` 类型,会在使用时触发。
*/
readonly useEvent: unknown;
readonly useEvent?: string;
/**
* 道具使用效果,使用道具时调用。对于 `Pick` 类型,会在拾取时触发;
* 对于 `Constant` 和 `Consumable` 类型,会在使用时触发。
* @param item 当前道具数据
* @param state 当前的数据端对象,即 `CoreState` 对象
*/
useEffect(item: IItemRawData<THero>): void;
useEffect?(item: IItemRawData<THero>, state: State): void;
/**
* 能否使用道具,仅对 `Constant` 和 `Consumable` 类型的道具生效
* @param item 当前道具数据
* @param state 当前的数据端对象,即 `CoreState` 对象
*/
canUse(item: IItemRawData<THero>): boolean;
canUse?(item: IItemRawData<THero>, state: State): boolean;
}
export interface IItemEquipData<THero> {
@ -156,9 +158,9 @@ export interface IItemEquipData<THero> {
/** 动画 id */
readonly animate: AnimationIds;
/** 数值加成 */
readonly value: Map<SelectKey<THero, number>, number>;
readonly value: Record<SelectKey<THero, number>, number>;
/** 百分比加成 */
readonly percentage: Map<SelectKey<THero, number>, number>;
readonly percentage: Record<SelectKey<THero, number>, number>;
/** 穿上装备事件 */
readonly loadEvent: unknown;
/** 脱下装备事件 */
@ -168,8 +170,6 @@ export interface IItemEquipData<THero> {
export interface IItemRawData<THero> {
/** 道具在地图上的图块数字 */
readonly num: number;
/** 道具的字符串标识符 */
readonly id: string;
/** 道具分类 */
readonly category: ItemCategory;
/** 道具显示名称 */
@ -188,10 +188,10 @@ export interface IItemRawData<THero> {
export interface IItemStore<THero> {
/**
* 获取指定图块数字对应的道具原始数据
* @param num 道具图块数字
* 获取指定图块数字或图块 id 对应的道具原始数据
* @param token 道具图块数字或图块 id
*/
getData(num: number): IItemRawData<THero> | null;
getData(token: number | string): IItemRawData<THero> | null;
/**
* 获取指定图块数字对应的道具分类
@ -307,3 +307,30 @@ export interface IGameEventStore {
}
//#endregion
//#region enemy
export interface IEnemyRawData<T> {
/** 怪物的图块数字 */
readonly num: number;
/** 怪物数据定义 */
readonly attribute: Readonly<T>;
/** 怪物拥有的特殊属性,键表示特殊属性数字,值表示特殊属性数值 */
readonly special: Record<number, any>;
}
export interface IEnemyStore<T> {
/**
* 添加怪物原始数据定义
* @param enemy 怪物原始数据
*/
addEnemy(enemy: IEnemyRawData<T>): void;
/**
* 根据图块数字或图块 id 获取怪物原始数据
* @param token 怪物的图块数字或图块 id
*/
getEnemy(token: number | string): IEnemyRawData<T> | null;
}
//#endregion

View File

@ -4,6 +4,8 @@ import { IGameEventStore, IItemStore, IMapStore, ITileStore } from './store';
import { IReplaySystem } from './replay';
export interface IEnemyAttr {
/** 怪物名称 */
name: string;
/** 怪物生命值 */
hp: number;
/** 怪物攻击力 */
@ -23,6 +25,8 @@ export interface IEnemyAttr {
export interface IHeroAttr {
/** 勇士名称 */
name: string;
/** 勇士等级 */
level: number;
/** 勇士生命值 */
hp: number;
/** 勇士生命值上限 */

View File

@ -122,7 +122,7 @@ export class CoreState implements ICoreState {
// 图块
this.tileStore = new TileStore();
// 道具
this.itemStore = new ItemStore<IHeroAttr>();
this.itemStore = new ItemStore<IHeroAttr>(this.tileStore);
// 地图
this.mapStore = new MapStore();
// 游戏事件

View File

@ -126,7 +126,7 @@ export function registerSpecials(manager: IEnemyManager<IEnemyAttr>): void {
defineCommonSerializableSpecial(7, 0, {
getSpecialName: () => '破甲',
getDescription: special =>
`战斗前,附加角色防御的${special.value || core.values.breakArmor}%作为伤害。`,
`战斗前,附加角色防御的${special.value}%作为伤害。`,
fromLegacyEnemy: enemy => enemy.breakArmor ?? 0
})
);
@ -137,7 +137,7 @@ export function registerSpecials(manager: IEnemyManager<IEnemyAttr>): void {
defineCommonSerializableSpecial(8, 0, {
getSpecialName: () => '反击',
getDescription: special =>
`战斗时,怪物每回合附加角色攻击的${special.value || core.values.counterAttack}%作为伤害,无视角色防御。`,
`战斗时,怪物每回合附加角色攻击的${special.value}%作为伤害,无视角色防御。`,
fromLegacyEnemy: enemy => enemy.counterAttack ?? 0
})
);
@ -148,7 +148,7 @@ export function registerSpecials(manager: IEnemyManager<IEnemyAttr>): void {
defineCommonSerializableSpecial(9, 0, {
getSpecialName: () => '净化',
getDescription: special =>
`战斗前,怪物附加角色护盾的${special.value || core.values.purify}倍作为伤害。`,
`战斗前,怪物附加角色护盾的${special.value}倍作为伤害。`,
fromLegacyEnemy: enemy => enemy.purify ?? 0
})
);

View File

@ -9,5 +9,6 @@ import { CoreState } from './core';
* 否则可能导致录像验证失败。
*/
export const state = new CoreState({
loadStarter: new WebLoadStarter()
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder'
});

View File

@ -30,6 +30,7 @@ export const DEFAULT_HERO_IMAGE: ImageIds = 'hero.png';
/** 勇士的初始属性,数值填多少目前都无所谓,因为最终会从旧样板读取,但是必须得填 */
export const HERO_DEFAULT_ATTRIBUTE: IHeroAttr = {
name: '',
level: 0,
hp: 1,
hpmax: 0,
atk: 0,

View File

@ -1,7 +1,9 @@
import { IMotaDataLoader, IStateBase } from '@user/data-base';
import { ILoadManager, ILoadTaskStarter } from '@motajs/loader';
import { IStateSystem } from '@user/data-system';
import { ISaveableContent } from '@user/data-common';
import { IHeroAttr, ISaveableContent } from '@user/data-common';
//#region 数据端主对象
export interface ICoreStateConfig {
/**
@ -9,6 +11,8 @@ export interface ICoreStateConfig {
* 例如网页端会使用 Web 加载器,而 node 端会使用专门的 node 加载器
*/
readonly loadStarter: ILoadTaskStarter;
/** 核心配置文件的 URL 路径,也就是 `core.jsonc` 的文件路径 */
readonly coreURL: string;
}
export interface ISaveableExecutor<T> {
@ -38,3 +42,82 @@ export interface ICoreState
executor: ISaveableExecutor<T>
): void;
}
//#endregion
//#region 数据端主配置
export interface ICoreStateContentConfig {
/** 地图文件夹路径 */
readonly mapsDir: string;
/** 需要加载的地图列表文件的文件路径 */
readonly mapListDir: string;
/** 怪物数据文件路径 */
readonly enemyDir: string;
/** 道具数据文件路径 */
readonly itemDir: string;
/** 图库数据文件路径 */
readonly tileDir: string;
}
export interface ICoreStateHeroInitConfig<T> {
/** 勇士的初始横坐标 */
readonly x: number;
/** 勇士的初始纵坐标 */
readonly y: number;
/** 勇士初始所在楼层 */
readonly floorId: string;
/** 勇士的初始装备栏 */
readonly equipSlots: string[];
/** 勇士的初始属性 */
readonly attribute: Readonly<T>;
}
export interface ICoreStateInitConfig {
/** 初始事件 id,游戏开始时将在执行完毕此事件后正式开始 */
readonly startEvent: string;
/** 勇士初始状态,包含起始位置、初始属性等 */
readonly hero: ICoreStateHeroInitConfig<IHeroAttr>;
}
export interface ICoreStateFlagConfig {
/** 楼层传送是否要求在楼梯边才能使用 */
readonly flyNearStair: boolean;
/** 楼层传送是否为平面塔模式,即传送时会传送至上一次离开此位置的位置而非楼梯口 */
readonly flyRecordLocation: boolean;
/** 道具在首次拾取时是否显示其文字说明 */
readonly itemHint: boolean;
/** 是否启用负伤机制,开启后怪物总伤害可以变为负值 */
readonly enableNegativeDamage: boolean;
/** 是否限制夹击最高伤害为两夹击怪物中伤害最高者 */
readonly betweenDamageMax: boolean;
/** 是否启用轻按机制,即可以按下空格来拾取玩家四周的物品 */
readonly enablePick: boolean;
/** 若在寻路时需要穿过楼梯,是否允许穿越 */
readonly ignoreChangeFLoor: boolean;
/** 若继续前进会导致勇士受到地图伤害而死亡,是否允许前进 */
readonly allowDeadZone: boolean;
/** 是否允许点击瞬移 */
readonly enableTeleport: boolean;
/** 是否开启录像折叠,这可以使走路的录像大幅减少,但可能导致某些机制出错,可查看文档详细了解 */
readonly enableRouteFolding: boolean;
/** 是否开启瞬移合并机制,开启后,连续瞬移会在录像中合并为单次瞬移,但可能导致某些机制出错 */
readonly enableTeleportMerge: boolean;
}
export interface ICoreStateCoreConfig {
/** 游戏的唯一标识符 */
readonly name: string;
/** 呈现给玩家的游戏名称 */
readonly title: string;
/** 游戏版本号,仅呈现作用,对游戏本身没有任何影响 */
readonly version: string;
/** 游戏数据配置 */
readonly content: ICoreStateContentConfig;
/** 游戏的初始状态数据 */
readonly init: ICoreStateInitConfig;
/** 游戏的全局设置 */
readonly flag: ICoreStateFlagConfig;
}
//#endregion

View File

@ -255,7 +255,7 @@
"178": "Save data with keys of '$1' are saved but not be loaded, is there some issue for it?",
"179": "Replay $1 received an out-of-range index $2, the current replay length is $3. The operation will be ignored.",
"180": "Specified face group does not exist, face group setting failed.",
"181": "",
"181": "Number $1 has been registered to $2 store, old one will be overridden.",
"182": "Cannot bind '$1' instance to '$2' since they belong to different CoreState instance.",
"183": "An in-map location is expected to begin map graph building, but got outside.",
"1001": "Event(setBlock): Unknown tile '$1' when setting block."

View File

@ -477,6 +477,12 @@ importers:
src:
dependencies:
'@user/data-common':
specifier: workspace:*
version: link:../packages-user/data-common
'@user/data-state':
specifier: workspace:*
version: link:../packages-user/data-state
'@user/entry-client':
specifier: workspace:*
version: link:../packages-user/entry-client

View File

@ -406,16 +406,14 @@ var data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d =
],
"flyNearStair": false,
"flyRecordPosition": true,
"itemFirstText": false,
"enableAddPoint": false,
"itemHint": true,
"enableNegativeDamage": false,
"betweenAttackMax": true,
"enableGentleClick": true,
"ignoreChangeFloor": true,
"canGoDeadZone": false,
"enableMoveDirectly": true,
"betweenDamageMax": true,
"enablePick": true,
"ignoreChangeFLoor": true,
"allowDeadZone": false,
"enableTeleport": true,
"enableRouteFolding": true,
"disableShopOnDamage": false,
"blurFg": true
"enableTeleportMerge": true
}
}

1
src/content/client.jsonc Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,47 @@
// Type: packages-user/data-state/src/types.ts ICoreStateCoreConfig
{
"name": "template",
"title": "魔塔样板",
"version": "Ver 2.C",
"content": {
"mapsDir": "content/maps",
"mapListDir": "content/maps/.list.jsonc",
"enemyDir": "content/enemy.jsonc",
"itemDir": "content/item.jsonc",
"tileDir": "content/tile.jsonc"
},
"init": {
"startEvent": "game-start",
"hero": {
"x": 6,
"y": 6,
"floorId": "MT0",
"equipSlots": ["武器", "盾牌"],
"attribute": {
"name": "阳光",
"level": 0,
"hp": 10000,
"atk": 100,
"def": 100,
"mdef": 0,
"mana": 0,
"manamax": 0,
"money": 0,
"exp": 0
}
}
},
"flag": {
"flyNearStair": false,
"flyRecordPosition": true,
"itemHint": true,
"enableNegativeDamage": false,
"betweenDamageMax": true,
"enablePick": true,
"ignoreChangeFLoor": true,
"allowDeadZone": false,
"enableTeleport": true,
"enableRouteFolding": true,
"enableTeleportMerge": true
}
}

View File

@ -0,0 +1,16 @@
// Type: packages-user/data-common/src/store/types IEnemyRawData
// "@required" means the property is required.
// For objects, system will compare every properties.
{
"num": "@required",
"attribute": {
"name": "新怪物",
"hp": 0,
"atk": 0,
"def": 0,
"money": 0,
"exp": 0,
"point": 0
},
"special": {}
}

View File

@ -0,0 +1,18 @@
// Type: packages-user/data-common/src/store/types IItemRawData
// "@required" means the property is required.
// For objects, system will compare every properties.
{
"num": "@required",
"category": 0,
"name": "新道具",
"text": "",
"hideInToolbox": false,
"equip": {
"slots": [],
"animate": null,
"value": {},
"percentage": {},
"loadEvent": null,
"unloadEvent": null
}
}

View File

@ -0,0 +1,10 @@
// Type: packages-user/data-common/src/store/types IMapRawData
// "@required" means the property is required.
// For objects, system will compare every properties.
{
"floorId": "@required",
"width": 13,
"map": {},
"layerAlias": {},
"events": {}
}

View File

@ -0,0 +1,15 @@
// Type: packages-user/data-common/src/store/types ITileRawData
// "@required" means the property is required.
// For objects, system will compare every properties.
{
"num": "@required",
"id": "@required",
"events": {},
"type": 0,
"eventPass": false,
"pass": {
"onlyEvents": true,
"outPass": 15,
"inPass": 15
}
}

View File

@ -1 +1,20 @@
{}
// Type: packages-user/data-common/src/store/types IEnemyRawData
[
{
"num": 100,
"attribute": {
"name": "绿头怪",
"hp": 100,
"atk": 25,
"def": 10,
"money": 5,
"exp": 5,
"point": 3
},
"special": {
"1": null,
"6": 10,
"9": 3
}
}
]

View File

@ -1,3 +1,11 @@
// Type: packages-user/data-common/src/store/types IItemRawData
// ItemEffect: ./item.ts
{}
[
{
"num": 200,
"category": 1,
"name": "怪物手册",
"text": "蕴含着魔塔中所有怪物的详细信息",
"hideInToolbox": false
}
]

View File

@ -0,0 +1,24 @@
type HeroAttr = import('@user/data-common').IHeroAttr;
type CoreState = import('@user/data-state').ICoreState;
type ItemEffect = import('@user/data-common').IItemEffect<HeroAttr, CoreState>;
// --- SYSTEM ITEM PREFIX --- //
export const item200: ItemEffect = {
canUse() {
return true;
}
};
// --- SYSTEM ITEM SPLIT --- //
export const item201: ItemEffect = {
canUse(item, state) {
return (
item.text.includes('test') &&
state.flags.getFieldValueDefaults('test', true)
);
}
};
// --- SYSTEM ITEM END --- //

View File

@ -0,0 +1,2 @@
// Type: string[], without `.jsonc` suffix
["MT0"]

View File

@ -1,2 +1,27 @@
// Type: packages-user/data-base/src/map/types.ts
{}
// Type: packages-user/data-common/src/store/types IMapRawData
{
"floorId": "MT0",
"width": 13,
"map": {
"0": [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
],
"10": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1
]
}
}

View File

@ -0,0 +1,2 @@
// Type: type define required
{}

View File

@ -1,2 +1,11 @@
// Type: packages-user/data-common/src/store/types ITileRawData
{}
[
{
"num": 1,
"id": "yellowWall",
"events": {},
"type": 2,
"eventPass": false,
"pass": { "onlyEvents": true, "inPass": 0, "outPass": 0 }
}
]

View File

@ -1,6 +1,8 @@
{
"name": "@user/main",
"dependencies": {
"@user/data-common": "workspace:*",
"@user/data-state": "workspace:*",
"@user/entry-client": "workspace:*",
"@user/entry-data": "workspace:*"
}