mirror of
https://github.com/motajs/template.git
synced 2026-10-31 13:50:17 +08:00
docs(09-34): add setting system and UI plan and execution summary (code uncommitted)
This commit is contained in:
parent
102963401a
commit
b94b4c3b41
@ -0,0 +1,48 @@
|
||||
{
|
||||
"head": "102963401afe8c8321167b2093bc9e15c8446922",
|
||||
"hashes": {
|
||||
"packages-user/client-base/src/index.ts": "3904bbe873a1fc1fe448529708dda15232271019",
|
||||
"packages-user/client-base/src/types.ts": "cdf7224dc39118665b00a9697c31e6f5ae7c4f84",
|
||||
"packages-user/client-base/src/hotkey/hotkey.ts": "9344fabb5d902ad18482ede6277513998c5db2b1",
|
||||
"packages-user/client-base/src/hotkey/types.ts": "d1cdf0c25530950a46bce801df63ed975665b451",
|
||||
"packages-user/client-base/src/components/index.ts": "5970bd87790983617689485114d9d2308fea9f1d",
|
||||
"packages-user/client-base/package.json": "3e9b20220568df6244bbbceadb16e5c3b2a3e874",
|
||||
"packages-user/data-common/src/save/types.ts": "06b37a622a5e26abb91cc6332c49ca65c6691204",
|
||||
"src/client/ui/index.ts": "4e3950598b1beee23753a5556d8e8c82eb21bc57",
|
||||
"src/client/ui/settings.tsx": "c5cca14cde8ba813ac4ef5e624e8b359470c472d",
|
||||
"packages/common/src/logger.json": "8ff7ecf8897899d6fa57dae8bc3a902542a77369"
|
||||
},
|
||||
"dirty": [
|
||||
"packages-user/client-base/src/index.ts",
|
||||
"packages-user/client-base/src/map/__test__/rendererGate.test.ts",
|
||||
"packages-user/client-base/src/map/moving.ts",
|
||||
"packages-user/client-base/src/map/renderer.ts",
|
||||
"packages-user/client-base/src/map/shader/map.frag",
|
||||
"packages-user/client-base/src/map/status.ts",
|
||||
"packages-user/client-base/src/map/types.ts",
|
||||
"packages-user/client-base/src/map/vertex.ts",
|
||||
"packages-user/data-common/src/store/types.ts",
|
||||
"src/client/ui/__test__/hotkeySettingsGate.test.ts",
|
||||
"src/client/ui/__test__/uiContainerGate.test.ts",
|
||||
"src/client/ui/book.tsx",
|
||||
"src/client/ui/bookDetail.tsx",
|
||||
"src/client/ui/choices.tsx",
|
||||
"src/client/ui/equipbox.tsx",
|
||||
"src/client/ui/hotkey.tsx",
|
||||
"src/client/ui/toolbar.tsx"
|
||||
],
|
||||
"typecheck": [],
|
||||
"typecheckTotal": 202,
|
||||
"keyAdaptation": {
|
||||
"file": "src/client/ui/__test__/keyAdaptationGate.test.ts",
|
||||
"failed": 6,
|
||||
"passed": 9,
|
||||
"total": 15
|
||||
},
|
||||
"uiContainer": {
|
||||
"file": "src/client/ui/__test__/uiContainerGate.test.ts",
|
||||
"failed": 7,
|
||||
"passed": 11,
|
||||
"total": 18
|
||||
}
|
||||
}
|
||||
548
.planning/phases/09-minimum-browser-runnable/09-34-PLAN.md
Normal file
548
.planning/phases/09-minimum-browser-runnable/09-34-PLAN.md
Normal file
@ -0,0 +1,548 @@
|
||||
---
|
||||
phase: 09-minimum-browser-runnable
|
||||
plan: 34
|
||||
type: execute
|
||||
# 29:沿用本阶段既有计划的波次编号,使本计划排在 09-01..09-33 之后(D-01 增量派发,计划累积)
|
||||
# depends_on 为空:本计划只在 client-base 的 setting 目录内新增实现,并新增一个未被任何入口引用的界面文件,
|
||||
# 不修改任何既有计划产出;运行在最后的波次只是「排期在后」,不是「依赖前序计划」
|
||||
wave: 29
|
||||
depends_on: []
|
||||
# 基线由 Task 1 在收到「可以执行」之后、写入任何代码之前写出(Task 0 只汇报、不写文件)
|
||||
# 范围告警(显式接受,见 OI-5):本计划共 11 个路径(8 源码 / 测试 + 本计划 + 基线 + 摘要)、估算 80k,不拆分。
|
||||
# 已按修订删除 factories.test.ts:工厂模块静态导入 controls,controls 静态导入 @motajs/render(导入期即需 DOM),
|
||||
# 因此工厂 / 预览改由源码形状闸门验证,不写运行期单元测试、不加 jsdom / DOM mock。
|
||||
files_modified:
|
||||
- packages-user/client-base/src/setting/types.ts
|
||||
- packages-user/client-base/src/setting/system.ts
|
||||
- packages-user/client-base/src/setting/factories.ts
|
||||
- packages-user/client-base/src/setting/controls.tsx
|
||||
- packages-user/client-base/src/setting/index.ts
|
||||
- packages-user/client-base/src/setting/__test__/system.test.ts
|
||||
- src/client/ui/setting.tsx
|
||||
- src/client/ui/__test__/settingPageGate.test.ts
|
||||
- .planning/phases/09-minimum-browser-runnable/09-34-PLAN.md
|
||||
- .planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json
|
||||
- .planning/phases/09-minimum-browser-runnable/09-34-SUMMARY.md
|
||||
autonomous: false
|
||||
requirements: []
|
||||
estimate:
|
||||
# 未校准估算(confidence: low),SUMMARY 须声明这一点。
|
||||
# 本计划做三件事:①client-base 的设置系统 + 四个设置项工厂 + 四个内置控件;②一个界面 SettingPage / SettingPageUI;
|
||||
# ③系统单元测试与界面 / 工厂 / 控件源码形状闸门。不做接线(不接渲染端主对象、不接入口、不进设置菜单、不注册存档)。
|
||||
# 主要成本在:逐文件落实现(约 600 行)、让「非响应式」的设置项通过 vue ref 驱动预览与控件重绘、
|
||||
# 控件拿不到 min/max/step(接口只有 setting + controller)时的闭包方案、两段测试与全部门禁。
|
||||
# tokens 取 raw 的 2 倍,与 09-19..09-33 的换算口径一致;仍属未校准。
|
||||
# tasks 只计 3 个执行任务(Task 1–3);Task 0 是 blocking-human 汇报关卡,不写文件,不计入估算。
|
||||
# 范围告警(12 文件 / 90k)已显式接受:修订后为 11 个路径 / 80k,仍高于推荐值,理由见 OI-5(不拆分)。
|
||||
tokens: 80000
|
||||
raw_tokens: 40000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "**本计划第一个 block 点是 Task 0 的 `blocking-human` 汇报关卡(AGENTS.md 硬要求 / D-03)**:先汇报「client-base 只放设置系统 / 工厂 / 内置控件,`src/client/ui` 只放设置界面」的要做什么 / 为什么 / 怎么做 / 影响范围,逐条列出 `SettingSystem` 的全部公开成员、四个工厂与它们的 options 字段、四个控件的行为、`SettingPage` 的布局、界面与控件的精确分工、命名清单、验证方式,以及「不接线 / 不做浏览器 UAT / 执行器不提交任何代码」三条铁律,并显式声明「11 个路径 / 80k 的范围告警已被接受(不拆分,见 OI-5)」与「`keyAdaptationGate.test.ts` 的 5/15 失败、`uiContainerGate.test.ts` 的 7/18 失败都是既有、范围外、只在基线记录、不作为门禁(`uiContainerGate.test.ts` 不在 `files_modified`)」,然后停下等待用户恰好回复「可以执行」"
|
||||
- "**分层是硬边界**:`client-base` 只放设置系统(`system.ts`)、工厂(`factories.ts`)、内置控件(`controls.tsx`)与类型;`src/client/ui/setting.tsx` 只放设置界面(`SettingPage` / `SettingPageUI`),**不定义任何控件组件**;`client-base` 的设置相关源码里**不出现界面对象构造**"
|
||||
- "**`ISettingComponentProps<T>` 只增加一个成员**:`readonly controller: IUIController<IGameUIPropsBase>`(用户选定的 option B);原有的 `readonly setting: ISettingItem<T>` 与 `types.ts` 里其余全部用户改动**逐字保持**(`ISettingItem` 的 7 个成员、`ISettingItemGroup`、`ISettingSystemSave`、`ISettingSystem` 的 `groups` + 8 个方法、`getSettingComponent()` 的联合返回类型),**不新增 `ISettingSubPageItem`**(用户已移除该类型)"
|
||||
- "**`SettingSystem implements ISettingSystem`**:`readonly groups: Map<string, ISettingItemGroup>`(接口要求公开只读,因此引用只读、原地增删)与 `private readonly settings: Map<string, ISettingItem>`;`setGroup` 登记每个设置项后整体替换分组、`items` 为空时删除该分组(此前登记的设置项变成未分组);`addSettingToGroup` 在分组不存在时是 no-op、存在时追加(同一设置项不重复追加);`getGroup` / `getSetting` 缺失返回 `null`;`iterateGroups` 返回 `this.groups.entries()`;`getUngrouped` 返回不在任何分组里的已登记设置项;`getValue<T>` / `setValue<T>` 转发;`saveState` 聚合每个设置项的 `saveState`(透传压缩级别);`loadState` 逐个调用设置项的 `loadState`,缺失 id 直接跳过且**不新增任何日志码**"
|
||||
- "**四个工厂函数逐字为** `defineNumberSetting(options)` / `defineBooleanSetting(options)` / `defineRangeSetting(options)` / `defineSubPageSetting(options)`,返回类型一律是 `ISettingItem<...>`(不返回实现类);options 接口为 `ISettingNumberOptions` / `ISettingBooleanOptions` / `ISettingRangeOptions` / `ISettingSubPageOptions`;数字与区间含 `defaultValue` / `min` / `max` / `step`,布尔含 `defaultValue`,子页面含 `page: IGameUI` 与可选 `props?: Record<string, unknown>`"
|
||||
- "**每个设置项实现 `ISettingItem<T>` 的 6 个成员**(`getValue` / `setValue` / `getPreviewString` / `getSettingComponent` + 继承来的 `saveState` / `loadState`);数值项在 `setValue` 内完成区间收敛与步长对齐(`loadState` 复用同一个 `setValue`);布尔预览为「开」/「关」;子页面项是普通的 `ISettingItem<void>`(保存 / 读取为空操作、预览为空串、组件打开 `controller.open(page, props ?? {})`)"
|
||||
- "**四个内置控件的行为**:数字控件 = 左减 / 右加按钮 + 中置当前值文本,每次按 `step` 增减并经设置项收敛;布尔控件 = 单个开关按钮,显示「开」/「关」,点击取反;区间控件 = 轨道 + 填充 + 滑块,按下与拖拽按指针横坐标线性映射到 `min..max` 并经设置项按 `step` 对齐,抬起或离开结束拖拽;子页面控件 = 可点击行(「打开」+ 右箭头),点击打开目标页面。四个控件都只接收 `ISettingComponentProps`(`setting` + `controller`),在 [0,0] 为原点的固定控件区内绘制,**不包界面对象**"
|
||||
- "**`SettingPageProps extends IGameUIPropsBase` 且只有一个新增成员** `readonly system: ISettingSystem`;左栏用 `ListPage` 承载 `iterateGroups()` 的分组(第一项为分组 id、第二项为分组名),右栏用一条 `Scroll` 平铺当前分组的设置行;每行绘制 `item.name`、`item.description`(长文本用 `TextContent`)、`item.getPreviewString()`,以及 `const Control = item.getSettingComponent()` 后渲染的 `<Control setting={item} controller={props.controller} />`;关闭走 `@common/exit` 与 `ListPage` 内置的关闭文本;**不做顶层全局 / 不做游戏根组装**,调用方自行组装页面"
|
||||
- "**界面文件不含任何持久化调用**(不读写存档),**不引用渲染端主对象上的任何设置字段**,**只构造自身的界面对象**;`SettingPageUI` 用 `new GameUI('setting-page', SettingPage)` 导出,但**不进入 `src/client/ui/index.ts`、不进入 `src/client/ui/settings.tsx`(设置菜单)、不接渲染端主对象**"
|
||||
- "**代码风格硬要求**:界面与控件的事件属性只接受顶层命名的函数引用(不写行内匿名函数);`loc` 只允许模块常量或先声明的局部 `ElementLocator` 变量(不在 JSX 属性里直接展开多行数组);顶层每个变量 / 常量 / 函数都有中文 jsDoc(R-3);JSX 分段有注释(R-4);不需要导出的类型不导出(R-5);字面量非空判断用 `isNil`、对象非空判断用 `!`(R-6);组件顶层无副作用(R-7);不使用 `import type`、不使用 `?.`、不改任何既有注释"
|
||||
- "**验证是真实的静态 / 单元验证,且如实声明未做浏览器验证**:`pnpm` 在本树已损坏(`packages-user/client-modules/package.json` 引用了已删除的 `@motajs/legacy-ui`),**不修任何 workspace / package 文件**,所有门禁改为直连二进制——`system.test.ts` / `settingPageGate.test.ts` 用 `node node_modules/vitest/vitest.mjs run <paths>` 跑通;`eslint` 用 `node node_modules/eslint/bin/eslint.js <paths>` 对 8 个目标路径 0 报错;类型检查用 `node node_modules/vue-tsc/bin/vue-tsc.js --noEmit`,在 6 个改动源码文件内 **NEW 诊断数为 0** 且整仓诊断总数不高于基线;`src/client/ui/__test__` **只跑** `settingPageGate.test.ts` / `hotkeySettingsGate.test.ts` 并全部通过(**不跑整个目录、也不宣称既有闸门全部通过**),`keyAdaptationGate.test.ts` 的 5/15 失败与 `uiContainerGate.test.ts` 的 7/18 失败均属既有 / 范围外(两个文件都不在 `files_modified`),只在基线记录并在 SUMMARY 说明,均不作为门禁;工厂与预览**不开运行期单元测试**(`controls.tsx` 静态导入 `@motajs/render`,导入期即需 DOM,`factories.ts` 静态导入 `controls.tsx`),改由源码形状闸门验证;10 个受保护文件哈希与基线一致;HEAD 与基线相同或 `baseline..HEAD` 只含 `.planning/`;**不做浏览器 UAT**;**执行器 0 次代码提交**"
|
||||
- "**范围与纪律**:只动 `files_modified` 的 **11 个**路径;不实现主对象接线、不实现任何 `globalSetting` / `gameSettings` 字段、不注册存档系统、不进设置菜单、不改 `hotkey`、不改 `logger.json`、不改 `package.json`、不修 workspace / 任何 `package.json`(`pnpm` 已损坏,只改门禁命令)、不新增依赖;发现疑似问题只记录、不擅自登记为缺陷"
|
||||
artifacts:
|
||||
- path: packages-user/client-base/src/setting/types.ts
|
||||
provides: "在用户改动之上**只做加法**:`ISettingComponentProps` 增加 `readonly controller: IUIController<IGameUIPropsBase>`;文件末尾追加 4 个 options 接口(`ISettingNumberOptions` / `ISettingBooleanOptions` / `ISettingRangeOptions` / `ISettingSubPageOptions`);导入追加 `IGameUI, IUIController` 与 `IGameUIPropsBase`,其余声明逐字不动"
|
||||
- path: packages-user/client-base/src/setting/system.ts
|
||||
provides: "`SettingSystem implements ISettingSystem`:`readonly groups: Map<string, ISettingItemGroup>`(公开只读、原地增删)与私有 `settings` 表,加上 `setGroup` / `addSettingToGroup` / `getGroup` / `getSetting` / `iterateGroups` / `getUngrouped` / `getValue<T>` / `setValue<T>` / `saveState` / `loadState`;不做任何构造、注册、存档系统调用"
|
||||
- path: packages-user/client-base/src/setting/factories.ts
|
||||
provides: "`defineNumberSetting` / `defineBooleanSetting` / `defineRangeSetting` / `defineSubPageSetting` 四个导出函数;模块内私有类 `NumberSetting` / `BooleanSetting` / `RangeSetting` / `SubPageSetting`(各自 `implements ISettingItem<...>`,值以 vue `shallowRef` 承载以驱动重绘);私有函数 `clampSettingValue`(区间收敛 + 步长对齐 + 浮点尾差消除)"
|
||||
- path: packages-user/client-base/src/setting/controls.tsx
|
||||
provides: "`createNumberSettingControl(step)` / `createBooleanSettingControl()` / `createRangeSettingControl(min, max)` / `createSubPageSettingControl(page, vBind)` —— 返回接收 `ISettingComponentProps` 的组件(用闭包携带接口里没有的 step / min / max),只依赖 vue 与 `@motajs/render` 的类型 / Font;固定控件区 180×24,原点 [0,0]"
|
||||
- path: packages-user/client-base/src/setting/index.ts
|
||||
provides: "同目录桶导出,恰好 4 行 `export * from './<同目录项>'`:`./controls` / `./factories` / `./system` / `./types`"
|
||||
- path: src/client/ui/setting.tsx
|
||||
provides: "`SettingPageProps extends IGameUIPropsBase { readonly system: ISettingSystem }`、`settingPageProps`、`SettingPage`(`ListPage` 承载分组 + 一条 `Scroll` 平铺设置行 + 关闭走 `@common/exit`)、`SettingPageUI = new GameUI('setting-page', SettingPage)`;零控件定义、零持久化调用、零入口接线"
|
||||
- path: packages-user/client-base/src/setting/__test__/system.test.ts
|
||||
provides: "`SettingSystem` 的单元测试(DOM-free:只导入 `../system` / `../types` / `vue` / `vitest`,不导入 `factories` / `controls`):登记 / 替换分组、空分组删除且设置项变未分组、`addSettingToGroup` 的 no-op 与去重、`getGroup` / `getSetting` 缺失返回 `null`、`iterateGroups`、`getUngrouped`、`getValue` / `setValue` 转发、`saveState` 聚合(透传压缩级别)、`loadState` 后值生效、未知 id 跳过不抛错"
|
||||
- path: src/client/ui/__test__/settingPageGate.test.ts
|
||||
provides: "界面 / 工厂 / 控件的源码形状闸门(`?raw` 只读源码,不运行任何模块、不导入运行期依赖):`ListPage` 使用与关闭路径、空系统 `Scroll` 占位、控件渲染方式与 props、`return () => renderPage()`、无行内处理函数、无多行内联 `loc`、界面层无持久化、`client-base` 无界面对象(用 `new GameUI(` 的调用形态匹配,允许 `IGameUI` 类型引用)、四个工厂 / 四个控件创建函数与会收敛的 `clampSettingValue`、布尔预览「开 / 关」、子页面空串预览、桶导出与菜单未被接入(`src/client/ui/index.ts` 断言不含带引号的 `'./setting'`)"
|
||||
- path: .planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json
|
||||
provides: "Task 1 在收到「可以执行」之后、写入任何代码之前写出的机器可读基线:`{ head, hashes, dirty, typecheck, typecheckTotal }`;`hashes` 保护 **10 个**文件;`typecheck` 是 6 个改动源码文件的归一化诊断数组;`typecheckTotal` 是整仓诊断条数;可选信息字段 `keyAdaptation`(`{ file, failed, passed, total }`)记录既有 `keyAdaptationGate.test.ts` 的 5/15 失败、`uiContainer`(同 schema)记录既有 `uiContainerGate.test.ts` 的 7/18 失败(两者都只记录、不作门禁);只写一次"
|
||||
- path: .planning/phases/09-minimum-browser-runnable/09-34-SUMMARY.md
|
||||
provides: "执行摘要:系统 API、工厂 options、控件行为、页面布局、界面 / 控件分工、真实门禁结果(直连二进制的实际命令)、**既有 `keyAdaptationGate.test.ts` 5/15 与 `uiContainerGate.test.ts` 7/18 失败为既有 / 范围外的证据**、**范围告警已接受(11 个路径 / 80k,不拆分,OI-5)**、未做浏览器 UAT 声明、估算未校准声明、执行器 0 次代码提交声明、未接线声明;**先跑完所有门禁再写本文件,写完后再跑摘要闸门**"
|
||||
- path: .planning/phases/09-minimum-browser-runnable/09-34-PLAN.md
|
||||
provides: "本计划文件本身"
|
||||
key_links:
|
||||
- "`ISettingComponentProps.controller` ↔ `setting.tsx` 的 `<Control ... />` — 控件不通过主对象单例取值,只通过这个成员拿到 `IUIController<IGameUIPropsBase>`;`src/client/ui/settings.tsx` 第 59 行已有 `const controller: IUIController<IGameUIPropsBase> = props.controller;` 的同一泛型收缩先例"
|
||||
- "`SettingSystem.settings` ↔ `ISettingItem` — `getValue` / `setValue` 都是对 `settings.get(id)` 的转发,`saveState` / `loadState` 都以 `settings` 为唯一枚举源;分组只决定显示归属,不参与取值"
|
||||
- "`setGroup(group, name, items)` ↔ `getUngrouped()` — 分组替换不删除 `settings` 里的登记,因此空数组删组后设置项仍可枚举为未分组;反过来 `addSettingToGroup` 也只往已存在的分组追加"
|
||||
- "`factories.ts` ↔ `controls.tsx` — 工厂构造设置项时就把 `step` / `min` / `max` / `page` 交给控件创建函数,控件拿到的闭包参数即该设置项的配置;单一依赖方向(工厂 → 控件),不得反向引用"
|
||||
- "`ISettingItem` 的值存储(vue `shallowRef`)↔ `SettingPage` 的行渲染 — 页面与控件都在渲染期调用 `getValue()` / `getPreviewString()`,读取同一个 ref,值变化即触发重绘;这是「设置项不是响应式对象」下预览能跟手的唯一机制"
|
||||
- "`setting/index.ts` ↔ `client-base/src/index.ts` — 包级桶已 `export * from './setting';`,本计划只补同目录桶,不改包级桶(受保护哈希)"
|
||||
- "`src/client/ui/setting.tsx` ↔ `src/client/ui/index.ts` / `settings.tsx` — 界面文件自身导出页面与界面对象,但**不**接入 UI 桶、**不**进设置菜单(受保护哈希 + 源码闸门双重证明)"
|
||||
- "`node node_modules/vue-tsc/bin/vue-tsc.js --noEmit` NEW-only ← `09-34-BASELINE.json` — 新增文件在改前不存在,只有「改前 / 改后」同一棵树内对比才能证明「本计划没有引入新诊断」;整仓诊断总数同样只允许下降或持平"
|
||||
assumptions:
|
||||
- "**用户已改动 `setting/types.ts`**:规划日该文件 106 行,`ISettingComponentProps` 只有 `setting`;`ISettingItem<T>` 有 `id` / `name` / `description` / `getValue` / `setValue` / `getPreviewString` / `getSettingComponent`;`ISettingSystem` 有 `groups` + `setGroup` / `addSettingToGroup` / `getGroup` / `getUngrouped` / `getSetting` / `iterateGroups` / `getValue<T>` / `setValue<T>`(共 8 个方法);**不存在 `ISettingSubPageItem`**。本计划只做加法,不改上述任何既有声明"
|
||||
- "**`getValue<T>(id): T | undefined` 已把缺失编码为 `undefined`**(不是 `null`),因此 `SettingSystem.getValue` 缺失时返回 `void 0`,不得返回 `null`;`getGroup` / `getSetting` 则按接口返回 `null`"
|
||||
- "**`IUIController<IGameUIPropsBase>` 可从界面 props 收缩赋值**:`src/client/ui/settings.tsx` 第 59 / 369 行已有该写法并长期通过类型检查;若 `controller={props.controller}` 直接传入报错,允许改用在 setup 里先声明该类型收窄变量再传给控件(既有先例,不是新写法),不得使用 `as` 断言"
|
||||
- "**控件无法从接口读取 step / min / max**:`ISettingItem` 与 `ISettingComponentProps` 都不含这些字段,且用户已锁定 `ISettingComponentProps` 只加 `controller`。因此控件改为「创建函数 + 闭包携带配置」的形式(`createXxxSettingControl(...)` 返回组件),避免新增接口成员、避免类型断言、避免模块级可变注册表"
|
||||
- "**函数式组件可带配置**:`defineComponent<ISettingComponentProps>` 的返回值可赋给 `ISettingItem.getSettingComponent()` 的联合返回类型;`packages/system/src/ui/container.tsx` 已用 `<v.ui.component />` / `<b.ui.component />` 在 JSX 里渲染联合类型的组件变量,是同一模式的既成先例"
|
||||
- "**`@motajs/render` 在 node 导入期需要 DOM,因此工厂 / 控件不开运行期单元测试**:`controls.tsx` 静态导入 `@motajs/render`(`ElementLocator` 类型与 `Font`),`factories.ts` 静态导入 `controls.tsx`,所以任何 `import '../factories'` 的测试都会在导入期碰到 DOM。因此**不新建 `factories.test.ts`**,也不加 jsdom / `vi.stubGlobal` 之类的 DOM mock;工厂的 options 字段、数值收敛与步长对齐、布尔「开 / 关」预览、子页面空串预览改由 `settingPageGate.test.ts` 的源码形状闸门(`?raw`,不运行模块)验证。只有 `system.ts`(不导入控件)可以写运行期单元测试"
|
||||
- "**界面层需要热键退出命令**:`IClientBase.hotkey` 是 `IClientHotkey`,`useKey(props.state.hotkey.system)` + `implement('@common/exit', exit)` 是 `src/client/ui/hotkey.tsx` 的既有写法,可直接沿用"
|
||||
- "**`ListPage` 的关闭文本是内置的**:`packages-user/client-base/src/components/list.tsx` 在 `close` 为真时自行渲染关闭文本并 `emit('close')`,因此界面只需传 `close` / `closeLoc` / `onClose`"
|
||||
- "**`uiConstant.ts` 提供 `FULL_LOC` / `MAIN_WIDTH` / `MAIN_HEIGHT`**(`src/client/uiConstant.ts` 第 58-75 行),与 `hotkey.tsx` 的取值方式一致;`src/client/ui` 的文件统一从 `@/client/uiConstant` 引入"
|
||||
- "**无 RESEARCH / 无 VALIDATION**:本 run 不存在 09-34-RESEARCH.md 与 09-34-VALIDATION.md;本阶段无 requirement ID,Nyquist 验证不适用,不创建这两类文件"
|
||||
- "**工作树状态是历史记录(仅供参考)**:规划日观察到多条未提交改动(`client-base/src/index.ts`、`map/*`、`src/client/ui/*`、`docs/.vitepress/*`、Phase 10 的计划文件等);`setting/index.ts` 与 `setting/types.ts` 已在 `2ce5d06`(feat: 设置系统类型声明)中提交,属**已跟踪**文件。上述枚举仅作背景说明(info-only),不是任何门禁的输入;所有门禁一律使用 `09-34-BASELINE.json` 现场采集的活基线(live baseline)"
|
||||
- "**包级桶的 `./setting` 导出只存在于当前脏工作树、不在 HEAD**:`packages-user/client-base/src/index.ts` 里的 `export * from './setting';` 是未提交改动。因此本计划**必须在当前工作树内执行**(不得切分支 / 新建或切换 worktree / 复原工作树);Task 1 第一步(写基线之前)用 fail-fast 断言该行存在,缺失即停下上报。该文件是受保护文件,**不得**加入 `files_modified`、不得由本计划补这一行"
|
||||
- "**`pnpm` 在本树不可用**:`packages-user/client-modules/package.json` 引用了已删除的 `@motajs/legacy-ui`,因此 `pnpm` / `pnpm run` / `pnpm exec` 一律失败。**不得修复 workspace 或任何 `package.json`**;所有门禁改直连二进制(`node_modules/vue-tsc/bin/vue-tsc.js`、`node_modules/eslint/bin/eslint.js`、`node_modules/vitest/vitest.mjs`,三条均已验证可用)"
|
||||
prohibitions:
|
||||
- "不得在用户恰好回复「可以执行」之前执行任何任务或派发执行子代理(AGENTS.md / D-03)"
|
||||
- "**不得运行任何 git 写命令**:git add / commit / stash / checkout / restore / worktree / reset 一律禁止;代码改动留在工作树,是否提交由用户另行决定;**该禁止显式覆盖执行器的任何提交协议**——`execute-plan` 的逐任务 commit 步骤与任何 `commit_docs` 代码提交一律跳过,HEAD 除 `.planning` 聚合提交外必须与基线相同"
|
||||
- "**不得接线**:不在渲染端主对象上加任何设置字段、不构造 / 注册 / 保存设置系统、不调用存档系统的注册接口、不在 `src/client/ui/settings.tsx` 里加入口、不改 `src/client/ui/index.ts`、不改 `src/client/**` 的其它文件"
|
||||
- "**不得改受保护文件**:`client-base/src/index.ts`、`client-base/src/types.ts`、`client-base/src/hotkey/hotkey.ts`、`client-base/src/hotkey/types.ts`、`client-base/src/components/index.ts`、`client-base/package.json`、`packages-user/data-common/src/save/types.ts`、`src/client/ui/index.ts`、`src/client/ui/settings.tsx`、`packages/common/src/logger.json`"
|
||||
- "**不得新增日志码**:`logger.json` 保持不变;系统 `loadState` 遇到未登记 id 直接跳过,不写新的错误 / 警告码(用户明确「优先不动公共包」)"
|
||||
- "**不得新增依赖、不得执行安装命令、不得跑 build 脚本**"
|
||||
- "**不得修改 `types.ts` 里用户已写的任何一行**(只允许追加导入、追加 `controller` 成员、追加 4 个 options 接口);不得恢复 `ISettingSubPageItem`"
|
||||
- "**不得把控件写进 `src/client/ui`**,也不得把界面写进 `client-base`;不得在控件里构造界面对象"
|
||||
- "不得越界处理发现的其它疑似问题;只记录、不擅自修复、不擅自登记为缺陷"
|
||||
open_items:
|
||||
- "**OI-1(命名报备,按 AGENTS.md 在 Task 0 一并报备)**:①类型 —— `ISettingNumberOptions` / `ISettingBooleanOptions` / `ISettingRangeOptions` / `ISettingSubPageOptions` 及其成员 `defaultValue` / `min` / `max` / `step` / `page` / `props`,`ISettingComponentProps.controller`;②实现类 —— `SettingSystem`(公开只读 `groups` / 私有 `settings`)、`NumberSetting` / `BooleanSetting` / `RangeSetting` / `SubPageSetting`(私有字段 `value` / `min` / `max` / `step` / `page` / `vBind` / `control`);③工厂与控件 —— `defineNumberSetting` / `defineBooleanSetting` / `defineRangeSetting` / `defineSubPageSetting`、`createNumberSettingControl` / `createBooleanSettingControl` / `createRangeSettingControl` / `createSubPageSettingControl`;④私有函数与常量 —— `clampSettingValue`、`CONTROL_WIDTH` / `CONTROL_HEIGHT` / `BUTTON_WIDTH` / `TRACK_HEIGHT` / `TRACK_TOP` / `KNOB_WIDTH` / `FONT` / `CONTROL_LOC` / `MINUS_LOC` / `PLUS_LOC` / `VALUE_LOC` / `TRACK_LOC`;⑤界面 —— `SettingPageProps` / `settingPageProps` / `SettingPage` / `SettingPageUI` / `'setting-page'` / `selectedGroup` / `groupItems` / `selectGroup` / `exit` / `renderGroup` / `renderRow` / `LEFT_PAD` / `TOP_PAD` / `GROUP_WIDTH` / `GROUP_BASIS` / `CONTENT_WIDTH` / `CONTENT_SCROLL_LOC` / `GROUP_ROW_HEIGHT` / `LIST_TOP` / `LIST_HEIGHT` / `CLOSE_LOC` / `ROW_HEIGHT` / `ROW_GAP` / `ROW_WIDTH` / `ROW_TEXT_WIDTH` / `DESCRIPTION_TOP` / `PREVIEW_TOP` / `CONTROL_TOP` / `CONTROL_AREA_LOC` / `CONTENT_FONT`;⑥测试内的私有辅助(`createFakeItem` 等)。**若你要调整任一命名,请在回复中给出替换名,我会整体替换后再执行**"
|
||||
- "**OI-2(设计待确认)**:①**`groups` 的可见性**——`ISettingSystem` 声明了公开只读 `groups`,因此 `SettingSystem` 的 `groups` 只能是公开只读字段(`Map` 引用只读、原地增删),`settings` 保持私有;若你要求两者都私有,需要先改 `types.ts` 的接口(不在本计划范围);②控件用「创建函数 + 闭包」承载 `step` / `min` / `max` / `page`(因为接口被锁定为只有 `setting` + `controller`);③界面左栏只列 `iterateGroups()` 的分组,**不为未分组项造一个合成分组**;④工厂不做参数校验(没有合适日志码),越界值由 `setValue` 收敛;⑤系统不做 id 重复告警(重复登记以最后一次为准)"
|
||||
- "**OI-3(执行期硬停条件)**:①`node node_modules/vue-tsc/bin/vue-tsc.js --noEmit` 在 6 个改动源码文件内出现 NEW 诊断(例如 `IUIController<IGameUIPropsBase>` 收缩失败、联合组件类型不可渲染、`controller.open(page, vBind)` 参数类型不匹配),停下上报,不得改依赖包 / `tsconfig` / 加 `@ts-expect-error` / 加 `as` 蒙过;②整仓诊断总数高于基线,停下上报并定位是否由本计划引起;③任何测试若被迫 `import '../factories'` 或 `'../controls'`(会经 `@motajs/render` 在导入期要 DOM),停下上报,**不得**为此新增 `factories.test.ts`、加 jsdom / 环境改造 / DOM mock,也不得改 `controls.tsx` 的导入结构;工厂只由源码形状闸门验证;④`settingPageGate.test.ts` / `hotkeySettingsGate.test.ts` 出现失败即停下上报(受保护哈希可证明不是本计划造成);⑤`keyAdaptationGate.test.ts` 的 5/15 失败与 `uiContainerGate.test.ts` 的 7/18 失败都是**既有、范围外**,不属硬停条件——不修、不改、不纳入门禁,只在基线记录并在 SUMMARY 写明(两个文件都不在 `files_modified`)"
|
||||
- "**OI-5(范围告警,显式接受,不拆分)**:规模告警为「12 个文件 / 90k」。修订后实际为 **11 个路径 / 80k**(已删除 `factories.test.ts`,并把工厂验证并入源码形状闸门)。**接受该告警、不拆分**,理由:①四层(类型 → 系统 → 工厂 → 控件 → 界面)是同一纵向切片,拆开后第二段(`SettingPage`)无法独立验证——它依赖 `factories.ts` / `controls.tsx` 已存在;②拆分会把 `types.ts` / `factories.ts` / `controls.tsx` 的写入分到两个计划,产生**同文件跨计划依赖**,违反同波次零文件重叠的并行规则,反而更慢;③本计划零接线、零新增依赖、来源单一是同一作者的一次增量,拆分只增加汇报与门禁次数。Task 0 汇报与 SUMMARY 都必须写明「范围告警已接受」"
|
||||
- "**OI-4(已知遗留,只记录、不修复)**:①设置系统与界面**均未接线**(不进渲染端主对象、不进设置菜单、不注册存档),属用户后续自行接线的范围;②`src/client/ui/setting.tsx` 不进 UI 桶,因此当前没有任何调用方;③`setting/index.ts` 与 `setting/types.ts` 已由 `2ce5d06` 提交为已跟踪文件,本计划会在其上产生正常的「已修改」记录;④工作树里 09-29..09-33 的改动与 docs / Phase 10 的既有改动仍未提交,范围闸门只能证明「没有新增范围外路径」"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Phase 9 · 第 34 个计划(新增的独立增量)——**在 `client-base` 落设置系统、四个设置项工厂与四个内置控件,并在 `src/client/ui` 落一个通用的设置界面 `SettingPage` / `SettingPageUI`;不做任何接线**。
|
||||
|
||||
**要解决的问题:**
|
||||
|
||||
09-33 已经把设置的数据契约钉在 `setting/types.ts`(`ISettingItem` / `ISettingItemGroup` / `ISettingSystemSave` / `ISettingSystem` / `ISettingComponentProps`),但全是声明、零实现。现在需要一个可用的系统本体、能把配置写成设置项的工厂、能改值的控件,以及一个能把分组 / 设置项 / 控件拼起来的界面,否则后续任何「设置」相关工作都要先自己造一套实现。本计划只交付这三层实现,**不接线**:不进渲染端主对象、不进设置菜单、不注册存档、不碰入口。
|
||||
|
||||
**分层(用户锁定的分工):**
|
||||
|
||||
- `packages-user/client-base`:**只**放设置系统(`system.ts`)、工厂(`factories.ts`)、内置控件(`controls.tsx`)与类型(`types.ts` 的加法)。
|
||||
- `src/client/ui`:**只**放设置界面 `setting.tsx`(`SettingPage` / `SettingPageUI`),**不定义任何控件**。
|
||||
|
||||
**做法(逐层):**
|
||||
|
||||
1. **类型加法**:`ISettingComponentProps<T>` 增加 `readonly controller: IUIController<IGameUIPropsBase>`(用户选定的 option B);文件末尾追加 4 个 options 接口;`types.ts` 其余内容逐字不动,**不恢复** `ISettingSubPageItem`。
|
||||
2. **系统**:`SettingSystem implements ISettingSystem`,公开只读 `groups`(`Map`,原地增删)+ 私有 `settings`,`setGroup` 空数组即删组、`addSettingToGroup` 分组不存在即 no-op、`getUngrouped` 由「已登记 − 已分组」得到、`saveState` / `loadState` 逐项聚合与转发(缺失 id 跳过、**不新增日志码**)。
|
||||
3. **工厂**:`defineNumberSetting` / `defineBooleanSetting` / `defineRangeSetting` / `defineSubPageSetting`,各自返回 `ISettingItem<...>`;值以 vue `shallowRef` 承载,使页面与控件的渲染读取到同一个响应式源;数值项在 `setValue` 里做区间收敛 + 步长对齐,`loadState` 复用 `setValue`;子页面项是普通 `ISettingItem<void>`,保存 / 读取空操作、预览空串、组件打开目标页面。
|
||||
4. **控件**:`createNumberSettingControl(step)` / `createBooleanSettingControl()` / `createRangeSettingControl(min, max)` / `createSubPageSettingControl(page, vBind)` —— 因为 `ISettingComponentProps` 只有 `setting` + `controller`,`step` / `min` / `max` 用闭包随工厂一起交给控件;控件在 [0,0] 为原点的固定 180×24 控件区内绘制,不包界面对象。
|
||||
5. **界面**:`SettingPageProps extends IGameUIPropsBase { readonly system: ISettingSystem }`;`ListPage` 左栏列分组、右栏一条 `Scroll` 平铺设置行(名称 / 描述(`TextContent`)/ 预览 / 控件),关闭走 `@common/exit` 与 `ListPage` 内置关闭文本;`SettingPageUI = new GameUI('setting-page', SettingPage)` 只在文件内导出,**不进 UI 桶、不进设置菜单**。
|
||||
|
||||
**明确不做:** 不接渲染端主对象、不加 `globalSetting` / `gameSettings` 之类的字段、不注册存档、不进设置菜单、不改入口 / 配置、不改 `logger.json`、不改 `package.json`、不新增依赖、不做浏览器 UAT。
|
||||
|
||||
**约束:** 只动 `files_modified` 的 **11 个**路径;事件处理只用命名函数引用;`loc` 只用常量或先声明的局部 `ElementLocator`;顶层定义逐个写中文 jsDoc;字面量用 `isNil`、对象用 `!`;**必须在当前脏工作树内执行**(包级桶的 `export * from './setting';` 只存在于工作树,不在 HEAD,Task 1 第一步 fail-fast 自检)。
|
||||
|
||||
**门禁命令(`pnpm` 在本树不可用):** `packages-user/client-modules/package.json` 引用了已删除的 `@motajs/legacy-ui`,本树 `pnpm` / `pnpm exec` / `pnpm run` 直接失败,**不修任何 workspace / `package.json`**。全部门禁改直连二进制(三条均已验证可用):`node node_modules/vue-tsc/bin/vue-tsc.js --noEmit`、`node node_modules/eslint/bin/eslint.js <paths>`、`node node_modules/vitest/vitest.mjs run <paths>`。
|
||||
|
||||
**范围告警(显式接受,不拆分,见 OI-5):** 规模超出推荐值(修订后 11 个路径 / 80k,原 12 / 90k)。不拆分的理由:四层是同一纵向切片,拆开后 `SettingPage` 无法独立验证;且会把 `types.ts` / `factories.ts` / `controls.tsx` 的写入分到两个计划,产生同文件跨计划依赖。Task 0 汇报与 SUMMARY 都必须写明「该告警已被接受」。
|
||||
|
||||
**修改方式声明(AGENTS.md):** 本计划的全部文件写入(写基线、写 4 个 `client-base` 源码文件、写 `client-base` 系统测试、写界面与闸门测试、写摘要)由**派发子代理(`gsd-executor`)执行**,非内联执行;除 `files_modified` 外无任何写入。**Task 0 只汇报、不写任何文件**;`09-34-BASELINE.json` 是 **Task 1 在收到「可以执行」之后、写入任何代码之前的第一步**写出(只写一次)。**执行器不提交任何代码**:该禁止显式覆盖执行器的提交协议,HEAD 除 `.planning` 聚合提交外必须与基线相同。
|
||||
|
||||
**可见跳过:** spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)。本阶段无 requirement ID,不创建 RESEARCH.md / VALIDATION.md,Nyquist 验证以 `system.test.ts` + `settingPageGate.test.ts` + 直连类型检查承担。
|
||||
</objective>
|
||||
|
||||
## 本计划产出物(Artifacts this plan produces)
|
||||
|
||||
| 类型 | 名称 / 路径 | 说明 |
|
||||
|------|-------------|------|
|
||||
| 追加 | `packages-user/client-base/src/setting/types.ts` | `ISettingComponentProps` + `controller`;追加 4 个 options 接口;其余逐字不动 |
|
||||
| 新增 | `packages-user/client-base/src/setting/system.ts` | `SettingSystem implements ISettingSystem` |
|
||||
| 新增 | `packages-user/client-base/src/setting/factories.ts` | 4 个 `define*Setting` + 4 个私有设置项类 + `clampSettingValue` |
|
||||
| 新增 | `packages-user/client-base/src/setting/controls.tsx` | 4 个 `create*SettingControl`(闭包携带 step / min / max / page) |
|
||||
| 修改 | `packages-user/client-base/src/setting/index.ts` | 4 行同目录桶导出 |
|
||||
| 新增 | `src/client/ui/setting.tsx` | `SettingPage` / `SettingPageUI`(唯一界面文件,零控件) |
|
||||
| 新增 | `packages-user/client-base/src/setting/__test__/system.test.ts` | 系统单元测试(DOM-free,不导入工厂 / 控件) |
|
||||
| 新增 | `src/client/ui/__test__/settingPageGate.test.ts` | 界面 / 工厂 / 控件源码形状闸门(`?raw`,不运行模块) |
|
||||
| 基线 | `.planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json` | HEAD + 10 个受保护文件哈希 + 脏文件快照 + 类型基线与总数 + 既有 `keyAdaptationGate` 5/15 与 `uiContainerGate` 7/18 失败数(信息字段) |
|
||||
| 交付 | `.planning/phases/09-minimum-browser-runnable/09-34-SUMMARY.md` | 执行摘要、门禁结果、既有失败记录、范围告警接受、未接线 / 未 UAT / 0 次提交声明 |
|
||||
| 计划 | `.planning/phases/09-minimum-browser-runnable/09-34-PLAN.md` | 本文件 |
|
||||
|
||||
## 覆盖审计(Multi-Source Coverage Audit)
|
||||
|
||||
| 源 | 条目 | 覆盖位置 |
|
||||
|----|------|----------|
|
||||
| GOAL(ROADMAP §Phase 9) | 「可在浏览器中运行并验证的最小版本」伞目标 | 本增量只交付设置系统 / 工厂 / 控件 / 界面四个层次,不新增入口、不做浏览器验证;SUMMARY 如实记录 |
|
||||
| REQ | 本阶段无 requirement ID(`requirements: []`) | Nyquist 以 `system.test.ts` + `settingPageGate.test.ts` + 直连类型检查承担 |
|
||||
| RESEARCH | 本 run 无 RESEARCH.md / 本计划无 DISCOVERY.md | 不适用 |
|
||||
| CONTEXT D-01 | 增量派发,一个任务一个计划 | 本计划即该次派发的唯一计划,不拆分 |
|
||||
| CONTEXT D-02 | 计划可累积追加 | plan 34,排在 09-01..09-33 之后(`wave: 29` 只是排期编号) |
|
||||
| CONTEXT D-03 | 执行前必须先汇报并等待「可以执行」 | Task 0(`blocking-human`),`autonomous: false` |
|
||||
| CONTEXT D-04 | 新命名必须先反馈获批 | OI-1(类型 / 类 / 工厂 / 控件 / 界面 / 常量全部命名) |
|
||||
| CONTEXT D-06 | 范围由用户界定,不擅自扩大 | `files_modified` **11 项** + 范围闸门 + 10 个受保护文件哈希 |
|
||||
| 用户要求 1 | `ISettingComponentProps` 只加 `controller`(option B),其余用户改动不动 | Task 1 的 types 步骤 + 形状闸门(既有声明逐字存在) |
|
||||
| 用户要求 2 | `SettingSystem implements ISettingSystem` 的全部成员语义 | Task 1 的系统步骤 + `system.test.ts` 用例 |
|
||||
| 用户要求 3 | 4 个工厂 + options 字段 + 子页面空操作 | Task 1(数字)/ Task 2(其余三个)+ Task 3 的源码形状断言(不写运行期工厂测试,见 OI-3 ③) |
|
||||
| 用户要求 4 | 4 个控件的行为与 [0,0] 固定控件区、只用 `setting` + `controller` | Task 1(数字控件)/ Task 2(其余三个) |
|
||||
| 用户要求 5 | `SettingPage` 用 `ListPage` + `Scroll` + 行渲染 + `@common/exit` + 不做顶层全局 | Task 1 的界面步骤 + Task 3 的源码闸门 |
|
||||
| 用户要求 6 | 界面 / 控件分层、界面层零持久化、不进菜单、不接主对象 | Task 3 闸门 + 受保护哈希 + prohibitions |
|
||||
| 用户要求 7 | 测试、eslint、直连 `vue-tsc` NEW-only、既有闸门按文件单独跑(`keyAdaptationGate` 5/15 与 `uiContainerGate` 7/18 既有失败仅记录)、无浏览器 UAT、无提交 | Task 2 / Task 3 全部门禁 |
|
||||
|
||||
<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/phases/09-minimum-browser-runnable/09-CONTEXT.md
|
||||
@AGENTS.md
|
||||
@dev.md
|
||||
@packages-user/client-base/src/setting/types.ts
|
||||
@packages-user/client-base/src/setting/index.ts
|
||||
@packages-user/client-base/src/hotkey/hotkey.ts
|
||||
@packages-user/client-base/src/hotkey/types.ts
|
||||
@packages-user/client-base/src/components/list.tsx
|
||||
@packages-user/client-base/src/components/input.tsx
|
||||
@packages-user/client-base/src/components/textbox.tsx
|
||||
@packages-user/client-base/src/types.ts
|
||||
@packages/system/src/ui/types.ts
|
||||
@src/client/ui/hotkey.tsx
|
||||
@src/client/uiConstant.ts
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 0: 预执行汇报关卡 —— 汇报设置系统 API、工厂 options、控件行为、SettingPage 布局、界面 / 控件分工、命名清单与验证方式,等待「可以执行」</name>
|
||||
<precondition>工作树仍处于 09-33 之后的状态,可用以下事实自检:`packages-user/client-base/src/setting/types.ts` 共 106 行,`ISettingComponentProps` 只有 `setting`,`ISettingItem<T>` 有 7 个成员,`ISettingSystem` 有 `groups` + 8 个方法,`ISettingItemGroup` 有 `name` + `items`,`ISettingSystemSave` 有 `values`,**没有** `ISettingSubPageItem`;`setting/index.ts` 只有 1 行 `export * from './types';`;`client-base/src/index.ts` 已 `export * from './setting';`;`client-base/src/types.ts` 的 `IGameUIPropsBase` 继承 `IUIDefaultPropsBase` 并带 `state`;`packages/system/src/ui/types.ts` 的 `IUIController<PB>` 有 `open` / `close`;`components/list.tsx` 的 `ListPage` 在 `close` 为真时自行渲染关闭文本并 `emit('close')`;`src/client/ui/hotkey.tsx` 是「`ListPage` + `Scroll` + `useKey('@common/exit')` + `FULL_LOC`」的既成先例;`src/client/ui/settings.tsx` 第 59 行有 `const controller: IUIController<IGameUIPropsBase> = props.controller;` 的泛型收缩先例。**本计划的执行环境自检(必须在汇报中一并说明):** ①`packages-user/client-base/src/index.ts` 含 `export * from './setting';`——该行只存在于**当前脏工作树**、不在 HEAD,因此必须在当前工作树内执行(缺失即停,不得新建 worktree / 切分支 / 复原,也不得由本计划补这一行);②`pnpm` 在本树不可用(`packages-user/client-modules/package.json` 引用已删除的 `@motajs/legacy-ui`),**不修 workspace / `package.json`**,门禁改直连 `node_modules/vue-tsc/bin/vue-tsc.js` / `node_modules/eslint/bin/eslint.js` / `node_modules/vitest/vitest.mjs`(三者已确认存在);③既有 `src/client/ui/__test__/keyAdaptationGate.test.ts` 有 **5/15 失败**、`src/client/ui/__test__/uiContainerGate.test.ts` 有 **7/18 失败**,两者均属既有、范围外(两个文件都不在本计划 `files_modified` 中、工作树里本来就是坏的),只在基线记录、不作为门禁。</precondition>
|
||||
<files>无(本任务不写任何文件,只做只读汇报;`09-34-BASELINE.json` 改由 Task 1 在获批后、写代码前写出)</files>
|
||||
<read_first>
|
||||
- .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-01 增量派发、D-03 汇报关卡、D-04 命名批准、D-06 不扩大范围、D-07 不臆造 UI)
|
||||
- .planning/phases/09-minimum-browser-runnable/09-33-PLAN.md(上一次派发的边界:只做类型声明,实现留到本计划)
|
||||
- dev.md(模块无副作用 / 无类型导入 / 命名 / jsDoc / 类型规范 / 非空判断约定)
|
||||
- AGENTS.md(执行前须获批、命名先反馈、既有注释不得擅改、修改方式须预先声明)
|
||||
- packages-user/client-base/src/setting/types.ts 与 index.ts(本次要追加的类型与桶导出)
|
||||
- packages-user/client-base/src/hotkey/hotkey.ts 与 types.ts(分组 / 未分组 / 存档聚合的既有写法参照)
|
||||
- packages-user/client-base/src/components/list.tsx(`ListPage` 的 props、内置关闭文本、插槽契约)
|
||||
- packages-user/client-base/src/components/textbox.tsx(`TextContent` 的 props:`text` / `width` / `autoHeight`)
|
||||
- packages-user/client-base/src/types.ts(`IGameUIPropsBase`)与 packages/system/src/ui/types.ts(`IUIController`)
|
||||
- src/client/ui/hotkey.tsx(`ListPage` + `Scroll` + `useKey('@common/exit')` + `FULL_LOC` 的界面先例)与 src/client/uiConstant.ts(`FULL_LOC` / `MAIN_WIDTH` / `MAIN_HEIGHT`)
|
||||
</read_first>
|
||||
<what-built>
|
||||
一份待批准的执行计划(`09-34-PLAN.md`):在 `client-base` 新增 `SettingSystem`、四个 `define*Setting` 工厂与四个内置控件,在 `src/client/ui/setting.tsx` 新增通用设置界面 `SettingPage` / `SettingPageUI`,并配一个系统单元测试 + 一个源码形状闸门与直连二进制的门禁;**不做任何接线**。
|
||||
</what-built>
|
||||
<action>
|
||||
**停下并向用户汇报,等待恰好回复「可以执行」后才继续;本任务不写任何文件(含 `09-34-BASELINE.json`——它由 Task 1 的第一步写出),不派发执行子代理。**
|
||||
|
||||
① **要做什么(四层实现,零接线):**
|
||||
- **系统层**:`packages-user/client-base/src/setting/system.ts` 新增 `SettingSystem`,实现 `ISettingSystem`。
|
||||
- **工厂层**:`packages-user/client-base/src/setting/factories.ts` 新增四个 `define*Setting` 函数。
|
||||
- **控件层**:`packages-user/client-base/src/setting/controls.tsx` 新增四个内置控件。
|
||||
- **界面层**:`src/client/ui/setting.tsx` 新增 `SettingPage` / `SettingPageUI`,只此一个界面文件、不定义控件。
|
||||
- 另配**两个**新测试文件(`system.test.ts` 系统单元测试 + `settingPageGate.test.ts` 界面 / 工厂 / 控件源码形状闸门),并补 `setting/index.ts` 的同目录桶导出;**不新建 `factories.test.ts`**(原因见 ④ 最后一条)。
|
||||
|
||||
② **为什么:** 09-33 只钉死了类型契约,没有实现。没有系统本体,设置项无处注册;没有工厂,每个设置项都要手写一个类;没有控件,界面无法改值;没有界面,分组 / 设置项 / 控件无法拼成可用的页面。本计划一次性补齐这四层,但**不接线**,方便你随后自行决定接在哪里。
|
||||
|
||||
③ **系统 API(`SettingSystem implements ISettingSystem`,请重点确认语义):**
|
||||
- 成员:公开只读 `groups: Map<string, ISettingItemGroup>`(分组 id → 分组;接口要求公开,引用只读、原地增删)与私有 `settings: Map<string, ISettingItem>`(设置项 id → 设置项)。**接口里有公开只读 `groups`,因此它不能是私有字段**——这是接口契约强制的可见性,`settings` 仍保持私有。
|
||||
- `setGroup(group, name, items)`:先把 `items` 里每个设置项按 id 登记进 `settings`,再用 `{ name, items }` 整体替换该分组;`items` 为空数组时**删除该分组**,此前登记过的设置项变成未分组(不会被删除)。
|
||||
- `addSettingToGroup(group, item)`:分组不存在时 **no-op**;存在时登记该设置项并把分组项列表换成「原有 + 新项」的新数组;同一个设置项重复加入时**不重复追加**。
|
||||
- `getGroup(group)` / `getSetting(id)`:缺失返回 `null`。
|
||||
- `iterateGroups()`:返回 `this.groups.entries()`。
|
||||
- `getUngrouped()`:返回「已登记但不在任何分组里」的设置项集合。
|
||||
- `getValue<T>(id)`:缺失返回 `undefined`(按接口签名 `T | undefined`);`setValue<T>(id, value)`:缺失时 no-op,否则转发给该设置项。
|
||||
- `saveState(compression)`:遍历 `settings`,把每个设置项的 `saveState(compression)` 收进 `{ values: Map<string, unknown> }`。
|
||||
- `loadState(state, compression)`:遍历 `state.values`,命中就转发给该设置项的 `loadState`,**未登记的 id 直接跳过**;**不新增任何日志码**(公共包 `logger.json` 不改)。
|
||||
- **不做**:不构造 / 不注册 / 不调用存档系统,不持有主对象,不引用任何界面。
|
||||
|
||||
④ **工厂 options(四个函数 + 四个 options 接口,请重点确认字段):**
|
||||
- `defineNumberSetting(options: ISettingNumberOptions)`:`id` / `name` / `description` / `defaultValue: number` / `min: number` / `max: number` / `step: number`。
|
||||
- `defineBooleanSetting(options: ISettingBooleanOptions)`:`id` / `name` / `description` / `defaultValue: boolean`。
|
||||
- `defineRangeSetting(options: ISettingRangeOptions)`:字段与数字项相同(`defaultValue` / `min` / `max` / `step`),只是控件形态不同。
|
||||
- `defineSubPageSetting(options: ISettingSubPageOptions)`:`id` / `name` / `description` / `page: IGameUI` / 可选 `props?: Record<string, unknown>`(打开时原样交给控制器)。
|
||||
- 四个函数都返回 `ISettingItem<...>`(不返回实现类);每个实现都实现 `getValue` / `setValue` / `getPreviewString` / `getSettingComponent` 与继承来的保存 / 读取。
|
||||
- 数值项(数字与区间)在 `setValue` 内做区间收敛 + 步长对齐(越界值与不齐步长的值都会被纠正),读取时走同一个 `setValue`;预览文本为当前值的字符串形式;布尔预览为「开」/「关」。
|
||||
- 子页面项是**普通的 `ISettingItem<void>`**:自身没有值(`getValue` 返回 `undefined`),保存 / 读取为空操作,预览为空串,控件点击时用 `controller.open(page, props ?? {})` 打开目标页面。
|
||||
- 值通过 vue 的 `shallowRef` 承载:页面与控件的渲染期读取的就是同一个响应式源,改值后预览与控件会自己刷新(设置项本身不是响应式对象)。
|
||||
- **不做**参数校验(没有合适的日志码),越界由收敛兜底。
|
||||
- **工厂不写运行期单元测试**:`factories.ts` 静态导入 `controls.tsx`,`controls.tsx` 静态导入 `@motajs/render`(导入期即需要 DOM),所以任何 `import '../factories'` 的测试都会在导入期失败。因此只对 `system.ts`(不导入控件)写单元测试;工厂 options、收敛 / 对齐、布尔「开 / 关」预览、子页面空串预览改由 `settingPageGate.test.ts` 读源码(`?raw`)做形状断言,**不加 jsdom / DOM mock**。若你希望工厂也有运行期测试,需要先把「设置项创建」与「控件创建」拆成两个模块(会扩大范围,需另行批准)。
|
||||
|
||||
⑤ **控件行为(四个控件,统一只接收 `setting` + `controller`,在 [0,0] 为原点的固定 180×24 区域绘制):**
|
||||
- 数字控件:左「-」按钮、中间当前值文本、右「+」按钮;每次按一个 `step` 增减,交给设置项收敛。
|
||||
- 布尔控件:单个开关按钮,显示「开」/「关」,点击取反。
|
||||
- 区间控件:轨道 + 已填充部分 + 滑块;按下与拖拽按指针横坐标线性映射到 `min..max`,交给设置项按 `step` 对齐;抬起或离开结束拖拽。
|
||||
- 子页面控件:可点击行(「打开」+ 右箭头),点击打开目标页面。
|
||||
- **为什么是「创建函数」而不是四个固定组件**:`ISettingItem` 与 `ISettingComponentProps` 都不含 `step` / `min` / `max`,而你已锁定 `ISettingComponentProps` 只加 `controller`。因此由工厂创建控件并用闭包把该设置项的 `step` / `min` / `max` / `page` 交进去——不新增接口成员、不做类型断言、不引入模块级可变注册表。组件来源仍是 `item.getSettingComponent()`,界面侧写法不变。
|
||||
|
||||
⑥ **`SettingPage` 布局(请重点确认):**
|
||||
- props:`SettingPageProps extends IGameUIPropsBase`,唯一新增成员 `readonly system: ISettingSystem`。
|
||||
- 左栏:`ListPage` 的列表,由 `iterateGroups()` 生成「分组 id → 分组名」;行高 24、分组列宽 180(按屏宽换算比例交给 `ListPage`)。
|
||||
- 右栏:一条 `Scroll`,平铺当前分组的设置行;每行显示名称、描述(长文本用 `TextContent`,支持换行)、`getPreviewString()` 的预览文本,右侧固定控件区内渲染该行的控件。
|
||||
- 控件渲染方式固定为:`const Control = item.getSettingComponent();` 后 `<Control setting={item} controller={props.controller} />`。
|
||||
- 关闭:`@common/exit` 热键命令 + `ListPage` 内置的关闭文本(`close` / `closeLoc` / `onClose`),界面文件不自行绘制关闭文本。
|
||||
- 导出:`SettingPage` 与 `SettingPageUI = new GameUI('setting-page', SettingPage)`;**不做**顶层全局 / 不做游戏根组装,调用方自行组装。
|
||||
- 左栏只列真实分组,**不为未分组项造合成分组**(未分组项可通过 `getUngrouped()` 取到,但不进入本界面);**分组为空时不渲染 `ListPage`**(列表组件会读第一项),改为渲染一个**带 `loc={CONTENT_SCROLL_LOC}` 的空 `Scroll` 占位**,避免空系统直接崩;该空分支**没有 `ListPage` 的关闭文本**,此时只有 `@common/exit` 热键能退出——这是空系统的既定行为,会在 Task 0 汇报与 SUMMARY 中写明。
|
||||
- 组件返回值固定为 `return () => renderPage();`(显式返回渲染函数,不在顶层直接求值、不写成隐式表达式)。
|
||||
|
||||
⑦ **界面 / 控件分工(硬边界):**
|
||||
- `client-base`:系统、工厂、控件、类型。控件文件里**不构造任何界面对象**。
|
||||
- `src/client/ui/setting.tsx`:只放页面,**不定义控件组件**。
|
||||
- **不接线**:渲染端主对象不加字段、不加 `globalSetting` / `gameSettings` 之类的字段、不注册存档、不进 `src/client/ui/settings.tsx`(设置菜单)、不改 `src/client/ui/index.ts`、不改入口。
|
||||
|
||||
⑧ **影响范围:** 新增 5 个源码文件 + **2** 个测试文件,修改 1 个既有文件(`setting/types.ts` 只做加法、`setting/index.ts` 只补桶导出);另有 3 个 `.planning` 产物(本计划、基线、摘要)——合计 **11 个路径 / 估算 80k**。**范围告警(原 12 文件 / 90k)已被显式接受、不拆分**(理由见 OI-5:四层是同一纵向切片,拆分会造成 `types.ts` / `factories.ts` / `controls.tsx` 的同文件跨计划依赖)。受保护(**不改**)的 10 个文件:`client-base/src/index.ts`、`client-base/src/types.ts`、`client-base/src/hotkey/hotkey.ts`、`client-base/src/hotkey/types.ts`、`client-base/src/components/index.ts`、`client-base/package.json`、`packages-user/data-common/src/save/types.ts`、`src/client/ui/index.ts`、`src/client/ui/settings.tsx`、`packages/common/src/logger.json`。**执行必须在当前脏工作树内**(`client-base/src/index.ts` 的 `export * from './setting';` 不在 HEAD)。
|
||||
|
||||
⑨ **命名报备(OI-1,AGENTS.md 要求先获批):** 见本计划 frontmatter 的 `open_items` 第一条,涵盖 4 个 options 接口、5 个实现类、4 个工厂、4 个控件创建函数、界面与内部常量。**其中多数来自你本次派发的措辞**;`create*SettingControl` 系列、`clampSettingValue` 与界面内部常量为本计划提议。若你要改名,请在回复里给出替换名。
|
||||
|
||||
⑩ **设计待确认(OI-2):** ①控件用创建函数 + 闭包承载 `step` / `min` / `max` / `page`;②界面不为未分组项造合成分组;③工厂不做参数校验;④系统不做 id 重复告警(重复登记以最后一次为准)、`loadState` 缺 id 静默跳过(不新增日志码)。
|
||||
|
||||
⑪ **验证方式(`pnpm` 在本树不可用,全部改直连二进制):**
|
||||
1. **两个**新测试文件:`system.test.ts`(分组 / 取消分组 / 未分组 / 取值 / 存档聚合与读取 / 未知 id 跳过)用 `node node_modules/vitest/vitest.mjs run <path>` 跑通;`settingPageGate.test.ts`(源码形状闸门:`ListPage` 使用、空系统 `Scroll` 占位、控件 props、`return () => renderPage()`、无行内处理函数、无多行内联 `loc`、界面层无持久化、`client-base` 无界面对象、四个工厂 / 四个控件 / 预览文本形状、桶导出与菜单未接入)同样用它跑通。**没有 `factories.test.ts`**。
|
||||
2. `node node_modules/eslint/bin/eslint.js <8 个目标路径>` 0 报错。
|
||||
3. `node node_modules/vue-tsc/bin/vue-tsc.js --noEmit` 前后基线比较:6 个改动源码文件内 **NEW 诊断数为 0**,整仓诊断总数不高于基线。
|
||||
4. `src/client/ui/__test__` **只跑** `settingPageGate.test.ts` / `hotkeySettingsGate.test.ts` 两个文件并全部通过;**不跑整个目录、也不宣称既有闸门全部通过**。`keyAdaptationGate.test.ts` 的 **5/15 失败**与 `uiContainerGate.test.ts` 的 **7/18 失败**均属既有、范围外(两个文件都不在 `files_modified` 中),只在基线记录、SUMMARY 说明,均不作门禁。
|
||||
5. 10 个受保护文件哈希一致(`client-base/src/index.ts` 的 `./setting` 导出必须仍在)、HEAD 政策、范围闸门。
|
||||
6. **不做浏览器 UAT**(本计划没有任何浏览器运行验证);**执行器不提交任何代码**(禁止 git 写命令,覆盖执行器的提交协议;`.planning` 产物可能由编排器聚合提交,不算本计划的代码提交)。
|
||||
|
||||
⑫ **三条铁律:** 不接线、不做浏览器 UAT、执行器 0 次代码提交(禁止任何 git 写命令;该禁止显式覆盖执行器的提交协议,HEAD 除 `.planning` 聚合提交外不得变化)。
|
||||
</action>
|
||||
<how-to-verify>
|
||||
逐条核对 ①–⑫ 是否与你的意图一致,特别是:③ `SettingSystem` 的全部公开成员语义(空数组删组、`addSettingToGroup` no-op、未分组来源、`loadState` 跳过未知 id 且不加日志码);④ 四个工厂的 options 字段与「数值项在 `setValue` 内收敛 + 对齐」,以及**工厂不开运行期单元测试**这一取舍;⑤ 四个控件的行为与「创建函数 + 闭包」这一形态;⑥ `SettingPage` 的布局(`ListPage` 左栏分组 + 一条 `Scroll` 右栏 + `TextContent` 描述 + 固定控件区)、「不为未分组项造合成分组」、空系统只渲染 `loc={CONTENT_SCROLL_LOC}` 的 `Scroll` 占位且只有 `@common/exit` 可退出,以及 `return () => renderPage();`;⑦ 界面 / 控件的硬边界与「不接线」;⑧ **11 个路径**与 10 个受保护文件、范围告警已被接受、必须在当前工作树内执行;⑨ 命名清单;⑪ 验证口径(直连二进制 + 两个新测试 + 只跑 `settingPageGate` / `hotkeySettingsGate` 两个既有闸门 + `keyAdaptationGate` 5/15 与 `uiContainerGate` 7/18 既有失败仅记录 + 无浏览器 UAT + 不提交)。
|
||||
</how-to-verify>
|
||||
<resume-signal>仅当完全同意时回复恰好四个字「可以执行」;任何修改意见(**尤其是 ③ 的系统语义、⑤ 的控件形态、⑥ 的页面布局、⑨ 的命名**)请直接说明,本计划会据此改写后再来一次汇报。</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: 落基线并打通「类型 → 系统 → 数字工厂 → 数字控件 → 界面」一条路径</name>
|
||||
<precondition>`packages-user/client-base/src/index.ts` 含 `export * from './setting';`(该行只存在于当前脏工作树、不在 HEAD),且当前目录就是该工作树;`node_modules/vue-tsc/bin/vue-tsc.js`、`node_modules/eslint/bin/eslint.js`、`node_modules/vitest/vitest.mjs` 均存在;`09-34-BASELINE.json` 尚不存在。</precondition>
|
||||
<files>.planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json(新增,第一步写出), packages-user/client-base/src/setting/types.ts, packages-user/client-base/src/setting/system.ts, packages-user/client-base/src/setting/factories.ts, packages-user/client-base/src/setting/controls.tsx, packages-user/client-base/src/setting/index.ts, src/client/ui/setting.tsx</files>
|
||||
<action>
|
||||
**依赖 Task 0 已收到「可以执行」。不得运行任何 git 写命令、不得提交任何代码**(该禁止显式覆盖执行器的提交协议;HEAD 除 `.planning` 聚合提交外必须与基线相同)。本任务只打通一条路径:一个数字设置项从工厂产出、被系统登记、在界面上以「名称 + 描述 + 预览 + 控件」渲染出来并可改值。布尔 / 区间 / 子页面在 Task 2 扩展。
|
||||
|
||||
**第零步(fail-fast,写基线之前):** 断言 `packages-user/client-base/src/index.ts` 含 `export * from './setting';`。缺失即停下上报——说明当前不在预期的工作树内;**不得**把该文件加入 `files_modified`、不得补这一行、不得切分支 / worktree。同时确认 `node_modules/vue-tsc/bin/vue-tsc.js` / `node_modules/eslint/bin/eslint.js` / `node_modules/vitest/vitest.mjs` 存在(`pnpm` 在本树已损坏,**不修 workspace / `package.json`**)。
|
||||
|
||||
**第一步(必须在写入任何代码之前):** 写出 `.planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json`,schema 为 `{ head, hashes, dirty, typecheck, typecheckTotal }`(另含可选信息字段 `keyAdaptation` / `uiContainer`):
|
||||
- `head`:当前 `git rev-parse HEAD`。
|
||||
- `hashes`:**10 个**受保护文件的 `git hash-object`(完整仓库相对路径、POSIX 正斜杠、小写 sha):`packages-user/client-base/src/index.ts`、`packages-user/client-base/src/types.ts`、`packages-user/client-base/src/hotkey/hotkey.ts`、`packages-user/client-base/src/hotkey/types.ts`、`packages-user/client-base/src/components/index.ts`、`packages-user/client-base/package.json`、`packages-user/data-common/src/save/types.ts`、`src/client/ui/index.ts`、`src/client/ui/settings.tsx`、`packages/common/src/logger.json`。
|
||||
- `dirty`:`git status --porcelain -uall`(不加 pathspec),每行取前 3 个字符之后的路径并 `trim()`,剔除以 `.planning/` 开头的条目,按字典序写入。
|
||||
- `typecheck`:整仓跑 `node node_modules/vue-tsc/bin/vue-tsc.js --noEmit`(`pnpm` 在本树不可用,**不得**改用 `pnpm` / `npx`),把匹配 `error TS` 加数字的行按归一化口径处理——反斜杠转正斜杠、去掉 `(行,列):` 片段、截取到首个 `src/` 或 `packages-user/` 起的部分——**只保留以下 6 个 scope 前缀开头的条目**,按行写入数组(保留重复与顺序):`packages-user/client-base/src/setting/types.ts`、`packages-user/client-base/src/setting/system.ts`、`packages-user/client-base/src/setting/factories.ts`、`packages-user/client-base/src/setting/controls.tsx`、`packages-user/client-base/src/setting/index.ts`、`src/client/ui/setting.tsx`。
|
||||
- `typecheckTotal`:同一份输出中匹配 `error TS` 加数字的总行数。
|
||||
- `keyAdaptation`(可选、信息字段):用 `node node_modules/vitest/vitest.mjs run src/client/ui/__test__/keyAdaptationGate.test.ts` 单独采集既有失败数,写入 `{ file, failed, passed, total }`。**只记录、不作门禁**;该文件有 5/15 既有失败,属范围外。
|
||||
- `uiContainer`(可选、信息字段):用 `node node_modules/vitest/vitest.mjs run src/client/ui/__test__/uiContainerGate.test.ts` 单独采集既有失败数,写入同样的 `{ file, failed, passed, total }`。**只记录、不作门禁**;该文件当前有 7/18 既有失败(文件不在 `files_modified` 中,不是本计划造成),属范围外。
|
||||
- 类型检查可能以非零退出(工作树本就有既有诊断),只要输出可解析就照常采集;只有未运行 / 空输出 / 无法解析出任何诊断行时才停下上报。**本文件只写一次,此后不得重写。**
|
||||
|
||||
**第二步:`packages-user/client-base/src/setting/types.ts`(只做加法,既有行逐字不动)。**
|
||||
1. 导入区改为 4 行普通导入、连续无空行无注释、其后不得再有导入:第 1 行 `import { ISaveableContent } from '@user/data-common';`;第 2 行 `import { IGameUI, IUIController } from '@motajs/system';`;第 3 行 `import { DefineComponent, DefineSetupFnComponent } from 'vue';`;第 4 行 `import { IGameUIPropsBase } from '../types';`。
|
||||
2. `ISettingComponentProps<T>` 在 `setting` 之后追加 `readonly controller: IUIController<IGameUIPropsBase>;`,jsDoc 说明这是当前设置组件所属的 UI 控制器;`setting` 成员与文件其余声明**不得改动一个字**。
|
||||
3. 文件末尾空一行后追加 4 个 options 接口,顺序为数字 / 布尔 / 区间 / 子页面,成员全部 `readonly`,逐成员中文 jsDoc(每个接口都不写接口本身的注释):
|
||||
- `ISettingNumberOptions`:`id` / `name` / `description` / `defaultValue: number` / `min: number` / `max: number` / `step: number`。
|
||||
- `ISettingBooleanOptions`:`id` / `name` / `description` / `defaultValue: boolean`。
|
||||
- `ISettingRangeOptions`:与 `ISettingNumberOptions` 同字段(`id` / `name` / `description` / `defaultValue: number` / `min` / `max` / `step`)。
|
||||
- `ISettingSubPageOptions`:`id` / `name` / `description` / `page: IGameUI` / 可选 `props?: Record<string, unknown>`(打开子页面时原样交给控制器)。
|
||||
4. **不得**恢复 `ISettingSubPageItem`,**不得**改 `ISettingItem` / `ISettingItemGroup` / `ISettingSystemSave` / `ISettingSystem` 的任何成员。
|
||||
|
||||
**第三步:`packages-user/client-base/src/setting/system.ts`(新建)。**
|
||||
- 只有 `import` + 一个 `export class SettingSystem implements ISettingSystem`;导入 `ISettingItem` / `ISettingItemGroup` / `ISettingSystem` / `ISettingSystemSave`(自 `./types`)与 `SaveCompression`(自 `@user/data-common`)。
|
||||
- 两个成员:公开只读 `groups: Map<string, ISettingItemGroup>`(接口要求公开只读,引用只读、原地增删)、私有 `private readonly settings: Map<string, ISettingItem>`,显式标注类型与初始值(形状闸门逐字断言 `readonly groups: Map<string, ISettingItemGroup>` 与 `private readonly settings` 两段文本)。
|
||||
- `setGroup(group, name, items)`:`items` 长度非零时,先按 `item.id` 逐个登记进 `settings`,再 `groups.set(group, { name, items })`;长度为零时只做 `groups.delete(group)`,**不删除** `settings` 里的登记。
|
||||
- `addSettingToGroup(group, item)`:`const obj = this.groups.get(group);` 若 `!obj` 直接 `return`;否则把该项登记进 `settings`,并在分组项里不存在该项(以引用比较)时用「原数组切片 + 追加」的新数组替换分组(`{ name: obj.name, items }`),**不得**在 `readonly` 数组上原地追加。
|
||||
- `getGroup` / `getSetting`:缺失返回 `null`(用 `?? null`);`iterateGroups` 返回 `this.groups.entries()`。
|
||||
- `getUngrouped()`:先收集所有分组里的设置项引用(`Set<ISettingItem>`),再遍历 `settings.values()` 把不在集合里的项收集进数组返回;不要用需要更高 ES 版本的集合差集方法。
|
||||
- `getValue<T>(id)`:未登记返回 `void 0`,否则返回该设置项的 `getValue()`(此处允许一次必要的 `as T`,因为接口只给出 `unknown`);`setValue<T>(id, value)`:未登记直接 `return`,否则转发(不需要断言)。
|
||||
- `saveState(compression)`:新建 `Map<string, unknown>`,遍历 `settings` 逐个写入 `item.saveState(compression)`,返回 `{ values }`。
|
||||
- `loadState(state, compression)`:遍历 `state.values`,未登记 id 用一行注释说明「未登记设置项直接跳过」后 `continue`,否则 `item.loadState(value, compression)`;**不得**引入 logger、不得新增日志码。
|
||||
- 不出现任何界面对象、主对象、存档系统调用;`implements` 而来的 API 不重复写 jsDoc(dev.md),只对模块内部新增语义写必要注释。
|
||||
|
||||
**第四步:`packages-user/client-base/src/setting/factories.ts`(新建,本任务只落数字项)。**
|
||||
- 导入(普通导入,无 `import type`):`shallowRef` / `ShallowRef` / `DefineSetupFnComponent`(自 `vue`)、`ISettingComponentProps` / `ISettingItem` / `ISettingNumberOptions`(自 `./types`)、`createNumberSettingControl`(自 `./controls`)。四个实现类都要把控件成员写成显式类型(见下),因此 `ShallowRef` 与 `DefineSetupFnComponent` 必须真的用上,不得留未使用导入。
|
||||
- 模块级私有函数 `clampSettingValue(value: number, min: number, max: number, step: number): number`:先按 `step` 对齐(`Math.round((value - min) / step) * step + min`),再用 `Math.min` / `Math.max` 收敛到 `[min, max]`,最后用 `Number(x.toFixed(6))` 消除浮点尾差;附中文 jsDoc 说明用途。
|
||||
- 模块内私有类 `NumberSetting implements ISettingItem<number>`:`readonly id` / `readonly name` / `readonly description`(构造器属性声明即可,不写构造器注释)、`private readonly min: number` / `max: number` / `step: number`、`private readonly value: ShallowRef<number>`(`shallowRef(options.defaultValue)`)、`private readonly control: DefineSetupFnComponent<ISettingComponentProps<number>>`(构造时用 `createNumberSettingControl(options.step)` 创建一次并复用)。若该返回类型不可赋值,改用 `ReturnType<typeof createNumberSettingControl>` 作为成员类型,**不得**用 `as` 断言或改接口。
|
||||
- `getValue()` 返回 `value.value`;`setValue(value)` 用 `clampSettingValue` 收敛后写回;`getPreviewString()` 返回当前值的字符串;`getSettingComponent()` 返回缓存的那个控件;`saveState()` 返回当前值;`loadState(state)` 复用 `setValue(state)`。**所有方法与局部函数显式标注参数类型**(如 `setValue(value: number): void`、`loadState(state: number): void`,与 `ISaveableContent` 的签名保持一致)。
|
||||
- `export function defineNumberSetting(options: ISettingNumberOptions): ISettingItem<number>`:`return new NumberSetting(options);`。
|
||||
- 所有成员显式标注类型;不使用 `?.`;除第三步 `getValue<T>` 那一次外不使用 `as`。
|
||||
|
||||
**第五步:`packages-user/client-base/src/setting/controls.tsx`(新建,本任务只落数字控件)。**
|
||||
- 导入(普通导入,无 `import type`):`defineComponent`(自 `vue`)、`ElementLocator` 与 `Font`(自 `@motajs/render`)、`SetupComponentOptions`(自 `@motajs/system`)、`ISettingComponentProps`(自 `./types`)。Task 2 追加的区间控件还需要 `ref`(自 `vue`),届时在同一行导入里补上,不得出现未使用导入。
|
||||
- 模块常量(全大写 + 下划线、逐个中文 jsDoc):控件区宽高 180×24、按钮宽 24、字体 `Font.defaults({ size: 13 })`;`ElementLocator` 常量:减号按钮、加号按钮、数值文本、控件区。**不得**在 JSX 属性里直接写多行数组。
|
||||
- `createNumberSettingControl(step: number)`:返回 `defineComponent<ISettingComponentProps>(...)`(返回类型由 `defineComponent` 推断,不手写联合类型注解)。**注意 `defineComponent<ISettingComponentProps>` 的 `T` 默认是 `unknown`**,因此**不要**给 `props.setting` 加 `ISettingItem<number>` 注解(`ISettingItem<unknown>` 不能赋给 `ISettingItem<number>`),也**不得**用 `as`;props 配置用 `{ props: ['setting', 'controller'] }` 并 `satisfies SetupComponentOptions<ISettingComponentProps>`;`step` 是闭包参数,必须显式写成 `step: number`。
|
||||
- 组件内 `decrease(): void` / `increase(): void` 两个命名函数:`const item = props.setting;`(**不加类型注解**,类型即 `ISettingItem<unknown>`),用 `Number(item.getValue())` 读当前值(`Number(...)` 接受 `unknown`,不用断言),`item.setValue(Number(item.getValue()) ± step)`——收敛交给设置项,控件不重复实现区间逻辑。**控件不构造任何界面对象,也不引用 `IGameUI`**。
|
||||
- 根元素为 `<container loc={CONTROL_LOC}>`:左按钮矩形(`g-rectr` + 居中文字「-」)、中间文本(内容取 `props.setting.getPreviewString()`,锚点 [0.5, 0.5])、右按钮矩形 + 「+」;按钮 `cursor="pointer"`,点击属性只接受上面两个命名函数引用(不写行内匿名函数)。
|
||||
- 不出现界面对象,不持有主对象。
|
||||
|
||||
**第六步:`packages-user/client-base/src/setting/index.ts`(修改)。**
|
||||
- 内容恰好 4 行同目录桶导出:`export * from './controls';`、`export * from './factories';`、`export * from './system';`、`export * from './types';`(保持字母序)。不得导出当前目录之外的内容。
|
||||
|
||||
**第七步:`src/client/ui/setting.tsx`(新建,通用页面)。**
|
||||
- 导入(普通导入,无 `import type`):`GameUI` / `SetupComponentOptions`(自 `@motajs/system`)、`defineComponent` 与 `ref`(自 `vue`)、`ElementLocator` / `Font`(自 `@motajs/render`)、`IGameUIPropsBase` / `ISettingItem` / `ISettingSystem` / `ListPage` / `Scroll` / `TextContent` / `useKey`(自 `@user/client-base`)、`FULL_LOC` / `MAIN_HEIGHT` / `MAIN_WIDTH`(自 `@/client/uiConstant`)。只在真的用到时才导入(不得留未使用导入);若渲染联合组件需要额外类型,按需补充 `DefineSetupFnComponent` / `ISettingComponentProps`,且**不得**使用 `as`。
|
||||
- 模块常量(逐个中文 jsDoc):左内边距 / 上内边距 16、分组列宽 180 及其占总宽比例、内容区宽、列表起止纵坐标与高度(底部留出关闭文本空间)、分组行高 24、行高 64、行间距 12、行宽、行内文本宽度、描述纵坐标、预览纵坐标、控件顶偏移、关闭文本定位、内容字体(`Font.defaults({ size: 13 })`);`CONTENT_SCROLL_LOC` / `CLOSE_LOC` / `CONTROL_AREA_LOC` 三个 `ElementLocator` 常量。
|
||||
- `SettingPageProps extends IGameUIPropsBase`,唯一新增成员 `readonly system: ISettingSystem`(写中文 jsDoc);`settingPageProps = { props: ['controller', 'instance', 'state', 'system'] } satisfies SetupComponentOptions<SettingPageProps>`。
|
||||
- 组件内命名函数(全部中文 jsDoc,组件顶层无副作用,只有 `ref` 与常量初始化;**所有局部函数显式标注参数类型**——有参数写具体类型,无参数写 `(): void`;返回 JSX 的函数把返回类型交给推断,不写 `any`、不写 `as`):
|
||||
- `groupItems: [string, string][]`:显式标注为 `[string, string][]`,值由 `Array.from(props.system.iterateGroups())` 映射为 `[id, name]`。
|
||||
- `selectedGroup`:`const selectedGroup = ref<string>(...)`(显式泛型,类型由 `ref` 推断为 `Ref<string>`),默认取 `groupItems` 第一项的 id,没有分组时为空串。
|
||||
- `selectGroup(id: string): void`:更新 `selectedGroup`。
|
||||
- `exit(): void`:`props.controller.close(props.instance)`。
|
||||
- `renderRow(item: ISettingItem, index: number)`:`rowTop = index * (ROW_HEIGHT + ROW_GAP)`;局部声明 `ElementLocator`(行、名称、描述、预览)后再返回行容器——名称文本、`TextContent`(`text={item.description}`、`width={ROW_TEXT_WIDTH}`、`autoHeight`、`noevent`)、预览文本(`item.getPreviewString()`);行内右侧再嵌一个 `CONTROL_AREA_LOC` 容器,其中 `const Control = item.getSettingComponent();` 后渲染 `<Control setting={item} controller={props.controller} />`(沿用 `packages/system/src/ui/container.tsx` 里渲染联合类型组件变量的既有写法,不得用 `as` 收窄)。
|
||||
- `renderGroup(key: string)`:`const group = props.system.getGroup(key);` 为 `null` 时返回空数组;否则返回 `<Scroll loc={CONTENT_SCROLL_LOC}>{group.items.map(renderRow)}</Scroll>`(把命名函数直接交给 `map`,不写行内回调)。
|
||||
- `renderPage()`:`groupItems` 为空时返回一个**带 `loc={CONTENT_SCROLL_LOC}` 的空 `Scroll` 占位**(`ListPage` 的列表会读第一项,空列表不能进 `ListPage`);该空分支**没有 `ListPage` 的关闭文本**,此时只有 `@common/exit` 可退出。否则返回 `ListPage`。
|
||||
- 组件返回固定为 `return () => renderPage();`(显式返回渲染函数)。
|
||||
- 关闭:`const key = useKey(props.state.hotkey.system)!;` 然后 `key.implement('@common/exit', exit);`(命名函数引用)。
|
||||
- 返回 `<ListPage loc={FULL_LOC} basis={GROUP_BASIS} list={groupItems} selected={selectedGroup.value} lineHeight={GROUP_ROW_HEIGHT} font={CONTENT_FONT} close closeLoc={CLOSE_LOC} onUpdate={selectGroup} onClose={exit}>` 并以 `{{ default: renderGroup }}` 作为内容插槽。
|
||||
- 导出 `SettingPageUI = new GameUI('setting-page', SettingPage);`,并在其上写 `/** @see {@link SettingPage} */`。
|
||||
- **不得**在本文件定义任何控件组件;**不得**出现任何持久化读写;**不得**引用渲染端主对象上的设置字段;**不得**自行绘制关闭文本;**不得**接入 UI 桶 / 设置菜单。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>node -e "const fs=require('fs');const b='.planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json';const bad=[];if(!fs.existsSync(b))bad.push('baseline missing');else{let j={};try{j=JSON.parse(fs.readFileSync(b,'utf8'))}catch(e){bad.push('baseline unparsable')}for(const k of ['head','hashes','dirty','typecheck','typecheckTotal'])if(!(k in j))bad.push('missing '+k);const ks=Object.keys(j.hashes||{});if(ks.length!==10)bad.push('hashes must have 10 entries, got '+ks.length);for(const f of ['packages-user/client-base/src/index.ts','packages-user/client-base/src/types.ts','packages-user/client-base/src/hotkey/hotkey.ts','packages-user/client-base/src/hotkey/types.ts','packages-user/client-base/src/components/index.ts','packages-user/client-base/package.json','packages-user/data-common/src/save/types.ts','src/client/ui/index.ts','src/client/ui/settings.tsx','packages/common/src/logger.json'])if(!Object.prototype.hasOwnProperty.call(j.hashes||{},f))bad.push('hashes missing '+f);if(!Array.isArray(j.dirty))bad.push('dirty must be an array');if(!Array.isArray(j.typecheck))bad.push('typecheck baseline must be an array');if(typeof j.typecheckTotal!=='number')bad.push('typecheckTotal must be a number')}if(bad.length){console.error('TASK1 BASELINE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task1: baseline')"</automated>
|
||||
<automated>node -e "const fs=require('fs');const bad=[];const rd=p=>fs.readFileSync(p,'utf8');const strip=s=>s.replace(/\{\s*\/\*[^]*?\*\/\s*\}/g,'').replace(/\/\*[^]*?\*\//g,'').split(/\r?\n/).map(l=>l.replace(/\/\/.*$/,'')).join('\n');const t=strip(rd('packages-user/client-base/src/setting/types.ts'));for(const n of ['export interface ISettingItem<T = unknown> extends ISaveableContent<T> {','readonly id: string;','readonly name: string;','readonly description: string;','getValue(): T;','setValue(value: T): void;','getPreviewString(): string;','getSettingComponent():','export interface ISettingItemGroup {','readonly items: readonly ISettingItem[];','export interface ISettingSystemSave {','readonly values: ReadonlyMap<string, unknown>;','export interface ISettingSystem extends ISaveableContent<ISettingSystemSave> {','readonly groups: ReadonlyMap<string, ISettingItemGroup>;','setGroup(group: string, name: string, items: ISettingItem[]): void;','addSettingToGroup(group: string, item: ISettingItem): void;','getGroup(group: string): ISettingItemGroup | null;','getUngrouped(): Iterable<ISettingItem>;','getSetting(id: string): ISettingItem | null;','iterateGroups(): Iterable<[id: string, group: ISettingItemGroup]>;','getValue<T>(id: string): T | undefined;','setValue<T>(id: string, value: T): void;','readonly setting: ISettingItem<T>;','readonly controller: IUIController<IGameUIPropsBase>;','export interface ISettingNumberOptions {','export interface ISettingBooleanOptions {','export interface ISettingRangeOptions {','export interface ISettingSubPageOptions {','readonly defaultValue: number;','readonly defaultValue: boolean;','readonly min: number;','readonly max: number;','readonly step: number;','readonly page: IGameUI;','readonly props?: Record<string, unknown>;'])if(t.indexOf(n)<0)bad.push('types.ts missing :: '+n);if(t.indexOf('ISettingSubPageItem')>=0)bad.push('ISettingSubPageItem must not exist');if(t.indexOf('import type')>=0)bad.push('type-only import forbidden');const s=strip(rd('packages-user/client-base/src/setting/system.ts'));if(s.indexOf('implements ISettingSystem')<0)bad.push('system must implement ISettingSystem');if(s.indexOf('readonly groups: Map<string, ISettingItemGroup>')<0||s.indexOf('private readonly settings')<0)bad.push('system group map (public readonly) or private settings map missing');if(/new\s+GameUI\s*\(|IClientBase|addSaveableContent/.test(s))bad.push('system must not construct ui objects / reference main object / save registration');const f=strip(rd('packages-user/client-base/src/setting/factories.ts'));if(f.indexOf('export function defineNumberSetting')<0)bad.push('defineNumberSetting missing');if(f.indexOf('clampSettingValue')<0)bad.push('clampSettingValue missing');if(f.indexOf('implements ISettingItem<number>')<0)bad.push('NumberSetting must implement ISettingItem<number>');const c=strip(rd('packages-user/client-base/src/setting/controls.tsx'));if(c.indexOf('createNumberSettingControl')<0)bad.push('createNumberSettingControl missing');if(/new\s+GameUI\s*\(/.test(c))bad.push('controls must not construct ui objects');const idx=strip(rd('packages-user/client-base/src/setting/index.ts')).split(/\r?\n/).map(l=>l.trim()).filter(Boolean);if(idx.length!==4)bad.push('setting barrel must have exactly 4 same-dir exports');for(const n of ['./controls','./factories','./system','./types'])if(!idx.some(l=>l.indexOf(n)>0))bad.push('setting barrel missing '+n);const p=strip(rd('src/client/ui/setting.tsx'));for(const n of ['<ListPage','close','closeLoc={CLOSE_LOC}','onClose={exit}','onUpdate={selectGroup}','@common/exit','getSettingComponent()','setting={item}','controller={props.controller}','TextContent','Scroll','SettingPageUI','renderPage','return () => renderPage();','new GameUI('])if(p.indexOf(n)<0)bad.push('setting.tsx missing :: '+n);for(const n of ['saveState','loadState','globalSetting','gameSettings'])if(p.indexOf(n)>=0)bad.push('setting.tsx must not contain :: '+n);if(p.indexOf('text='+String.fromCharCode(34)+'关闭'+String.fromCharCode(34))>=0)bad.push('setting.tsx must not draw the list close text itself');if(/loc=\{\[\s*\r?\n/.test(p))bad.push('multiline inline loc found');if(/on[A-Z][A-Za-z]*=\{\s*(\(|\w+\s*=>)/.test(p))bad.push('inline handler found');if(bad.length){console.error('TASK1 SHAPE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task1: shapes')"</automated>
|
||||
<automated>node node_modules/eslint/bin/eslint.js packages-user/client-base/src/setting/types.ts packages-user/client-base/src/setting/system.ts packages-user/client-base/src/setting/factories.ts packages-user/client-base/src/setting/controls.tsx packages-user/client-base/src/setting/index.ts src/client/ui/setting.tsx</automated>
|
||||
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json','utf8'));const scope=['packages-user/client-base/src/setting/types.ts','packages-user/client-base/src/setting/system.ts','packages-user/client-base/src/setting/factories.ts','packages-user/client-base/src/setting/controls.tsx','packages-user/client-base/src/setting/index.ts','src/client/ui/setting.tsx'];const r=cp.spawnSync(process.execPath,['node_modules/vue-tsc/bin/vue-tsc.js','--noEmit'],{encoding:'utf8',timeout:900000});const text=(r.stdout||'')+(r.stderr||'');const diag=text.split(/\r?\n/).filter(l=>/error TS[0-9]+/.test(l));const bad=[];if(r.status===null)bad.push('vue-tsc did not run');else if(r.status!==0&&diag.length===0)bad.push('vue-tsc crashed with no parseable diagnostics');const norm=l=>l.replace(/\\/g,'/').replace(/\([0-9]+,[0-9]+\):/,'').replace(/^.*?(?=(src|packages-user)\/)/,'').trim();const cur=diag.map(norm).filter(x=>scope.some(s=>x.indexOf(s)===0));const before=(b.typecheck||[]).filter(x=>scope.some(s=>x.indexOf(s)===0));const count=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(count(cur,v)>count(before,v))bad.push('NEW diagnostic :: '+v)}if(diag.length>(b.typecheckTotal||0))bad.push('total diagnostics increased :: '+diag.length+' > '+b.typecheckTotal);if(bad.length){console.error('TYPECHECK GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK typecheck: '+cur.length+' in-scope diagnostics, '+diag.length+' total')"</automated>
|
||||
<fails_when>基线缺失或字段残缺、`hashes` 不是 10 条、`dirty` / `typecheck` 不是数组、`typecheckTotal` 不是数字;包级桶 `packages-user/client-base/src/index.ts` 不含 `export * from './setting';`;`types.ts` 缺少任一必需声明或出现 `ISettingSubPageItem`;`system.ts` 未 `implements ISettingSystem`、缺少 `readonly groups: Map<string, ISettingItemGroup>` 或 `private readonly settings` 表、或引用了界面对象构造 / 主对象 / 存档注册;`factories.ts` 缺 `defineNumberSetting` / `clampSettingValue` / 数值类的 `implements`,或构造界面对象;`controls.tsx` 缺数字控件或构造界面对象(判据是 `new GameUI(` 的调用形态,允许 `IGameUI` 类型引用);`setting/index.ts` 不是恰好 4 行同目录桶导出;`setting.tsx` 缺 `ListPage` / 关闭 / 控件 props / `TextContent` / `Scroll` / `SettingPageUI` / `return () => renderPage()` / 空分支 `loc={CONTENT_SCROLL_LOC}`,或出现持久化调用、主对象设置字段、自行绘制的关闭文本、多行内联 `loc`、行内处理函数;eslint 报错;`node node_modules/vue-tsc/bin/vue-tsc.js --noEmit` 在 6 个 scope 内出现 NEW 诊断或整仓总数上升;任一 node 闸门非零退出。</fails_when>
|
||||
</verify>
|
||||
<done>`09-34-BASELINE.json` 已在写代码前写出且只写一次;包级桶的 `export * from './setting';` 已 fail-fast 断言存在(未修改该文件);`types.ts` 只做加法(`controller` 成员 + 4 个 options 接口)且既有声明逐字保留、没有 `ISettingSubPageItem`;`SettingSystem` 实现了 `ISettingSystem` 的全部成员且不接线;`factories.ts` 提供 `defineNumberSetting`、数值收敛与显式类型的 `control` 成员;`controls.tsx` 提供闭包式的数字控件;`setting/index.ts` 恰好 4 行同目录桶导出;`src/client/ui/setting.tsx` 用 `ListPage` + 一条 `Scroll` + 行渲染 + 命名处理函数 + `@common/exit` 关闭 + `return () => renderPage()`,且不含控件定义与持久化调用;eslint 0 报错;直连 `vue-tsc --noEmit` 无 NEW 诊断且整仓总数不增;**未运行任何 git 写命令、未提交任何代码**。</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: 补齐布尔 / 区间 / 子页面三个工厂与控件,并写系统的单元测试</name>
|
||||
<files>packages-user/client-base/src/setting/factories.ts, packages-user/client-base/src/setting/controls.tsx, packages-user/client-base/src/setting/__test__/system.test.ts(新增)</files>
|
||||
<action>
|
||||
**依赖 Task 1 已完成(路径已通)。不得运行任何 git 写命令、不得提交任何代码。**
|
||||
|
||||
**A. `packages-user/client-base/src/setting/controls.tsx` 追加三个控件创建函数(保留 Task 1 的数字控件与全部常量)。**
|
||||
1. `createBooleanSettingControl()`:返回 `defineComponent<ISettingComponentProps>(...)`;单个可点击开关按钮(矩形 + 居中文本),文本与状态取 `Boolean(props.setting.getValue())` 显示「开」/「关」;`toggle(): void` 命名函数把取反值写回设置项。
|
||||
2. `createRangeSettingControl(min: number, max: number)`:返回组件;`dragging` 用 `ref<boolean>(false)`;`applyOffset(offsetX: number): void` 把 `offsetX / 控件区宽` 收敛到 `[0, 1]` 后映射为 `min + ratio * (max - min)` 并写回设置项(步长对齐由设置项负责,控件不重复实现);`startDrag(event: IActionEvent): void` / `drag(event: IActionEvent): void` / `endDrag(): void` 为命名函数(`IActionEvent` 自 `@motajs/render`,取 `event.offsetX`);轨道上挂 `onDown` / `onMove` / `onUp` / `onLeave`(只传命名函数引用);渲染轨道、按当前值比例计算宽度的填充条、以及滑块位置。
|
||||
3. `createSubPageSettingControl(page: IGameUI, vBind: Record<string, unknown>)`:返回组件;可点击行(文本「打开」+ 右箭头「>」),`openPage(): void` 命名函数执行 `props.controller.open(page, vBind)`;不读写设置项的值。**这里允许 `IGameUI` 类型引用,但不得 `new GameUI(`。**
|
||||
4. 新增控件复用 Task 1 的常量与字体;`ref` 若尚未导入则补进 `vue` 的导入行;不得新引入除 `vue` / `@motajs/render` / `@motajs/system` / `./types` 之外的依赖;不得构造界面对象;不得出现行内处理函数;不得出现多行内联 `loc`。
|
||||
|
||||
**B. `packages-user/client-base/src/setting/factories.ts` 追加三个工厂与三个私有类(保留 Task 1 的数字实现)。**
|
||||
1. `BooleanSetting implements ISettingItem<boolean>`:`private readonly value: ShallowRef<boolean>`;`getPreviewString(): string` 返回「开」/「关」;`saveState(): boolean` / `loadState(state: boolean): void` 复用同一个值;`private readonly control: DefineSetupFnComponent<ISettingComponentProps<boolean>>`(不可赋值时用 `ReturnType<typeof createBooleanSettingControl>`),构造时由 `createBooleanSettingControl()` 创建一次并缓存;导出 `defineBooleanSetting(options: ISettingBooleanOptions): ISettingItem<boolean>`。
|
||||
2. `RangeSetting implements ISettingItem<number>`:字段与数字项一致(`private readonly min: number` / `max: number` / `step: number` / `value: ShallowRef<number>`),`setValue(value: number): void` 复用 `clampSettingValue`,`private readonly control: DefineSetupFnComponent<ISettingComponentProps<number>>`(同上,不可赋值时用 `ReturnType<typeof createRangeSettingControl>`),构造时由 `createRangeSettingControl(options.min, options.max)` 创建一次并缓存;导出 `defineRangeSetting(options: ISettingRangeOptions): ISettingItem<number>`。
|
||||
3. `SubPageSetting implements ISettingItem<void>`:`private readonly page: IGameUI`、`private readonly vBind: Record<string, unknown>`(取 `options.props ?? {}`)、`private readonly control: DefineSetupFnComponent<ISettingComponentProps<void>>`(同上,不可赋值时用 `ReturnType<typeof createSubPageSettingControl>`);`getValue(): void` 返回 `void 0`;`setValue(): void` 为空操作(未使用参数直接不填);`getPreviewString(): string` 返回空串;`saveState(): void` / `loadState(state: void): void` 为空操作;导出 `defineSubPageSetting(options: ISettingSubPageOptions): ISettingItem<void>`。
|
||||
4. 三个新工厂的导入补齐 `IGameUI`(自 `@motajs/system`)与三个新控件创建函数;`ShallowRef` / `DefineSetupFnComponent` / `ISettingComponentProps` 已在 Task 1 导入,确实用到即可;保持单一依赖方向(工厂 → 控件)。**三个新类的所有方法与局部函数都必须显式标注参数类型**(与 `ISaveableContent` 的签名保持一致;无参数写 `(): void`),不得用 `as`。
|
||||
|
||||
**C. `packages-user/client-base/src/setting/__test__/system.test.ts`(新增)。**
|
||||
- 顶部用一个测试内辅助 `createFakeItem(id: string, initial: number)` 造假的 `ISettingItem<number>`(值存普通变量,记录保存 / 读取的调用次数,`getSettingComponent` 返回 `defineComponent<ISettingComponentProps>(() => () => [])`);辅助函数与文件级变量都写中文 jsDoc,**每个 `it` 前必须有一行说明该测试内容的注释**(dev.md 测试规则)。
|
||||
- 覆盖:登记设置项后可按 id 取到;`setGroup` 整体替换分组且分组项被登记;空数组删除分组,但此前登记项仍能按 id 取到并出现在 `getUngrouped()`;`addSettingToGroup` 在分组不存在时是 no-op(不抛错、不登记);`addSettingToGroup` 追加一次且重复加入不产生重复项;`getGroup` / `getSetting` 缺失返回 `null`;`iterateGroups` 返回全部分组条目;`getUngrouped` 不包含已分组项;`getValue` / `setValue` 转发到设置项;`saveState` 聚合所有已登记项并透传压缩级别;`loadState` 逐项写回;`loadState` 遇到未登记 id 跳过且不抛错、也不影响其它项。
|
||||
- 只 `import` 类型、`vue` 的 `defineComponent`、`vitest` 与本目录的 `../system` / `../types`;**不得**导入 `../factories` 或 `../controls`(`controls.tsx` 经 `@motajs/render` 在导入期需要 DOM),不得引入渲染相关模块,不得用 `vi.stubGlobal` / jsdom 造 DOM。若 `../types` 的既有导入在运行环境里仍然缺 DOM,按 OI-3 ③ 停下上报,**不得**改导入结构蒙过。
|
||||
|
||||
**D. 工厂 / 预览不再写运行期测试(明确的取舍,不是遗漏)。**
|
||||
- `factories.ts` 静态导入 `controls.tsx`,`controls.tsx` 静态导入 `@motajs/render`,导入期即需要 DOM,因此 `import '../factories'` 无法在 node 环境成立。**本任务不新建 `factories.test.ts`**,也不改动模块导入结构。
|
||||
- 四个工厂的 options 字段、数值收敛与步长对齐(`clampSettingValue`)、布尔「开 / 关」预览、子页面空串预览与 `getValue()` 返回 `undefined`,由 Task 3 的 `settingPageGate.test.ts` 以 `?raw` 读源码做**形状断言**(不运行模块、不需要 DOM)。
|
||||
- 若你希望工厂也有运行期测试,唯一干净做法是把「设置项创建」与「控件创建」拆成两个模块(会扩大范围、改 `files_modified`),需另行批准——本计划不做。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>node -e "const fs=require('fs');const strip=s=>s.replace(/\{\s*\/\*[^]*?\*\/\s*\}/g,'').replace(/\/\*[^]*?\*\//g,'').split(/\r?\n/).map(l=>l.replace(/\/\/.*$/,'')).join('\n');const bad=[];const f=strip(fs.readFileSync('packages-user/client-base/src/setting/factories.ts','utf8'));for(const n of ['export function defineNumberSetting','export function defineBooleanSetting','export function defineRangeSetting','export function defineSubPageSetting','implements ISettingItem<number>','implements ISettingItem<boolean>','implements ISettingItem<void>','clampSettingValue','shallowRef','ShallowRef','DefineSetupFnComponent','private readonly control'])if(f.indexOf(n)<0)bad.push('factories.ts missing :: '+n);if(/new\s+GameUI\s*\(/.test(f))bad.push('factories.ts must not construct ui objects');const c=strip(fs.readFileSync('packages-user/client-base/src/setting/controls.tsx','utf8'));for(const n of ['createNumberSettingControl','createBooleanSettingControl','createRangeSettingControl','createSubPageSettingControl','onDown','onMove','onUp'])if(c.indexOf(n)<0)bad.push('controls.tsx missing :: '+n);if(/new\s+GameUI\s*\(/.test(c))bad.push('controls.tsx must not construct ui objects');if(/on[A-Z][A-Za-z]*=\{\s*(\(|\w+\s*=>)/.test(c))bad.push('inline handler found in controls');if(/loc=\{\[\s*\r?\n/.test(c))bad.push('multiline inline loc found in controls');for(const p of ['packages-user/client-base/src/setting/__test__/system.test.ts'])if(!fs.existsSync(p))bad.push('missing test file :: '+p);if(fs.existsSync('packages-user/client-base/src/setting/__test__/factories.test.ts'))bad.push('factories.test.ts must not exist (factories -> controls -> @motajs/render needs DOM)');const s=fs.readFileSync('packages-user/client-base/src/setting/__test__/system.test.ts','utf8');for(const n of ['setGroup','addSettingToGroup','getGroup','getSetting','iterateGroups','getUngrouped'])if(s.indexOf(n)<0)bad.push('system.test.ts missing :: '+n);if(/from '\.\.\/(factories|controls)'/.test(s))bad.push('system.test.ts must not import factories/controls');if(/vi\.stubGlobal|jsdom/.test(s))bad.push('system.test.ts must not fake DOM');if(bad.length){console.error('TASK2 SHAPE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task2: shapes')"</automated>
|
||||
<automated>node node_modules/vitest/vitest.mjs run packages-user/client-base/src/setting/__test__/system.test.ts</automated>
|
||||
<automated>node node_modules/eslint/bin/eslint.js packages-user/client-base/src/setting/factories.ts packages-user/client-base/src/setting/controls.tsx packages-user/client-base/src/setting/__test__/system.test.ts</automated>
|
||||
<fails_when>`factories.ts` 缺任一工厂 / 任一 `implements` / `clampSettingValue` / `ShallowRef` / `DefineSetupFnComponent` / `private readonly control`,或构造界面对象(判据是 `new GameUI(` 调用形态);`controls.tsx` 缺任一控件或拖拽事件、构造界面对象、出现行内处理函数或多行内联 `loc`;`system.test.ts` 缺失、缺关键用例标识、导入 `../factories` / `../controls`、或伪造 DOM;出现 `factories.test.ts`;`node node_modules/vitest/vitest.mjs run <system.test.ts>` 有失败;eslint 报错;任一 node 闸门非零退出。</fails_when>
|
||||
</verify>
|
||||
<done>四个工厂与四个控件全部就位(工厂 → 控件的单一依赖方向),工厂的 `control` 成员带显式类型;`system.test.ts` 覆盖分组 / 取消分组 / 未分组 / 取值 / 存档聚合与读取 / 未知 id 跳过,且不导入工厂 / 控件、不伪造 DOM;**不新建 `factories.test.ts`**(工厂与预览由 Task 3 的源码形状闸门覆盖);`system.test.ts` 与 eslint 全绿;未运行任何 git 写命令、未提交任何代码。</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: 写界面 / 工厂 / 控件源码闸门,然后跑全部门禁(直连 eslint / vitest / vue-tsc NEW-only / 受保护哈希 / HEAD / 范围),最后写执行摘要</name>
|
||||
<files>src/client/ui/__test__/settingPageGate.test.ts(新增), .planning/phases/09-minimum-browser-runnable/09-34-SUMMARY.md</files>
|
||||
<action>
|
||||
**依赖 Task 1 与 Task 2 已完成。不得运行任何 git 写命令、不得提交任何代码。**
|
||||
|
||||
**A. 新增 `src/client/ui/__test__/settingPageGate.test.ts`(`?raw` 源码闸门,参照 `hotkeySettingsGate.test.ts` 的写法)。**
|
||||
- 顶部按既有闸门的方式写一个 `stripComments` 辅助(抹掉块注释、JSX 注释与行注释),避免说明性文字让否定断言假红;`it` 前一行说明注释。
|
||||
- 原始导入:`../setting.tsx?raw`、`../index.ts?raw`、`../settings.tsx?raw`、`../../../../packages-user/client-base/src/setting/index.ts?raw`、`../../../../packages-user/client-base/src/setting/system.ts?raw`、`../../../../packages-user/client-base/src/setting/factories.ts?raw`、`../../../../packages-user/client-base/src/setting/controls.tsx?raw`。
|
||||
- 断言(分组为若干 `it`):
|
||||
1. **界面导出与 props**:`SettingPageProps extends IGameUIPropsBase`、`readonly system: ISettingSystem`、`SettingPage`、`SettingPageUI`、`new GameUI('setting-page'`。
|
||||
2. **`ListPage` 使用与关闭路径**:`<ListPage` 出现且带 `close`、`closeLoc={CLOSE_LOC}`、`onClose={exit}`、`onUpdate={selectGroup}`;含 `@common/exit`;界面文件**不**自行绘制关闭文本(断言不含该文本字面量)。
|
||||
3. **控件渲染契约**:含 `getSettingComponent()`、`setting={item}`、`controller={props.controller}`、`{{ default: renderGroup }}` 或 `default: renderGroup`。
|
||||
4. **无行内处理函数**:正则断言界面与控件源码都不出现「事件属性 = 行内匿名函数」的形态;并断言 `onUpdate={selectGroup}` / `onClose={exit}` 为命名引用。
|
||||
5. **无多行内联 `loc`**:正则断言界面与控件源码都不出现「`loc={[` 紧跟换行」的形态。
|
||||
6. **界面层零持久化 / 零接线**:界面源码不含 `saveState` / `loadState` / `globalSetting` / `gameSettings` / 存档注册调用。
|
||||
7. **`client-base` 无界面对象**:`system.ts` / `factories.ts` / `controls.tsx` 均**不构造**界面对象——判据是 `new GameUI(` 的调用形态(**不得**用裸 `GameUI` 匹配,`IGameUI` 类型引用必须允许);`system.ts` 不含存档注册调用与主对象引用。
|
||||
8. **分层**:`system.ts` 与 `factories.ts` 不含 `defineComponent`(控件只在 `controls.tsx`);界面文件不含 `ISettingComponentProps` 定义。
|
||||
9. **桶与菜单未接入**:`setting/index.ts` 恰好 4 行同目录桶导出;`src/client/ui/index.ts` 不含**带引号的** `'./setting'`(同级还有 `'./settings'`,裸子串会假红);`src/client/ui/settings.tsx` 不含 `SettingPage`。
|
||||
10. **工厂 / 预览形状(替代运行期工厂测试)**:`factories.ts` 含四个 `define*Setting` 导出、四个 `implements ISettingItem<...>`、`clampSettingValue` 中的 `Math.round` 对齐与 `Math.min` / `Math.max` 收敛、`private readonly control` 显式成员、`shallowRef`;布尔预览的字面量「开」与「关」;子页面 `getPreviewString()` 返回空串、`getValue()` 返回 `void 0`、`open(` 调用;`controls.tsx` 含四个 `create*SettingControl` 与 `onDown` / `onMove` / `onUp`。这些断言只读源码文本,不运行模块、不需要 DOM。
|
||||
11. **空系统占位**:界面源码含 `loc={CONTENT_SCROLL_LOC}` 的空 `Scroll` 分支与 `return () => renderPage();`。
|
||||
- 该测试文件只做源码断言,不导入运行时组件、不导入 `@motajs/render`。
|
||||
|
||||
**B. 先跑完所有门禁(此时还没有 SUMMARY):** 逐条执行下方 automated 命令并记录**实际**输出。`pnpm` 在本树不可用,**一律用直连二进制**。
|
||||
- `node node_modules/vitest/vitest.mjs run` **只跑两个闸门文件**:新增的 `settingPageGate.test.ts` 与既有的 `hotkeySettingsGate.test.ts`,两者全部通过。**不跑整个 `src/client/ui/__test__` 目录、也不宣称既有闸门全部通过**——`keyAdaptationGate.test.ts` 有 **5/15 既有失败**、`uiContainerGate.test.ts` 有 **7/18 既有失败**,两者都属范围外(都不在 `files_modified`、工作树里本来就是坏的),只在基线 / SUMMARY 记录(本次不得修、不得纳入门禁)。
|
||||
- `node node_modules/eslint/bin/eslint.js <8 个目标路径>` 0 报错(含 prettier:4 空格 / 单引号 / 无尾逗号)。
|
||||
- `node node_modules/vue-tsc/bin/vue-tsc.js --noEmit`:按**与 Task 1 基线逐字相同的归一化口径与同一 scope**(6 个改动源码文件)过滤,只判 NEW;基线中存在、执行后消失的诊断允许;同时要求整仓诊断总数不高于 `typecheckTotal`。
|
||||
- 受保护文件哈希(**10 个**)与基线逐字相等(其中 `client-base/src/index.ts` 的哈希必须未变,即 `export * from './setting';` 仍在);HEAD 与基线相同,或 `git diff --name-only <baseline>..HEAD` 只含 `.planning/` 路径。
|
||||
- 范围(两条都要):①**8 个**目标路径都出现在 `git status --porcelain -uall` 中(新增文件为未跟踪、修改文件为已改动);②整仓 `git status --porcelain -uall` 的每条路径必须属于这 8 个目标路径、或出现在基线 dirty 快照、或以 `.planning/` 开头,否则判范围外写入。
|
||||
- **顺序铁律**:先跑门禁,**再**写 SUMMARY;不得在门禁实际运行之前把结果写进 SUMMARY。
|
||||
|
||||
**C. 写 `.planning/phases/09-minimum-browser-runnable/09-34-SUMMARY.md`(用 B 步的真实结果):**
|
||||
- **系统 API**:`SettingSystem` 的 `readonly groups`(公开只读、原地增删)与私有 `settings` 表,以及全部公开成员的最终签名与语义(空数组删组、`addSettingToGroup` 的 no-op 与去重、`getUngrouped` 的来源、`getValue` 缺失返回 `undefined`、`saveState` 聚合与压缩级别透传、`loadState` 跳过未知 id)。
|
||||
- **工厂 options**:四个 `define*Setting` 与四个 options 接口的字段清单;数值项的收敛与步长对齐口径;子页面项的 `void` 语义与 `props ?? {}` 透传;值以 vue `shallowRef` 承载的理由。
|
||||
- **控件行为**:四个控件各自的交互与固定 180×24 控件区;「创建函数 + 闭包」的原因(`ISettingComponentProps` 只有 `setting` + `controller`)。
|
||||
- **页面布局**:`ListPage` 左栏分组 + 一条 `Scroll` 右栏 + 每行的名称 / `TextContent` 描述 / 预览 / 控件区;`@common/exit` 与 `ListPage` 内置关闭文本;导出 `SettingPage` / `SettingPageUI`。
|
||||
- **界面 / 控件分工**:`client-base` 只有系统 / 工厂 / 控件;`src/client/ui` 只有页面;界面层零持久化、零接线;`SettingPageUI` 未进 UI 桶、未进设置菜单。
|
||||
- **真实门禁结果**:`system.test.ts` 与 `settingPageGate.test.ts` 的通过数;单独跑的 `hotkeySettingsGate.test.ts` 通过数;**`keyAdaptationGate.test.ts` 的 5/15 既有失败与 `uiContainerGate.test.ts` 的 7/18 既有失败(附基线里 `keyAdaptation` / `uiContainer` 记录与「两个文件均未在 `files_modified` 中、工作树里本来就是坏的」的证据)**;eslint 退出码;`vue-tsc --noEmit` 的 scope 内诊断数与整仓总数;10 个受保护哈希;HEAD 政策;范围闸门;以及实际使用的直连二进制命令原文。
|
||||
- **范围告警声明**:本计划为 **11 个路径 / 80k**,高于推荐规模,**已显式接受、未拆分**(OI-5:同一纵向切片、拆分会造成 `types.ts` / `factories.ts` / `controls.tsx` 的同文件跨计划依赖)。
|
||||
- **声明**:`estimate` 未校准(`confidence: low`);**不做浏览器 UAT,不宣称任何界面 / 控件的浏览器行为已验证**;设置系统与界面**均未接线**(不进渲染端主对象、不进设置菜单、不注册存档),属后续用户自行接线的范围;**执行器 0 次代码提交**(未运行任何 git 写命令;`.planning` 产物可能由编排器聚合提交),代码工作树留给用户审查。
|
||||
- SUMMARY 必须逐字包含这些 token(摘要闸门会断言):`SettingSystem`、`ISettingSystem`、`defineNumberSetting`、`defineBooleanSetting`、`defineRangeSetting`、`defineSubPageSetting`、`createNumberSettingControl`、`createBooleanSettingControl`、`createRangeSettingControl`、`createSubPageSettingControl`、`ListPage`、`Scroll`、`TextContent`、`@common/exit`、`setting.tsx`、`controls.tsx`、`factories.ts`、`system.ts`、`node_modules/vue-tsc/bin/vue-tsc.js`、`node_modules/eslint/bin/eslint.js`、`node_modules/vitest/vitest.mjs`、`keyAdaptationGate`、`uiContainerGate`、`范围告警已接受`、`confidence: low`、`UAT`、`0 次代码提交`。
|
||||
|
||||
**D. 最后跑摘要闸门:** 确认 SUMMARY 存在且包含上述必需 token(见下方最后一条 automated);该闸门必须在 C 步之后运行,失败则补齐 SUMMARY 后重跑,不得只改闸门。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>node node_modules/vitest/vitest.mjs run src/client/ui/__test__/settingPageGate.test.ts src/client/ui/__test__/hotkeySettingsGate.test.ts</automated>
|
||||
<automated>node node_modules/eslint/bin/eslint.js packages-user/client-base/src/setting src/client/ui/setting.tsx src/client/ui/__test__/settingPageGate.test.ts</automated>
|
||||
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json','utf8'));const scope=['packages-user/client-base/src/setting/types.ts','packages-user/client-base/src/setting/system.ts','packages-user/client-base/src/setting/factories.ts','packages-user/client-base/src/setting/controls.tsx','packages-user/client-base/src/setting/index.ts','src/client/ui/setting.tsx'];const r=cp.spawnSync(process.execPath,['node_modules/vue-tsc/bin/vue-tsc.js','--noEmit'],{encoding:'utf8',timeout:900000});const text=(r.stdout||'')+(r.stderr||'');const diag=text.split(/\r?\n/).filter(l=>/error TS[0-9]+/.test(l));const bad=[];if(r.status===null)bad.push('vue-tsc did not run');else if(r.status!==0&&diag.length===0)bad.push('vue-tsc crashed with no parseable diagnostics');const norm=l=>l.replace(/\\/g,'/').replace(/\([0-9]+,[0-9]+\):/,'').replace(/^.*?(?=(src|packages-user)\/)/,'').trim();const cur=diag.map(norm).filter(x=>scope.some(s=>x.indexOf(s)===0));const before=(b.typecheck||[]).filter(x=>scope.some(s=>x.indexOf(s)===0));const count=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(count(cur,v)>count(before,v))bad.push('NEW diagnostic :: '+v)}if(diag.length>(b.typecheckTotal||0))bad.push('total diagnostics increased :: '+diag.length+' > '+b.typecheckTotal);if(bad.length){console.error('TYPECHECK GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK typecheck: '+cur.length+' in-scope diagnostics, '+diag.length+' total')"</automated>
|
||||
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json','utf8'));const bad=[];const keys=Object.keys(b.hashes||{});if(keys.length!==10)bad.push('baseline must protect 10 files, got '+keys.length);for(const file of keys){const r=cp.spawnSync('git',['hash-object',file],{encoding:'utf8'});if(r.status!==0)bad.push('cannot hash '+file);else if((r.stdout||'').trim()!==b.hashes[file])bad.push(file+' changed')}const head=(cp.spawnSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).stdout||'').trim();if(head!==b.head){const d=cp.spawnSync('git',['diff','--name-only',b.head+'..'+head],{encoding:'utf8'});if(d.status!==0)bad.push('HEAD changed and diff failed');else{const nonPlan=(d.stdout||'').split(/\r?\n/).filter(Boolean).filter(p=>!p.startsWith('.planning/'));if(nonPlan.length)bad.push('HEAD changed with non-planning commits :: '+nonPlan.join(','))}}if(bad.length){console.error('PROTECTED GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK protected files unchanged; HEAD policy satisfied')"</automated>
|
||||
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json','utf8'));const targets=['packages-user/client-base/src/setting/types.ts','packages-user/client-base/src/setting/system.ts','packages-user/client-base/src/setting/factories.ts','packages-user/client-base/src/setting/controls.tsx','packages-user/client-base/src/setting/index.ts','packages-user/client-base/src/setting/__test__/system.test.ts','src/client/ui/setting.tsx','src/client/ui/__test__/settingPageGate.test.ts'];const bad=[];if(!Array.isArray(b.dirty))bad.push('baseline dirty snapshot missing');const out=cp.spawnSync('git',['status','--porcelain','-uall'],{encoding:'utf8'}).stdout||'';const paths=out.split(/\r?\n/).filter(Boolean).map(l=>l.slice(3).trim()).filter(p=>p.indexOf('.planning/')!==0);const before=new Set((b.dirty||[]).map(p=>String(p).replace(/\\/g,'/')));for(const p of paths)if(!targets.includes(p)&&!before.has(p))bad.push('out-of-scope write :: '+p);for(const t of targets)if(!paths.includes(t))bad.push('target not modified :: '+t);if(bad.length){console.error('SCOPE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK scope: 8 targets present, no out-of-scope writes ('+paths.length+' dirty paths total, '+before.size+' pre-existing)')"</automated>
|
||||
<automated>node -e "const fs=require('fs');const p='.planning/phases/09-minimum-browser-runnable/09-34-SUMMARY.md';const bad=[];if(!fs.existsSync(p))bad.push('summary missing');else{const s=fs.readFileSync(p,'utf8');for(const n of ['SettingSystem','ISettingSystem','defineNumberSetting','defineBooleanSetting','defineRangeSetting','defineSubPageSetting','createNumberSettingControl','createBooleanSettingControl','createRangeSettingControl','createSubPageSettingControl','ListPage','Scroll','TextContent','@common/exit','setting.tsx','controls.tsx','factories.ts','system.ts','node_modules/vue-tsc/bin/vue-tsc.js','node_modules/eslint/bin/eslint.js','node_modules/vitest/vitest.mjs','keyAdaptationGate','uiContainerGate','范围告警已接受','confidence: low','UAT','0 次代码提交'])if(s.indexOf(n)<0)bad.push('summary missing :: '+n)}if(bad.length){console.error('SUMMARY GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK summary content')"</automated>
|
||||
<fails_when>`settingPageGate.test.ts` / `hotkeySettingsGate.test.ts` 任一失败(**注意:不得把 `keyAdaptationGate.test.ts` 的 5/15 与 `uiContainerGate.test.ts` 的 7/18 既有失败算作本计划失败,也不得声称既有闸门全部通过;`uiContainerGate.test.ts` 不在 `files_modified`**);eslint 退出码非 0;`node node_modules/vue-tsc/bin/vue-tsc.js --noEmit` 在 6 个 scope 内出现 NEW 诊断或整仓总数高于基线;10 个受保护文件任一哈希变化(含 `client-base/src/index.ts` 的 `./setting` 导出消失);HEAD 变化且 `baseline..HEAD` 含非 `.planning` 路径;8 个目标路径有任一未出现在脏文件列表;整仓脏文件里出现「既不在基线 dirty 快照、也不是 8 个目标路径、也不在 `.planning/`」的新路径;`09-34-SUMMARY.md` 缺失或缺少必需 token;或任一 node 闸门非零退出。**顺序错误(先写 SUMMARY 再跑门禁、或未运行就记录结果)同样判失败。**</fails_when>
|
||||
</verify>
|
||||
<done>直连 eslint 对 8 个目标路径 0 报错;`system.test.ts` + `settingPageGate.test.ts` 与单独跑的 `hotkeySettingsGate.test.ts` 全部通过(`keyAdaptationGate` 5/15 与 `uiContainerGate` 7/18 既有失败仅记录、已声明范围外,两者均不在 `files_modified`);直连 `vue-tsc --noEmit` 无 NEW 诊断且整仓总数不增;10 个受保护文件哈希与基线一致;HEAD 政策与范围闸门通过;**先跑完门禁、再写 `09-34-SUMMARY.md`、最后通过摘要闸门**,摘要如实声明系统 API、工厂 options、控件行为、页面布局、界面 / 控件分工、既有失败证据、范围告警已接受、未做浏览器 UAT、估算未校准、未接线、执行器 0 次代码提交。</done>
|
||||
</task>
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| 设置项 ↔ `SettingSystem` | 设置项由工厂创建、经 `setGroup` / `addSettingToGroup` 进入系统;系统是唯一的登记与存档聚合入口 |
|
||||
| 分组 ↔ 未分组 | 分组只承载显示归属,不承载取值;空数组删组后设置项仍可能被取到,边界必须一致 |
|
||||
| 控件 ↔ 界面 | 控件只通过 `ISettingComponentProps`(`setting` + `controller`)与外界交互,界面只通过 `getSettingComponent()` 取控件 |
|
||||
| `client-base` ↔ `src/client/ui` | 依赖单向:界面依赖 `client-base`;`client-base` 不得反向引用界面或入口 |
|
||||
| 界面 ↔ 渲染端主对象 | 界面只经 props 拿控制器与热键系统,不持有主对象上的设置字段,不做持久化 |
|
||||
| 存档契约 ↔ `ISaveableContent` | `saveState` / `loadState` 的签名与语义由 `@user/data-common` 定义;系统只聚合、不改变语义 |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-09-34-01 | Tampering | `SettingSystem` 的分组 / 未分组语义 | high | mitigate | `system.test.ts` 断言空数组删组后设置项仍可取到、`addSettingToGroup` 对不存在分组为 no-op、重复加入不追加、`getUngrouped` 不含已分组项 |
|
||||
| T-09-34-02 | Tampering | 用户已改写的 `setting/types.ts` | high | mitigate | Task 1 只做加法(`controller` + 4 个 options);Task 1 / Task 3 的形状闸门逐条断言既有声明存在且 `ISettingSubPageItem` 不存在;受保护哈希覆盖被参照的 `client-base/src/types.ts` |
|
||||
| T-09-34-03 | Tampering | 值收敛与步长对齐 | medium | mitigate | 收敛逻辑只在工厂的 `clampSettingValue` 内实现一次,控件不重复实现;`settingPageGate.test.ts` 对 `clampSettingValue` 的 `Math.round` 对齐与 `Math.min` / `Math.max` 收敛做源码形状断言(运行期工厂测试因 `@motajs/render` 导入期需要 DOM 而不写,见 OI-3 ③) |
|
||||
| T-09-34-04 | Tampering | 借「实现」之名接线 / 加依赖 / 加日志码 | high | mitigate | prohibitions 明确禁止接线与新增日志码;10 个受保护文件哈希覆盖包级桶、主对象类型、热键、组件桶、依赖声明、存档类型、UI 桶、设置菜单与 `logger.json`;范围闸门拒绝任何新增范围外路径 |
|
||||
| T-09-34-05 | Elevation of Privilege | 控件绕过设置项直接改值 / 控件与界面互相穿透 | medium | mitigate | 控件只接收 `setting` + `controller`;闭包只携带该设置项的 `step` / `min` / `max` / `page`;源码闸门断言 `client-base` 不出现 `new GameUI(`(允许 `IGameUI` 类型引用)、界面文件无控件定义 |
|
||||
| T-09-34-06 | Repudiation | 声称「设置界面可用」但未做任何运行验证 | medium | mitigate | SUMMARY 必须写明「不做浏览器 UAT、不宣称任何界面 / 控件浏览器行为已验证、系统与界面均未接线」;Task 0 汇报中同样声明 |
|
||||
| T-09-34-07 | Elevation of Privilege | 为了让类型检查变绿而改依赖 / `tsconfig` / 加指令或断言 | high | mitigate | OI-3 规定遇到 NEW 诊断必须停下上报;禁止改依赖包 / `tsconfig` / 用 `@ts-expect-error`;`as` 仅允许 `SettingSystem.getValue<T>` 那一次 |
|
||||
| T-09-34-SC | Tampering | npm / pnpm 安装 | high | mitigate | 本计划**不新增任何依赖、不执行安装命令、不修 workspace / `package.json`**(`pnpm` 在本树已因 `@motajs/legacy-ui` 引用失效);门禁只用 `node_modules` 里既有的直连二进制;`vue` / `@motajs/render` / `@motajs/system` / `@user/data-common` 均为既有依赖;若执行期出现安装需求,必须停下上报 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- **门禁命令(`pnpm` 在本树不可用,一律直连二进制)**:`node node_modules/vitest/vitest.mjs run <paths>`、`node node_modules/eslint/bin/eslint.js <paths>`、`node node_modules/vue-tsc/bin/vue-tsc.js --noEmit`。
|
||||
- **单元测试(DOM-free)**:`node node_modules/vitest/vitest.mjs run packages-user/client-base/src/setting/__test__/system.test.ts` 全绿;该文件不导入 `../factories` / `../controls`、不伪造 DOM。
|
||||
- **源码闸门**:`node node_modules/vitest/vitest.mjs run src/client/ui/__test__/settingPageGate.test.ts src/client/ui/__test__/hotkeySettingsGate.test.ts` 全绿——**只跑这两个文件**;`keyAdaptationGate.test.ts` 的 5/15 失败与 `uiContainerGate.test.ts` 的 7/18 失败均为既有、范围外(两者都不在 `files_modified`),只在基线 / SUMMARY 记录,**不跑整个目录、不宣称既有闸门全部通过**。
|
||||
- **静态检查(源码)**:`node node_modules/eslint/bin/eslint.js <8 个目标路径>` 退出码 0(含 prettier 的 4 空格 / 单引号 / 无尾逗号)。
|
||||
- **静态检查(类型)**:`node node_modules/vue-tsc/bin/vue-tsc.js --noEmit` 整仓运行后在 6 个改动源码文件内 **NEW 诊断数为 0**(归一化口径与 scope 与 Task 1 基线逐字相同),且整仓诊断总数不高于基线。
|
||||
- **形状**:Task 1 / Task 2 / Task 3 的 `node -e` 与 `?raw` 闸门通过——`types.ts` 既有声明逐条存在且无 `ISettingSubPageItem`;`system.ts` 实现 `ISettingSystem`、含 `readonly groups: Map<string, ISettingItemGroup>` 与 `private readonly settings`,且不引用界面对象构造 / 主对象 / 存档注册;`factories.ts` 四个工厂 + 四个 `implements` + `clampSettingValue` + 显式 `private readonly control`;`controls.tsx` 四个控件创建函数 + 拖拽事件,且无 `new GameUI(`(允许 `IGameUI` 类型引用)/ 行内处理函数 / 多行内联 `loc`;`setting/index.ts` 恰好 4 行同目录桶导出;`setting.tsx` 具备 `ListPage` / 关闭 / 控件 props / `TextContent` / `Scroll` / `SettingPageUI` / 空分支 `loc={CONTENT_SCROLL_LOC}` / `return () => renderPage();`。
|
||||
- **无运行期工厂测试**:不存在 `packages-user/client-base/src/setting/__test__/factories.test.ts`;工厂的 options、收敛 / 对齐、布尔「开 / 关」预览、子页面空串预览由 `settingPageGate.test.ts` 的源码形状断言覆盖。
|
||||
- **范围**:**8 个**目标路径都出现在脏文件列表;整仓没有「不在基线 dirty 快照、也不是目标路径、也不在 `.planning/`」的新路径;**10 个**受保护文件(`client-base/src/index.ts` / `client-base/src/types.ts` / `hotkey.ts` / `hotkey/types.ts` / `components/index.ts` / `client-base/package.json` / `packages-user/data-common/src/save/types.ts` / `src/client/ui/index.ts` / `src/client/ui/settings.tsx` / `packages/common/src/logger.json`)哈希与基线一致——其中 `client-base/src/index.ts` 的哈希不变即证明 `export * from './setting';` 仍在(该行只存在于当前工作树,本计划必须在当前工作树内执行)。
|
||||
- **无代码提交**:HEAD 与 `09-34-BASELINE.json` 的 `head` 相同,或 `git diff --name-only <baseline>..HEAD` 只含 `.planning/` 路径;任何非 `.planning` 路径的提交都判失败(该禁止覆盖执行器的提交协议)。
|
||||
- **无浏览器验证声明**:SUMMARY 明确写入「未做浏览器 UAT」,并写明设置系统与界面均未接线、未进设置菜单、未注册存档。
|
||||
- **执行顺序**:全部门禁(测试 / eslint / vue-tsc / 哈希 / HEAD / 范围)先跑完 → 再写 `09-34-SUMMARY.md` → 最后跑摘要闸门;不得在门禁运行前记录结果。
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Task 0 的 `blocking-human` 汇报关卡已执行:汇报了系统 API、工厂 options、控件行为、页面布局、界面 / 控件分工、命名清单、验证口径与三条铁律,并等待到恰好「可以执行」。
|
||||
- [ ] `packages-user/client-base/src/setting/types.ts` 只做加法:`ISettingComponentProps` 增加 `readonly controller: IUIController<IGameUIPropsBase>`,末尾追加 `ISettingNumberOptions` / `ISettingBooleanOptions` / `ISettingRangeOptions` / `ISettingSubPageOptions`;既有 `ISettingItem` / `ISettingItemGroup` / `ISettingSystemSave` / `ISettingSystem` 逐字不变;无 `ISettingSubPageItem`。
|
||||
- [ ] `SettingSystem implements ISettingSystem`:公开只读 `groups`(`Map`,原地增删)+ 私有 `settings` 表;`setGroup` 空数组删组且设置项变未分组;`addSettingToGroup` 不存在即 no-op、不重复追加;`getGroup` / `getSetting` 缺失返回 `null`;`iterateGroups` 返回分组条目;`getUngrouped` 返回未分组项;`getValue` 缺失返回 `undefined`;`setValue` 转发;`saveState` 聚合且透传压缩级别;`loadState` 跳过未知 id 且不新增日志码;不做任何接线。
|
||||
- [ ] 四个工厂(`defineNumberSetting` / `defineBooleanSetting` / `defineRangeSetting` / `defineSubPageSetting`)与四个控件创建函数(`createNumberSettingControl` / `createBooleanSettingControl` / `createRangeSettingControl` / `createSubPageSettingControl`)就位;数值项在 `setValue` 内收敛 + 对齐;子页面项保存 / 读取空操作、预览空串、点击打开目标页面;控件只接收 `setting` + `controller`、在 [0,0] 为原点的固定 180×24 区域内绘制、不包界面对象。
|
||||
- [ ] `src/client/ui/setting.tsx` 只有页面:`SettingPageProps extends IGameUIPropsBase` + `readonly system: ISettingSystem`;`ListPage` 左栏分组 + 一条 `Scroll` 右栏 + 名称 / `TextContent` 描述 / 预览 / 控件;`@common/exit` 与 `ListPage` 内置关闭文本;导出 `SettingPage` / `SettingPageUI`;零控件定义、零持久化、零接线。
|
||||
- [ ] `setting/index.ts` 恰好 4 行同目录桶导出;`src/client/ui/index.ts` 与 `src/client/ui/settings.tsx` 未被改动(后者不含带引号的 `'./setting'`);10 个受保护文件哈希与基线一致(含 `client-base/src/index.ts` 的 `./setting` 导出仍在);执行在当前脏工作树内完成。
|
||||
- [ ] `system.test.ts` 与 `settingPageGate.test.ts` 全绿;`hotkeySettingsGate.test.ts` 单独跑全绿;`keyAdaptationGate.test.ts` 的 5/15 与 `uiContainerGate.test.ts` 的 7/18 既有失败按范围外记录(均未修、均未纳入门禁、均不在 `files_modified`);**不存在 `factories.test.ts`**;直连 eslint 0 报错;直连 `vue-tsc --noEmit` 无 NEW 诊断且整仓总数不增;范围闸门与 HEAD 政策通过;**先跑门禁、再写 `09-34-SUMMARY.md`、最后通过摘要闸门**;摘要含既有失败证据、范围告警已接受、未做浏览器 UAT、估算未校准、未接线、执行器 0 次代码提交声明。
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/09-minimum-browser-runnable/09-34-SUMMARY.md` **after all gates have actually run**, recording: the exact direct-binary gate commands actually used (`node node_modules/vue-tsc/bin/vue-tsc.js --noEmit`, `node node_modules/eslint/bin/eslint.js <paths>`, `node node_modules/vitest/vitest.mjs run <paths>`) and that `pnpm` is unusable in this tree because `packages-user/client-modules/package.json` references the deleted `@motajs/legacy-ui` (no workspace / `package.json` repair was made); that **no `factories.test.ts` was created** — factories and previews are covered by the `?raw` source-shape gate because `controls.tsx` imports `@motajs/render`, which needs DOM at import time, and no jsdom / DOM mock was added; the pre-existing `keyAdaptationGate` 5/15 failures and the pre-existing `uiContainerGate` 7/18 failures as out-of-scope with the baseline `keyAdaptation` / `uiContainer` records as evidence (only `settingPageGate.test.ts` and `hotkeySettingsGate.test.ts` were run; `uiContainerGate.test.ts` is not in `files_modified`); that the scope warning (11 paths / 80k, originally 12 / 90k) was explicitly accepted without splitting the plan (OI-5); that execution happened in the current dirty worktree because `client-base/src/index.ts` carries `export * from './setting';` only there; the final `SettingSystem` API (public read-only `groups` map mutated in place plus the private `settings` map, and `setGroup` / `addSettingToGroup` / `getGroup` / `getSetting` / `iterateGroups` / `getUngrouped` / `getValue<T>` / `setValue<T>` / `saveState` / `loadState`, with the empty-array group deletion, the add-to-missing-group no-op, the ungrouped derivation, the `undefined` return for missing values, the compression pass-through, and the silent skip of unregistered ids in `loadState`); the four factories (`defineNumberSetting` / `defineBooleanSetting` / `defineRangeSetting` / `defineSubPageSetting`) and their option fields (`id` / `name` / `description` / `defaultValue` / `min` / `max` / `step` / `page` / `props`); that each item implements `ISettingItem<T>` and uses a vue `shallowRef` for its value so previews and controls repaint; the numeric clamp + step alignment in `setValue` (reused by `loadState`) and the boolean on/off preview; that sub-page items are plain `ISettingItem<void>` with no-op save/load, an empty preview, and `controller.open(page, props ?? {})`; the four controls (`createNumberSettingControl` / `createBooleanSettingControl` / `createRangeSettingControl` / `createSubPageSettingControl`) with their exact behaviours (step buttons, toggle, drag-track mapping, openable row), that they receive only `ISettingComponentProps` (`setting` + `controller`), draw inside a fixed 180×24 area at `[0, 0]`, and that the closure form exists because the locked interface carries no `step` / `min` / `max`; the `SettingPage` layout (`ListPage` group column, one `Scroll` of rows with name, `TextContent` description, preview string and control area, `@common/exit` plus the built-in list close text) and the fact that `SettingPage` / `SettingPageUI` are exported from `setting.tsx` only, with no user-story of a menu entry; the exact `client-base` / `src/client/ui` split (system, factories and `controls.tsx` in `client-base`; page only in `src/client/ui`; no interface-object construction in `client-base`); the real gate results from step B; that `estimate` is uncalibrated (`confidence: low`); that **no browser UAT was performed** and no settings-system / page / control browser behaviour is claimed; that nothing was wired into the render-side main object, the settings menu, the UI barrel, or the save system; and that the executor made **0 code commits** (the `.planning` artifacts may be aggregated by the orchestrator under `commit_docs: true`), leaving the code in the working tree for the user's review.
|
||||
</output>
|
||||
|
||||
147
.planning/phases/09-minimum-browser-runnable/09-34-SUMMARY.md
Normal file
147
.planning/phases/09-minimum-browser-runnable/09-34-SUMMARY.md
Normal file
@ -0,0 +1,147 @@
|
||||
---
|
||||
phase: 09-minimum-browser-runnable
|
||||
plan: 34
|
||||
status: complete
|
||||
subsystem: client-base setting system + generic setting page
|
||||
tags:
|
||||
- setting
|
||||
- client-base
|
||||
- ui
|
||||
dependency_graph:
|
||||
requires:
|
||||
- packages-user/client-base/src/setting/types.ts (09-33 类型契约)
|
||||
provides:
|
||||
- SettingSystem
|
||||
- defineNumberSetting / defineBooleanSetting / defineRangeSetting / defineSubPageSetting
|
||||
- createNumberSettingControl / createBooleanSettingControl / createRangeSettingControl / createSubPageSettingControl
|
||||
- SettingPage / SettingPageUI
|
||||
affects: []
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- vue defineComponent + 闭包携带控件配置
|
||||
- vue shallowRef 承载设置项取值
|
||||
key_files:
|
||||
created:
|
||||
- packages-user/client-base/src/setting/system.ts
|
||||
- packages-user/client-base/src/setting/factories.ts
|
||||
- packages-user/client-base/src/setting/controls.tsx
|
||||
- packages-user/client-base/src/setting/__test__/system.test.ts
|
||||
- src/client/ui/setting.tsx
|
||||
- src/client/ui/__test__/settingPageGate.test.ts
|
||||
- .planning/phases/09-minimum-browser-runnable/09-34-BASELINE.json
|
||||
modified:
|
||||
- packages-user/client-base/src/setting/types.ts
|
||||
- packages-user/client-base/src/setting/index.ts
|
||||
commits: 0
|
||||
decisions:
|
||||
- 控件用「创建函数 + 闭包」携带 step / min / max / page,不给 ISettingComponentProps 增加成员
|
||||
- 设置项取值用 vue shallowRef 承载,使页面预览与控件读取同一响应式源
|
||||
- 工厂的 control 成员使用 ReturnType<typeof createXxxSettingControl> 作为显式类型
|
||||
- 不开运行期工厂测试(controls.tsx 经 @motajs/render 在导入期需要 DOM)
|
||||
---
|
||||
|
||||
# Phase 9 Plan 34: 设置系统、工厂、内置控件与通用设置界面 Summary
|
||||
|
||||
在 `client-base` 落地设置系统(`system.ts`)、四个设置项工厂(`factories.ts`)与四个内置控件(`controls.tsx`),并在 `src/client/ui/setting.tsx` 落地一个通用设置页面(`SettingPage` / `SettingPageUI`)。**不做任何接线**:不接渲染端主对象、不进设置菜单、不注册存档、不改入口。
|
||||
|
||||
## 系统 API(`SettingSystem implements ISettingSystem`)
|
||||
|
||||
- 成员:公开只读 `groups: Map<string, ISettingItemGroup>`(引用只读、原地增删)与私有 `private readonly settings: Map<string, ISettingItem>`。
|
||||
- `setGroup(group, name, items)`:先把每个设置项按 id 登记进 `settings`,再整体替换分组;`items` 为空数组时只删除分组,已登记的设置项保留并变为未分组。
|
||||
- `addSettingToGroup(group, item)`:分组不存在时直接返回(no-op,不登记);存在时登记并追加,同一设置项重复加入不会产生重复项(以引用比较去重)。
|
||||
- `getGroup` / `getSetting`:缺失返回 `null`。
|
||||
- `iterateGroups()`:返回 `this.groups.entries()`。
|
||||
- `getUngrouped()`:先收集所有分组内的设置项引用,再返回「已登记但不在任何分组」的设置项数组。
|
||||
- `getValue<T>(id)`:缺失返回 `undefined`(`void 0`),否则转发设置项的 `getValue()`(唯一一处必要的 `as T`)。
|
||||
- `setValue<T>(id, value)`:缺失直接返回,否则转发。
|
||||
- `saveState(compression)`:遍历 `settings` 聚合为 `{ values: Map<string, unknown> }`,压缩级别原样透传给每个设置项。
|
||||
- `loadState(state, compression)`:逐项转发;未登记的 id 直接跳过,**不新增任何日志码**(`logger.json` 未改)。
|
||||
|
||||
## 工厂 options
|
||||
|
||||
- `defineNumberSetting(options: ISettingNumberOptions)`:`id` / `name` / `description` / `defaultValue` / `min` / `max` / `step`,返回 `ISettingItem<number>`。
|
||||
- `defineBooleanSetting(options: ISettingBooleanOptions)`:`id` / `name` / `description` / `defaultValue`,返回 `ISettingItem<boolean>`。
|
||||
- `defineRangeSetting(options: ISettingRangeOptions)`:字段与数字项相同,返回 `ISettingItem<number>`。
|
||||
- `defineSubPageSetting(options: ISettingSubPageOptions)`:`id` / `name` / `description` / `page: IGameUI` / 可选 `props`,返回 `ISettingItem<void>`;打开时把 `props ?? {}` 原样交给控制器。
|
||||
- 数值项(数字 / 区间)在 `setValue` 内统一经 `clampSettingValue` 做区间收敛、步长对齐与浮点尾差消除,`loadState` 复用 `setValue`;布尔预览为「开」/「关」;子页面项无值(`getValue()` 返回 `void 0`)、保存 / 读取为空操作、预览为空串。
|
||||
- 取值由 vue `shallowRef` 承载,使页面与控件的渲染期读取到同一个响应式源。
|
||||
|
||||
## 控件行为(`controls.tsx`)
|
||||
|
||||
- 控件只接收 `ISettingComponentProps`(`setting` + `controller`),在 [0,0] 为原点的固定 180×24 区域内绘制,不构造任何界面对象。
|
||||
- `createNumberSettingControl(step)`:左减 / 右加按钮 + 中置当前值文本,每次按 `step` 增减,收敛交给设置项。
|
||||
- `createBooleanSettingControl()`:整块开关按钮,显示「开」/「关」,点击取反。
|
||||
- `createRangeSettingControl(min, max)`:轨道 + 已填充部分 + 滑块;`onDown` / `onMove` / `onUp` / `onLeave` 只接命名函数,按下与拖拽按指针横坐标线性映射到区间,步长对齐交给设置项,抬起或离开结束拖拽。
|
||||
- `createSubPageSettingControl(page, vBind)`:可点击行(「打开」+「>」),点击执行 `controller.open(page, vBind)`,不读写设置项的值。
|
||||
- `step` / `min` / `max` / `page` 通过创建函数的闭包携带,因此 `ISettingComponentProps` 只增加了 `controller`。
|
||||
|
||||
## 页面布局(`src/client/ui/setting.tsx`)
|
||||
|
||||
- props:`SettingPageProps extends IGameUIPropsBase`,唯一新增成员 `readonly system: ISettingSystem`。
|
||||
- 左栏:`ListPage` 承载 `iterateGroups()` 的分组(第一项为分组 id、第二项为分组名)。
|
||||
- 右栏:一条 `Scroll` 平铺当前分组的设置行;每行为 `item.name`、`TextContent` 描述(`autoHeight`)、`item.getPreviewString()` 预览,以及右侧固定控件区中的 `<Control setting={item} controller={props.controller} />`。
|
||||
- 关闭:`@common/exit` 热键命令 + `ListPage` 内置关闭文本(`close` / `closeLoc` / `onClose`),界面不自行绘制关闭文本。
|
||||
- 空系统:不渲染 `ListPage`,改渲染一个 `loc={CONTENT_SCROLL_LOC}` 的空 `Scroll` 占位,此时只有 `@common/exit` 可退出。
|
||||
- 导出 `SettingPage` 与 `SettingPageUI = new GameUI('setting-page', SettingPage)`;组件返回固定为 `return () => renderPage();`。
|
||||
|
||||
## 界面 / 控件分工
|
||||
|
||||
- `client-base`:只放系统、工厂、内置控件与类型;`system.ts` / `factories.ts` / `controls.tsx` 均不出现 `new GameUI(`,允许 `IGameUI` 类型引用。
|
||||
- `src/client/ui`:只放页面,不定义任何控件;界面层零持久化调用、零主对象设置字段。
|
||||
- `SettingPageUI` 未进入 `src/client/ui/index.ts`,`src/client/ui/settings.tsx` 未出现 `SettingPage`;设置系统与界面**均未接线**(不接渲染端主对象、不进设置菜单、不注册存档)。
|
||||
- 受保护的 10 个文件哈希与基线逐字一致,其中 `packages-user/client-base/src/index.ts` 的 `export * from './setting';` 仍在(执行在当前脏工作树内完成)。
|
||||
|
||||
## 真实门禁结果(全部直连二进制,未使用 pnpm)
|
||||
|
||||
`pnpm` 在本树不可用:`packages-user/client-modules/package.json` 引用了已删除的 `@motajs/legacy-ui`。未修任何 workspace / `package.json`,门禁全部直连二进制:
|
||||
|
||||
1. `node node_modules/vitest/vitest.mjs run packages-user/client-base/src/setting/__test__/system.test.ts` → 13 passed / 13(DOM-free,未导入 factories / controls)。
|
||||
2. `node node_modules/vitest/vitest.mjs run src/client/ui/__test__/settingPageGate.test.ts src/client/ui/__test__/hotkeySettingsGate.test.ts` → 2 files passed,24 passed / 24(新增闸门 12、既有 hotkeySettingsGate 12)。只跑了这两个文件,未跑整个目录,也未宣称既有闸门全部通过。
|
||||
3. `node node_modules/eslint/bin/eslint.js packages-user/client-base/src/setting src/client/ui/setting.tsx src/client/ui/__test__/settingPageGate.test.ts` → 退出码 0(Task 1 / Task 2 各自的 eslint 也均退出码 0)。
|
||||
4. `node node_modules/vue-tsc/bin/vue-tsc.js --noEmit` → 6 个改动源码文件内 NEW 诊断为 **0**,整仓诊断总数 **202**(与基线 `typecheckTotal` 202 持平);基线 `typecheck` 数组为空,scope 内诊断数 0。
|
||||
5. 受保护文件:10 个哈希与 `09-34-BASELINE.json` 逐字相等;HEAD 与基线相同(`102963401afe8c8321167b2093bc9e15c8446922`),未产生任何代码提交。
|
||||
6. 范围闸门:8 个目标路径全部出现在脏文件列表;整仓 25 条脏路径中 17 条为基线既有,其余为目标路径,无范围外写入。
|
||||
7. 摘要闸门:本文件写完后运行,见下文。
|
||||
|
||||
### 既有失败(范围外,仅记录,不作门禁)
|
||||
|
||||
- `keyAdaptationGate.test.ts`:现场单独采集为 **6 failed / 9 passed / 15 total**(计划写作时预期 5/15;基线以现场采集为准记录为 6/15)。该文件不在 `files_modified` 中,工作树里本来就是坏的,属既有 / 范围外,未修、未纳入门禁。
|
||||
- `uiContainerGate.test.ts`:**7 failed / 11 passed / 18 total**(与计划的 7/18 一致)。该文件同样不在 `files_modified` 中,属既有 / 范围外,未修、未纳入门禁。
|
||||
- 证据:`09-34-BASELINE.json` 的 `keyAdaptation` / `uiContainer` 信息字段记录的是写代码之前的现场数字。
|
||||
|
||||
## 工厂测试取舍
|
||||
|
||||
未创建 `factories.test.ts`:`factories.ts` 静态导入 `controls.tsx`,`controls.tsx` 静态导入 `@motajs/render`,导入期即需要 DOM,因此在 node 环境无法 `import '../factories'`。未新增 jsdom / DOM mock,也未改模块导入结构;四个工厂的 options、`clampSettingValue` 的收敛与对齐、布尔「开 / 关」预览、子页面空串预览改由 `settingPageGate.test.ts` 的 `?raw` 源码形状断言覆盖。
|
||||
|
||||
## 偏差与实现说明
|
||||
|
||||
1. **控件成员类型走计划允许的回退方案**:`ISettingComponentProps<number>` 作为 `control` 成员类型时产生 3 条 NEW 诊断(`DefineSetupFnComponent` 的实例类型在 `unknown` / `number` 之间不可赋值)。按计划改用 `ReturnType<typeof createNumberSettingControl>` 等作为成员类型,并仅把 `getSettingComponent()` 的返回标注为 `DefineSetupFnComponent<ISettingComponentProps>`;未使用 `as`,未改接口。修正后 scope 内 NEW 诊断为 0。
|
||||
2. **组件内部注释使用 `//` 而非块注释**:计划自带的 `stripComments` 形状闸门会把「`{` + 空白 + `/*`」起的整段吞到下一个「`*/}`」,若在组件体或 `SettingPageProps` 成员处使用块注释,会把真实代码一起从闸门输入中抹掉。为让形状闸门断言的是真实代码,组件内部与 `SettingPageProps.system` 的说明改用单行注释;模块级常量 / 导出函数 / 主要导出的中文 jsDoc 保留。
|
||||
3. **控件 props 声明在调用处内联**:按计划写成 `{ props: ['setting', 'controller'] } satisfies SetupComponentOptions<ISettingComponentProps>`,未引入额外命名。
|
||||
4. **`keyAdaptationGate` 实际为 6/15**(计划预期 5/15):属既有失败,仅记录实际值。
|
||||
|
||||
## 范围告警
|
||||
|
||||
本计划共 11 个路径 / 估算 80k(原 12 / 90k),高于推荐规模。**范围告警已接受**,不拆分(OI-5:四层是同一纵向切片,拆分会造成 `types.ts` / `factories.ts` / `controls.tsx` 的同文件跨计划依赖)。
|
||||
|
||||
## 声明
|
||||
|
||||
- 估算未校准:`confidence: low`。
|
||||
- **未做浏览器 UAT**,不宣称任何界面 / 控件的浏览器行为已验证。
|
||||
- 设置系统与界面均未接线(不进渲染端主对象、不进设置菜单、不注册存档)。
|
||||
- 执行器 **0 次代码提交**:未运行任何 git 写命令,代码改动留在工作树;`.planning` 产物可能由编排器聚合提交,不属本计划的代码提交。
|
||||
|
||||
## 实际使用的门禁命令原文
|
||||
|
||||
- `node node_modules/vue-tsc/bin/vue-tsc.js --noEmit`
|
||||
- `node node_modules/eslint/bin/eslint.js packages-user/client-base/src/setting/types.ts packages-user/client-base/src/setting/system.ts packages-user/client-base/src/setting/factories.ts packages-user/client-base/src/setting/controls.tsx packages-user/client-base/src/setting/index.ts src/client/ui/setting.tsx`
|
||||
- `node node_modules/eslint/bin/eslint.js packages-user/client-base/src/setting src/client/ui/setting.tsx src/client/ui/__test__/settingPageGate.test.ts`
|
||||
- `node node_modules/vitest/vitest.mjs run packages-user/client-base/src/setting/__test__/system.test.ts`
|
||||
- `node node_modules/vitest/vitest.mjs run src/client/ui/__test__/settingPageGate.test.ts src/client/ui/__test__/hotkeySettingsGate.test.ts`
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- 已创建:`setting/system.ts`、`setting/factories.ts`、`setting/controls.tsx`、`setting/__test__/system.test.ts`、`src/client/ui/setting.tsx`、`src/client/ui/__test__/settingPageGate.test.ts`、`09-34-BASELINE.json`、本摘要。
|
||||
- 已修改:`setting/types.ts`(只做加法)、`setting/index.ts`(4 行桶导出)。
|
||||
- 未创建 `factories.test.ts`;未提交任何代码(HEAD 与基线相同)。
|
||||
Loading…
Reference in New Issue
Block a user