diff --git a/docs/superpowers/plans/2026-06-20-config-editor-foundation.md b/docs/superpowers/plans/2026-06-20-config-editor-foundation.md new file mode 100644 index 00000000..caec5b59 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-config-editor-foundation.md @@ -0,0 +1,1741 @@ +# 英雄/技能配置编辑器 — Plan A:基础层(脚手架 + IO + Schema + 校验 + 主进程) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 `extensions/pixelhero-config-editor/` 下交付一个可加载的 Cocos Creator 3.8.6 扩展,包含经自动化测试的纯逻辑层(TS AST 往返 IO、schema、校验)与主进程消息处理,外加一个最小面板证明端到端 IPC 打通。 + +**Architecture:** schema 驱动;IO 层用 TypeScript Compiler API 对现有 `Record` 配置做"条目级 AST 区间替换",保留符号表达式(`AtkSpeedSet[AtkSpeedLv.X].cd`)与枚举引用(`FacSet.HERO`);纯逻辑层零 Cocos 依赖、可独立测试;主进程单例持有内存真理源,面板经 `Editor.Message` 读写。 + +**Tech Stack:** TypeScript、`typescript`(编译器 API)、`vue@3`(面板,esm-bundler 含编译器)、`esbuild`(打包)、`tsx`+`node:test`(单元测试)。Cocos Creator 3.8.6 扩展 API(`Editor.Panel.define`、`Editor.Message`、`contributions.messages/menu`)。 + +**本计划范围(Plan A):** P0 脚手架 + P1 IO + P2 schema/校验 + P3 主进程(只读+patch保存)+ 最小面板。**不在本计划:** 完整 UI(master-detail/嵌套编辑器/预览)、add/delete 记录的消息与 `HeroList` 写同步 —— 见 Plan B。 + +**Refines spec §5.2:** `RecordValue` 增加 `enumRef`(`FacSet.HERO` 等)与 `raw`(未识别表达式原样保留、UI 标只读)两种;v1 序列化**不**输出 `info` 行注释(`info` 作为普通字段),以规避条目前置注释在 patch 时的重复问题。 + +--- + +## File Structure + +``` +extensions/pixelhero-config-editor/ +├── package.json # 扩展清单(package_version:2, panels, contributions, deps) +├── tsconfig.json +├── esbuild.config.mjs # 打包 main(node/cjs)+ 面板(browser/iife, bundle vue) +├── i18n/en.js, i18n/zh.js +├── static/template/default/index.html # 面板宿主
+├── static/style/default/index.css +├── src/ +│ ├── main/ +│ │ ├── index.ts # 扩展入口:onLoad/onMessage 注册 +│ │ └── store.ts # 三张表 TsConfigFile 单例 + 消息处理实现 +│ ├── io/ +│ │ ├── recordValue.ts # RecordValue 类型(纯类型,无依赖) +│ │ ├── parser.ts # AST → RecordValue(含 speed/enumRef/raw) +│ │ ├── serializer.ts # RecordValue → TS 文本(serializeValue / serializeEntry) +│ │ └── TsConfigFile.ts # load/getKeys/read/patch/add/delete/save(条目级区间替换) +│ ├── shared/ # 纯逻辑(无 cc / Editor 依赖,可独立测试) +│ │ ├── schema/ +│ │ │ ├── types.ts # TableSchema / FieldSchema / FieldType +│ │ │ ├── enums.ts # 枚举镜像(HType/FacSet/TGroup/.../AtkSpeedLv/Attrs) +│ │ │ ├── hero.ts # HeroInfo schema +│ │ │ ├── skill.ts # SkillSet(SkillConfig) schema +│ │ │ ├── field.ts # FieldSkillSet(FieldSkillConfig) schema +│ │ │ └── registry.ts # 三表注册 + getSchema +│ │ ├── validation/index.ts # validate(tableId, allRecords, context) → Issue[] +│ │ └── desc/buildSkillDesc.ts # HeroSkillDesc 的 JS 移植(预览用) +│ └── panels/default/ +│ ├── index.ts # Editor.Panel.define + Vue mount +│ └── app.ts # 最小 Vue app(Plan A:证明 query-keys 打通) +├── dist/ # 产物(main.js, panels/default.js)—— gitignore +└── __tests__/ + ├── fixtures/heroSet.sample.ts # 真实片段副本(含 speed + enumRef) + ├── fixtures/skillSet.sample.ts + ├── recordValue.serializer.test.ts + ├── parser.test.ts + ├── tsConfigFile.test.ts + ├── validation.test.ts + ├── schema.test.ts + └── buildSkillDesc.test.ts +``` + +**职责边界:** `src/shared/**` 与 `src/io/**` 严禁 import `cc` 或 `Editor`(保证可测)。`src/main/**` 才允许用 `Editor`。IO 层 `save()` 只做 fs+语法校验(纯),asset-db refresh 由 main 在 save 成功后调用(解耦)。 + +--- + +## Task 1: 扩展脚手架(package.json / tsconfig / esbuild / i18n) + +**Files:** +- Create: `extensions/pixelhero-config-editor/package.json` +- Create: `extensions/pixelhero-config-editor/tsconfig.json` +- Create: `extensions/pixelhero-config-editor/esbuild.config.mjs` +- Create: `extensions/pixelhero-config-editor/i18n/en.js` +- Create: `extensions/pixelhero-config-editor/i18n/zh.js` +- Create: `extensions/pixelhero-config-editor/.gitignore` + +- [ ] **Step 1: 写 package.json** + +```json +{ + "package_version": 2, + "name": "pixelhero-config-editor", + "version": "0.1.0", + "description": "i18n:pixelhero-config-editor.description", + "main": "./dist/main.js", + "author": "pixelhero", + "editor": ">=3.8.0", + "scripts": { + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "test": "tsx --test __tests__/*.test.ts" + }, + "panels": { + "default": { + "title": "i18n:pixelhero-config-editor.title", + "type": "dockable", + "main": "./dist/panels/default.js", + "size": { "width": 960, "height": 640, "min-width": 640, "min-height": 480 } + } + }, + "contributions": { + "menu": [ + { "path": "i18n:menu.panel/英雄技能配置", "message": "open-panel" } + ], + "messages": { + "open-panel": { "methods": ["open-panel"] }, + "query-schema": { "methods": ["query-schema"] }, + "query-enums": { "methods": ["query-enums"] }, + "query-keys": { "methods": ["query-keys"] }, + "query-record": { "methods": ["query-record"] }, + "query-preview-desc": { "methods": ["query-preview-desc"] }, + "validate": { "methods": ["validate"] }, + "save-record": { "methods": ["save-record"] }, + "revert-record": { "methods": ["revert-record"] } + } + }, + "dependencies": { + "typescript": "^5.4.0", + "vue": "^3.4.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.20.0", + "fs-extra": "^11.0.0", + "tsx": "^4.7.0" + } +} +``` + +- [ ] **Step 2: 写 tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "types": ["node"], + "lib": ["ES2020", "DOM"] + }, + "include": ["src/**/*", "__tests__/**/*"] +} +``` + +- [ ] **Step 3: 写 esbuild.config.mjs**(含 vue esm-bundler 别名以启用运行时模板编译) + +```js +import { build, context } from 'esbuild'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const watch = process.argv.includes('--watch'); + +const common = { + bundle: true, + sourcemap: false, + logLevel: 'info', + alias: { 'vue': 'vue/dist/vue.esm-bundler.js' }, +}; + +const entries = [ + { entryPoints: ['src/main/index.ts'], outfile: 'dist/main.js', platform: 'node', format: 'cjs', external: [] }, + { entryPoints: ['src/panels/default/index.ts'], outfile: 'dist/panels/default.js', platform: 'browser', format: 'iife', external: [] }, +]; + +if (watch) { + for (const e of entries) { + const ctx = await context({ ...common, ...e }); + await ctx.watch(); + } +} else { + for (const e of entries) await build({ ...common, ...e }); +} +``` + +- [ ] **Step 4: 写 i18n/en.js 与 i18n/zh.js** + +`i18n/en.js`: +```js +exports.en = { + 'pixelhero-config-editor': { description: 'Hero/Skill Config Editor', title: 'Hero/Skill Config' }, + 'menu': { 'panel/英雄技能配置': 'Hero/Skill Config' }, +}; +``` +`i18n/zh.js`: +```js +exports.zh = { + 'pixelhero-config-editor': { description: '英雄/技能配置编辑器', title: '英雄/技能配置' }, + 'menu': { 'panel/英雄技能配置': '英雄技能配置' }, +}; +``` + +- [ ] **Step 5: 写 .gitignore** + +``` +node_modules/ +dist/ +*.bak +``` + +- [ ] **Step 6: 安装依赖并提交** + +```bash +cd extensions/pixelhero-config-editor +npm install +cd ../.. +git add extensions/pixelhero-config-editor/package.json extensions/pixelhero-config-editor/package-lock.json extensions/pixelhero-config-editor/tsconfig.json extensions/pixelhero-config-editor/esbuild.config.mjs extensions/pixelhero-config-editor/i18n extensions/pixelhero-config-editor/.gitignore +git commit -m "feat(config-editor): scaffold extension manifest, build, i18n" +``` + +--- + +## Task 2: RecordValue 类型与枚举镜像(纯逻辑) + +**Files:** +- Create: `extensions/pixelhero-config-editor/src/io/recordValue.ts` +- Create: `extensions/pixelhero-config-editor/src/shared/schema/enums.ts` + +- [ ] **Step 1: 写 recordValue.ts** + +```ts +/** IO 层对配置对象字面量值的结构化表示。零依赖。 */ +export type RecordValue = + | { kind: 'num'; value: number } + | { kind: 'str'; value: string } + | { kind: 'bool'; value: boolean } + | { kind: 'enumRef'; qualifier: string; member: string } // 如 FacSet.HERO + | { kind: 'speed'; level: string } // AtkSpeedSet[AtkSpeedLv.X].cd + | { kind: 'obj'; props: Record } + | { kind: 'arr'; items: RecordValue[] } + | { kind: 'raw'; text: string }; // 未识别表达式,原样保留 + +export function isScalar(v: RecordValue): boolean { + return v.kind === 'num' || v.kind === 'str' || v.kind === 'bool' + || v.kind === 'enumRef' || v.kind === 'speed' || v.kind === 'raw'; +} +``` + +- [ ] **Step 2: 写 enums.ts**(镜像游戏枚举;value 与游戏 enum 数值一致) + +```ts +export interface EnumMember { label: string; value: number | string; } +export interface EnumDef { qualifier: string; members: EnumMember[]; } + +export const ENUMS: Record = { + HType: { qualifier: 'HType', members: [ + { label: '近战 Melee', value: 0 }, { label: '中程 Mid', value: 1 }, { label: '远程 Long', value: 2 } ] }, + FacSet: { qualifier: 'FacSet', members: [ + { label: '英雄 HERO', value: 0 }, { label: '怪物 MON', value: 1 } ] }, + TGroup: { qualifier: 'TGroup', members: [ + { label: '自身 Self', value: 0 }, { label: '友方含己 Ally', value: 1 }, + { label: '友方 Team', value: 2 }, { label: '敌方 Enemy', value: 3 }, { label: '全体 All', value: 4 } ] }, + DTType: { qualifier: 'DTType', members: [ + { label: '单体 single', value: 0 }, { label: '范围 range', value: 1 }, { label: '3x3 aoe_grid', value: 2 } ] }, + SkillKind: { qualifier: 'SkillKind', members: [ + { label: '伤害 Damage', value: 0 }, { label: '治疗 Heal', value: 1 }, { label: '护盾 Shield', value: 2 }, + { label: '辅助 Support', value: 3 }, { label: '金币 Gold', value: 4 } ] }, + DType: { qualifier: 'DType', members: [ + { label: '物理 ATK', value: 0 }, { label: '冰 ICE', value: 1 }, { label: '火 FIRE', value: 2 }, { label: '风 WIND', value: 3 } ] }, + IType: { qualifier: 'IType', members: [ + { label: '近战 Melee', value: 0 }, { label: '远程 remote', value: 1 }, { label: '辅助 support', value: 2 } ] }, + RType: { qualifier: 'RType', members: [ + { label: '直线 linear', value: 0 }, { label: '贝塞尔 bezier', value: 1 }, + { label: '固定起点 fixed', value: 2 }, { label: '固定终点 fixedEnd', value: 3 } ] }, + EType: { qualifier: 'EType', members: [ + { label: '动画结束 animationEnd', value: 0 }, { label: '时间结束 timeEnd', value: 1 }, { label: '碰撞 collision', value: 2 } ] }, + FieldSkillType: { qualifier: 'FieldSkillType', members: [ + { label: '召唤次数 SummonCount', value: 1 }, { label: '死亡次数 DeadCount', value: 2 }, + { label: '开场次数 StartCount', value: 3 }, { label: '结束次数 EndCount', value: 4 }, + { label: '每波金币 WaveGold', value: 5 }, { label: '卖出金币 SellGold', value: 6 }, + { label: '战后回复 WaveHeal', value: 7 }, { label: '英雄攻击 HeroAtk', value: 8 }, + { label: '英雄击晕 HeroStun', value: 9 }, { label: '英雄暴击 HeroCrit', value: 10 }, + { label: '英雄暴伤 HeroCritDamage', value: 11 }, { label: '英雄攻速 HeroSpeed', value: 12 }, + { label: '购买优惠 BuyDiscount', value: 13 }, { label: '刷新优惠 RefreshDiscount', value: 14 }, + { label: '英雄生命 HeroHp', value: 16 }, { label: '英雄风怒 HeroWindFury', value: 17 }, + { label: '英雄穿刺 HeroPuncture', value: 18 }, { label: '攻击次数 AtkCount', value: 19 }, + { label: '受击次数 BeAtkCount', value: 20 } ] }, + AtkSpeedLv: { qualifier: 'AtkSpeedLv', members: [ + { label: '极速++ VeryFast1', value: 1 }, { label: '极速+ VeryFast2', value: 2 }, { label: '极速 VeryFast3', value: 3 }, + { label: '快速++ Fast1', value: 4 }, { label: '快速+ Fast2', value: 5 }, { label: '快速 Fast3', value: 6 }, + { label: '中速++ Normal1', value: 7 }, { label: '中速+ Normal2', value: 8 }, { label: '中速 Normal3', value: 9 }, + { label: '一般+ Mid1', value: 10 }, { label: '一般 Mid2', value: 11 }, { label: '一般- Mid3', value: 12 }, + { label: '慢 Slow1', value: 13 }, { label: '慢+ Slow2', value: 14 }, { label: '慢++ Slow3', value: 15 }, + { label: '很慢 VerySlow1', value: 16 }, { label: '很慢+ VerySlow2', value: 17 }, { label: '很慢++ VerySlow3', value: 18 } ] }, + Attrs: { qualifier: 'Attrs', members: [ + { label: 'ap', value: 'ap' }, { label: 'hp', value: 'hp' }, { label: 'hp_max', value: 'hp_max' }, + { label: 'critical', value: 'critical' }, { label: 'critical_damage', value: 'critical_damage' }, + { label: 'stun_chance', value: 'stun_chance' }, { label: 'puncture_chance', value: 'puncture_chance' }, + { label: 'wfuny', value: 'wfuny' }, { label: 'freeze_chance', value: 'freeze_chance' }, + { label: 'knockback_chance', value: 'knockback_chance' } ] }, +}; + +/** qualifier(如 'FacSet')→ 对应 enumId(如 'FacSet')。当前两者同名。 */ +export const QUALIFIER_TO_ID: Record = Object.fromEntries( + Object.entries(ENUMS).map(([id, def]) => [def.qualifier, id]) +); +``` + +- [ ] **Step 3: 提交** + +```bash +git add extensions/pixelhero-config-editor/src/io/recordValue.ts extensions/pixelhero-config-editor/src/shared/schema/enums.ts +git commit -m "feat(config-editor): add RecordValue type and enum mirror" +``` + +--- + +## Task 3: Schema 类型与三张表 schema + +**Files:** +- Create: `extensions/pixelhero-config-editor/src/shared/schema/types.ts` +- Create: `extensions/pixelhero-config-editor/src/shared/schema/hero.ts` +- Create: `extensions/pixelhero-config-editor/src/shared/schema/skill.ts` +- Create: `extensions/pixelhero-config-editor/src/shared/schema/field.ts` +- Create: `extensions/pixelhero-config-editor/src/shared/schema/registry.ts` + +- [ ] **Step 1: 写 types.ts** + +```ts +export type TableId = 'hero' | 'skill' | 'field'; + +export type FieldType = + | 'number' | 'string' | 'boolean' + | 'enum' // 下拉,选项来自 enumRef + | 'ref' // 引用另一表 uuid + | 'speedExpr' // 攻速符号表达式 + | 'skillMap' | 'triggerSlots' | 'fieldList' | 'reviveSlot' | 'overrides'; + +export interface FieldSchema { + key: string; + label: string; + type: FieldType; + required?: boolean; + default?: unknown; + group?: string; + enumRef?: string; // type='enum' + refTable?: TableId; // type='ref' +} + +export interface IdSegment { label: string; min: number; max: number; } + +export interface TableSchema { + id: TableId; + label: string; + sourceFile: string; // 相对 assets/script/game/common/config/ + exportName: string; // 'HeroInfo' | 'SkillSet' | 'FieldSkillSet' + listExportName?: string; // 'HeroList'(仅 hero) + idSegments: IdSegment[]; + fields: FieldSchema[]; +} +``` + +- [ ] **Step 2: 写 hero.ts** + +```ts +import { TableSchema } from './types'; +export const heroSchema: TableSchema = { + id: 'hero', label: '英雄/怪物', + sourceFile: 'heroSet.ts', exportName: 'HeroInfo', listExportName: 'HeroList', + idSegments: [ + { label: '英雄', min: 5000, max: 5999 }, + { label: '怪物', min: 6000, max: 6999 }, + ], + fields: [ + { key: 'uuid', label: 'UUID', type: 'number', required: true, group: '基础' }, + { key: 'name', label: '名称', type: 'string', required: true, group: '基础' }, + { key: 'path', label: '资源路径', type: 'string', required: true, group: '基础' }, + { key: 'icon', label: '图标', type: 'string', group: '基础' }, + { key: 'fac', label: '阵营', type: 'enum', enumRef: 'FacSet', required: true, group: '基础' }, + { key: 'pool_lv', label: '卡片等级', type: 'number', group: '基础' }, + { key: 'lv', label: '英雄等级', type: 'number', required: true, default: 1, group: '基础' }, + { key: 'type', label: '攻击定位', type: 'enum', enumRef: 'HType', required: true, group: '基础' }, + { key: 'hp', label: '生命上限', type: 'number', required: true, group: '基础' }, + { key: 'ap', label: '攻击力', type: 'number', required: true, group: '基础' }, + { key: 'dis', label: '攻击距离', type: 'number', group: '基础' }, + { key: 'speed', label: '移动速度', type: 'number', group: '基础' }, + { key: 'skills', label: '携带技能', type: 'skillMap', required: true, group: '技能' }, + { key: 'call', label: '召唤触发', type: 'triggerSlots', group: '触发技能' }, + { key: 'dead', label: '死亡触发', type: 'triggerSlots', group: '触发技能' }, + { key: 'fstart', label: '战斗开始触发', type: 'triggerSlots', group: '触发技能' }, + { key: 'fend', label: '战斗结束触发', type: 'triggerSlots', group: '触发技能' }, + { key: 'atking', label: '攻击触发', type: 'triggerSlots', group: '触发技能' }, + { key: 'atked', label: '受击触发', type: 'triggerSlots', group: '触发技能' }, + { key: 'field', label: '驻场技能', type: 'fieldList', group: '触发技能' }, + { key: 'revive', label: '复活触发', type: 'reviveSlot', group: '触发技能' }, + { key: 'info', label: '描述文案', type: 'string', required: true, group: '基础' }, + ], +}; +``` + +- [ ] **Step 3: 写 skill.ts** + +```ts +import { TableSchema } from './types'; +export const skillSchema: TableSchema = { + id: 'skill', label: '技能', + sourceFile: 'SkillSet.ts', exportName: 'SkillSet', + idSegments: [{ label: '技能', min: 6000, max: 6999 }], + fields: [ + { key: 'uuid', label: 'UUID', type: 'number', required: true, group: '基础' }, + { key: 'name', label: '名称', type: 'string', required: true, group: '基础' }, + { key: 'sp_name', label: '特效名', type: 'string', required: true, group: '基础' }, + { key: 'icon', label: '图标ID', type: 'string', required: true, group: '基础' }, + { key: 'act', label: '执行动画', type: 'string', required: true, group: '基础' }, + { key: 'TGroup', label: '目标群体', type: 'enum', enumRef: 'TGroup', required: true, group: '目标' }, + { key: 'DTType', label: '伤害类型', type: 'enum', enumRef: 'DTType', required: true, group: '目标' }, + { key: 'IType', label: '技能类型', type: 'enum', enumRef: 'IType', required: true, group: '目标' }, + { key: 'RType', label: '运行类型', type: 'enum', enumRef: 'RType', required: true, group: '目标' }, + { key: 'EType', label: '结束条件', type: 'enum', enumRef: 'EType', required: true, group: '目标' }, + { key: 'kind', label: '主效果', type: 'enum', enumRef: 'SkillKind', group: '目标' }, + { key: 'ap', label: 'ap(伤害%/护盾次)', type: 'number', required: true, group: '数值' }, + { key: 'gold', label: '金币值', type: 'number', group: '数值' }, + { key: 'hit_count', label: '可命中次数', type: 'number', required: true, group: '数值' }, + { key: 'hitcd', label: '伤害间隔', type: 'number', required: true, group: '数值' }, + { key: 'speed', label: '移动速度', type: 'number', required: true, group: '数值' }, + { key: 'ready', label: '前摇时间', type: 'number', required: true, group: '数值' }, + { key: 'with', label: '宽度', type: 'number', default: 0, group: '数值' }, + { key: 'readyAnm', label: '前摇动画', type: 'string', group: '动画' }, + { key: 'endAnm', label: '结束动画', type: 'string', group: '动画' }, + { key: 'DAnm', label: '命中动画ID', type: 'string', group: '动画' }, + { key: 'EAnm', label: '结束动画ID', type: 'number', group: '动画' }, + { key: 'crt', label: '额外暴击率', type: 'number', group: '高级' }, + { key: 'stun', label: '额外击晕概率', type: 'number', group: '高级' }, + { key: 'frz', label: '额外冰冻概率', type: 'number', group: '高级' }, + { key: 'bck', label: '额外击退概率', type: 'number', group: '高级' }, + { key: 'buff_type', label: 'Buff类型', type: 'enum', enumRef: 'Attrs', group: '高级' }, + { key: 'call_hero', label: '召唤英雄', type: 'ref', refTable: 'hero', group: '高级' }, + { key: 'time', label: '持续时间', type: 'number', group: '高级' }, + { key: 'bezier_start_y', label: '贝塞尔起始Y', type: 'number', group: '高级' }, + { key: 'bezier_mid_y', label: '贝塞尔中点Y', type: 'number', group: '高级' }, + { key: 'bezier_arc', label: '贝塞尔弧度', type: 'number', group: '高级' }, + { key: 'info', label: '描述', type: 'string', required: true, group: '基础' }, + ], +}; +``` + +- [ ] **Step 4: 写 field.ts** + +```ts +import { TableSchema } from './types'; +export const fieldSchema: TableSchema = { + id: 'field', label: '驻场技能', + sourceFile: 'SkillSet.ts', exportName: 'FieldSkillSet', + idSegments: [{ label: '驻场技能', min: 7000, max: 7999 }], + fields: [ + { key: 'uuid', label: 'UUID', type: 'number', required: true, group: '基础' }, + { key: 'name', label: '名称', type: 'string', required: true, group: '基础' }, + { key: 'icon', label: '图标', type: 'string', required: true, group: '基础' }, + { key: 'type', label: '类型', type: 'enum', enumRef: 'FieldSkillType', required: true, group: '效果' }, + { key: 'value', label: '数值', type: 'number', required: true, group: '效果' }, + { key: 'info', label: '描述', type: 'string', required: true, group: '基础' }, + ], +}; +``` + +- [ ] **Step 5: 写 registry.ts** + +```ts +import { TableId, TableSchema } from './types'; +import { heroSchema } from './hero'; +import { skillSchema } from './skill'; +import { fieldSchema } from './field'; + +export const REGISTRY: Record = { + hero: heroSchema, skill: skillSchema, field: fieldSchema, +}; +export function getSchema(id: TableId): TableSchema { return REGISTRY[id]; } +export function allSchemas(): TableSchema[] { return Object.values(REGISTRY); } +``` + +- [ ] **Step 6: 提交** + +```bash +git add extensions/pixelhero-config-editor/src/shared/schema +git commit -m "feat(config-editor): add schema types and hero/skill/field table schemas" +``` + +--- + +## Task 4: 序列化器 serializer(RecordValue → TS 文本) + +**Files:** +- Create: `extensions/pixelhero-config-editor/src/io/serializer.ts` +- Test: `extensions/pixelhero-config-editor/__tests__/recordValue.serializer.test.ts` +- Create: `extensions/pixelhero-config-editor/__tests__/.gitignore`(忽略 `.tmp/`) + +- [ ] **Step 1: 写 .gitignore(测试临时目录)** + +``` +.tmp/ +``` + +- [ ] **Step 2: 写失败测试 `recordValue.serializer.test.ts`** + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { serializeValue, serializeEntry } from '../src/io/serializer'; +import { RecordValue } from '../src/io/recordValue'; + +test('serializeValue: num/str/bool', () => { + assert.equal(serializeValue({ kind: 'num', value: 400 }), '400'); + assert.equal(serializeValue({ kind: 'str', value: '小铁卫' }), '"小铁卫"'); + assert.equal(serializeValue({ kind: 'bool', value: true }), 'true'); +}); +test('serializeValue: enumRef', () => { + const v: RecordValue = { kind: 'enumRef', qualifier: 'FacSet', member: 'HERO' }; + assert.equal(serializeValue(v), 'FacSet.HERO'); +}); +test('serializeValue: speed', () => { + const v: RecordValue = { kind: 'speed', level: 'Slow3' }; + assert.equal(serializeValue(v), 'AtkSpeedSet[AtkSpeedLv.Slow3].cd'); +}); +test('serializeValue: arr', () => { + const v: RecordValue = { kind: 'arr', items: [{ kind: 'num', value: 1 }, { kind: 'num', value: 2 }] }; + assert.equal(serializeValue(v), '[1,2]'); +}); +test('serializeValue: obj', () => { + const v: RecordValue = { kind: 'obj', props: { a: { kind: 'num', value: 1 }, b: { kind: 'str', value: 'x' } } }; + assert.equal(serializeValue(v), '{a:1,b:"x"}'); +}); +test('serializeValue: raw passthrough', () => { + const v: RecordValue = { kind: 'raw', text: 'a + b' }; + assert.equal(serializeValue(v), 'a + b'); +}); +test('serializeEntry: all-scalar one line with trailing comma', () => { + const v: RecordValue = { kind: 'obj', props: { uuid: { kind: 'num', value: 1 }, name: { kind: 'str', value: 'x' } } }; + assert.equal(serializeEntry('1', v), '1:{uuid:1,name:"x"},'); +}); +test('serializeEntry: nested on continuation lines', () => { + const v: RecordValue = { kind: 'obj', props: { + uuid: { kind: 'num', value: 5011 }, + skills: { kind: 'obj', props: { 6001: { kind: 'obj', props: { uuid: { kind: 'num', value: 6001 } } } } }, + } }; + const out = serializeEntry('5011', v); + assert.match(out, /^5011:\{uuid:5011,$/); // 首行:键 + 标量 + assert.match(out, / skills:\{6001:\{uuid:6001\}\},$/m); // 续行 8 空格 + assert.match(out, /^ \},$/m); // 闭合 4 空格 +}); +``` + +- [ ] **Step 3: 运行测试,确认失败** + +```bash +cd extensions/pixelhero-config-editor && npx tsx --test __tests__/recordValue.serializer.test.ts +``` +Expected: FAIL(`Cannot find module '../src/io/serializer'`) + +- [ ] **Step 4: 写 serializer.ts** + +```ts +import { RecordValue, isScalar } from './recordValue'; + +export function serializeValue(v: RecordValue): string { + switch (v.kind) { + case 'num': return String(v.value); + case 'str': return JSON.stringify(v.value); + case 'bool': return String(v.value); + case 'enumRef': return `${v.qualifier}.${v.member}`; + case 'speed': return `AtkSpeedSet[AtkSpeedLv.${v.level}].cd`; + case 'arr': return `[${v.items.map(serializeValue).join(',')}]`; + case 'obj': { + const body = Object.entries(v.props).map(([k, val]) => `${k}:${serializeValue(val)}`).join(','); + return `{${body}}`; + } + case 'raw': return v.text; + } +} + +/** 序列化一条顶层记录。首行无缩进(缩进由调用处保留的 trivia 提供);嵌套字段 8 空格续行;闭合 4 空格。 */ +export function serializeEntry(key: string, obj: RecordValue): string { + if (obj.kind !== 'obj') return `${key}:${serializeValue(obj)},`; + const props = obj.props; + const keys = Object.keys(props); + const scalars = keys.filter(k => isScalar(props[k])); + const nested = keys.filter(k => !isScalar(props[k])); + const scalarTxt = scalars.map(k => `${k}:${serializeValue(props[k])}`).join(','); + if (nested.length === 0) return `${key}:{${scalarTxt}},`; + const lines = [`${key}:{${scalarTxt},`]; + for (const k of nested) lines.push(` ${k}:${serializeValue(props[k])},`); + lines.push(' },'); + return lines.join('\n'); +} +``` + +- [ ] **Step 5: 运行测试,确认通过** + +```bash +npx tsx --test __tests__/recordValue.serializer.test.ts +``` +Expected: PASS(全部用例) + +- [ ] **Step 6: 提交** + +```bash +git add extensions/pixelhero-config-editor/src/io/serializer.ts extensions/pixelhero-config-editor/__tests__/recordValue.serializer.test.ts extensions/pixelhero-config-editor/__tests__/.gitignore +git commit -m "feat(config-editor): add RecordValue serializer with tests" +``` + +--- + +## Task 5: 解析器 parser(AST → RecordValue) + +**Files:** +- Create: `extensions/pixelhero-config-editor/src/io/parser.ts` +- Test: `extensions/pixelhero-config-editor/__tests__/parser.test.ts` + +- [ ] **Step 1: 写失败测试 `parser.test.ts`** + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as ts from 'typescript'; +import { parseExpression, findExportObjectLiteral } from '../src/io/parser'; + +function parse(text: string): ts.Expression { + const src = ts.createSourceFile('x.ts', `const _ = ${text};`, ts.ScriptTarget.Latest, true); + const decl = src.statements[0] as ts.VariableStatement; + return decl.declarationList.declarations[0].initializer!; +} + +test('num / str / bool', () => { + assert.deepEqual(parseExpression(parse('400')), { kind: 'num', value: 400 }); + assert.deepEqual(parseExpression(parse('"小铁卫"')), { kind: 'str', value: '小铁卫' }); + assert.deepEqual(parseExpression(parse('true')), { kind: 'bool', value: true }); +}); +test('enumRef: FacSet.HERO', () => { + assert.deepEqual(parseExpression(parse('FacSet.HERO')), { kind: 'enumRef', qualifier: 'FacSet', member: 'HERO' }); +}); +test('speed: AtkSpeedSet[AtkSpeedLv.Slow3].cd', () => { + assert.deepEqual(parseExpression(parse('AtkSpeedSet[AtkSpeedLv.Slow3].cd')), { kind: 'speed', level: 'Slow3' }); +}); +test('non-Speed two-segment access falls back to enumRef', () => { + assert.deepEqual(parseExpression(parse('Foo.bar')), { kind: 'enumRef', qualifier: 'Foo', member: 'bar' }); +}); +test('arr', () => { + assert.deepEqual(parseExpression(parse('[1,2,3]')), { kind: 'arr', items: [ + { kind: 'num', value: 1 }, { kind: 'num', value: 2 }, { kind: 'num', value: 3 }] }); +}); +test('obj with numeric + identifier keys', () => { + const v = parseExpression(parse('{6001:{uuid:6001},name:"x"}')); + assert.equal(v.kind, 'obj'); + assert.deepEqual(v.props['6001'], { kind: 'obj', props: { uuid: { kind: 'num', value: 6001 } } }); + assert.deepEqual(v.props['name'], { kind: 'str', value: 'x' }); +}); +test('raw: unsupported expression preserved verbatim', () => { + const v = parseExpression(parse('a + b')); + assert.equal(v.kind, 'raw'); + assert.equal((v as any).text, 'a + b'); +}); +test('findExportObjectLiteral locates HeroInfo', () => { + const src = ts.createSourceFile('x.ts', + `export const HeroInfo: Record = { 5011:{uuid:5011} };`, ts.ScriptTarget.Latest, true); + const node = findExportObjectLiteral(src, 'HeroInfo'); + assert.ok(node); + assert.equal(node!.properties.length, 1); +}); +``` + +- [ ] **Step 2: 运行测试,确认失败** + +```bash +npx tsx --test __tests__/parser.test.ts +``` +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 写 parser.ts** + +```ts +import * as ts from 'typescript'; +import { RecordValue } from './recordValue'; + +/** 在 SourceFile 中查找 `export const = {...}`,返回其对象字面量节点。 */ +export function findExportObjectLiteral(src: ts.SourceFile, name: string): ts.ObjectLiteralExpression | null { + let result: ts.ObjectLiteralExpression | null = null; + const visit = (node: ts.Node) => { + if (result) return; + if (ts.isVariableStatement(node)) { + for (const d of node.declarationList.declarations) { + if (d.name.getText() === name && d.initializer && ts.isObjectLiteralExpression(d.initializer)) { + result = d.initializer; + return; + } + } + } + ts.forEachChild(node, visit); + }; + visit(src); + return result; +} + +/** 识别 `AtkSpeedSet[AtkSpeedLv.].cd`,否则 null。 */ +function tryParseSpeedExpr(node: ts.PropertyAccessExpression): RecordValue | null { + if (node.name.text !== 'cd') return null; + const inner = node.expression; + if (!ts.isElementAccessExpression(inner)) return null; + if (!ts.isIdentifier(inner.expression) || inner.expression.text !== 'AtkSpeedSet') return null; + const arg = inner.argumentExpression; + if (!ts.isPropertyAccessExpression(arg)) return null; + if (!ts.isIdentifier(arg.expression) || arg.expression.text !== 'AtkSpeedLv') return null; + return { kind: 'speed', level: arg.name.text }; +} + +export function parseExpression(node: ts.Expression): RecordValue { + if (ts.isNumericLiteral(node)) return { kind: 'num', value: Number(node.text) }; + if (ts.isStringLiteral(node)) return { kind: 'str', value: node.text }; + if (node.kind === ts.SyntaxKind.TrueKeyword) return { kind: 'bool', value: true }; + if (node.kind === ts.SyntaxKind.FalseKeyword) return { kind: 'bool', value: false }; + + if (ts.isPropertyAccessExpression(node)) { + const speed = tryParseSpeedExpr(node); + if (speed) return speed; + if (ts.isIdentifier(node.expression)) { + return { kind: 'enumRef', qualifier: node.expression.text, member: node.name.text }; + } + return { kind: 'raw', text: node.getText() }; // 多级 a.b.c 等 + } + + if (ts.isArrayLiteralExpression(node)) { + return { kind: 'arr', items: node.elements.map(e => parseExpression(e)) }; + } + + if (ts.isObjectLiteralExpression(node)) { + const props: Record = {}; + for (const m of node.properties) { + if (ts.isPropertyAssignment(m)) { + props[m.name.getText()] = parseExpression(m.initializer); + } + } + return { kind: 'obj', props }; + } + + return { kind: 'raw', text: node.getText() }; // 二元/模板/调用/其他元素访问 +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +```bash +npx tsx --test __tests__/parser.test.ts +``` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add extensions/pixelhero-config-editor/src/io/parser.ts extensions/pixelhero-config-editor/__tests__/parser.test.ts +git commit -m "feat(config-editor): add AST parser (speed/enumRef/raw) with tests" +``` + +--- + +## Task 6: 测试夹具 fixtures + +**Files:** +- Create: `extensions/pixelhero-config-editor/__tests__/fixtures/heroSet.sample.ts` +- Create: `extensions/pixelhero-config-editor/__tests__/fixtures/skillSet.sample.ts` + +- [ ] **Step 1: 写 heroSet.sample.ts**(真实片段;未解析标识符不影响纯语法解析) + +```ts +// 测试夹具:镜像真实 heroSet.ts 片段(speed 表达式 + 枚举引用 + 触发槽 + 驻场 + 复活) +export const HeroInfo: Record = { + 5011:{uuid:5011,name:"小铁卫",path:"hk1", fac:FacSet.HERO,pool_lv:1,lv:1,type:HType.Melee,hp:400,ap:20, + skills:{6001:{uuid:6001,lv:1,cd:AtkSpeedSet[AtkSpeedLv.Slow3].cd,ccd:0}}, + atked:[{s_uuid:6301,t_num:3,overrides:{TGroup:TGroup.Self,ap:4}}], + field:[7015], + revive:{s_uuid:6501,r_num:1,upr:0.3}, + info:"每受击3次为自身添加4层护盾"}, + 6001:{uuid:6001,name:"兽人战士",path:"m1", fac:FacSet.MON,lv:1,type:HType.Melee,hp:220,ap:10,speed:70, + skills:{6005:{uuid:6005,lv:1,cd:AtkSpeedSet[AtkSpeedLv.VerySlow1].cd,ccd:0}},info:"基础近战位怪"}, +}; + +export const HeroList: number[] = [5011]; +``` + +- [ ] **Step 2: 写 skillSet.sample.ts** + +```ts +// 测试夹具:镜像真实 SkillSet.ts 片段(含 SkillSet + FieldSkillSet) +export const SkillSet: Record = { + 6001:{uuid:6001,name:"火球",sp_name:"atk_1",icon:"Stat_Attack_01",TGroup:TGroup.Enemy,act:"atk", + DTType:DTType.single,ap:100,hit_count:1,hitcd:0.2,speed:720,with:0,ready:0.2, + IType:IType.Melee,RType:RType.bezier,EType:EType.collision,info:"造成攻击力100%的伤害"}, + 6301:{uuid:6301,name:"护盾",sp_name:"buff_wind",icon:"Stat_Defense",TGroup:TGroup.Self,act:"atk", + DTType:DTType.single,kind:SkillKind.Shield,ap:3,hit_count:1,hitcd:0.2,speed:720,with:0,ready:0.2, + IType:IType.support,RType:RType.fixed,EType:EType.animationEnd,info:"添加护盾"}, + 6501:{uuid:6501,name:"复活",sp_name:"buff_wind",icon:"Stat_HolyDamage",TGroup:TGroup.Self,act:"atk", + DTType:DTType.single,kind:SkillKind.Support,ap:50,hit_count:3,hitcd:0.2,speed:720,with:0,ready:0.2, + IType:IType.support,RType:RType.fixed,EType:EType.animationEnd,info:"复活百分比"}, +}; + +export const FieldSkillSet: Record = { + 7015:{uuid:7015,name:"死亡强化",icon:"Stat_PoisonChanceIncrease",type:FieldSkillType.DeadCount,value:1,info:"死亡触发技能次数+1"}, +}; +``` + +- [ ] **Step 3: 提交** + +```bash +git add extensions/pixelhero-config-editor/__tests__/fixtures +git commit -m "test(config-editor): add hero/skill fixtures mirroring real config shape" +``` + +--- + +## Task 7: TsConfigFile — load / getKeys / read + +**Files:** +- Create: `extensions/pixelhero-config-editor/src/io/TsConfigFile.ts` +- Test: `extensions/pixelhero-config-editor/__tests__/tsConfigFile.test.ts` + +- [ ] **Step 1: 写失败测试 `tsConfigFile.test.ts`(load/keys/read 部分)** + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, copyFileSync, mkdtempSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { TsConfigFile } from '../src/io/TsConfigFile'; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixture = join(here, 'fixtures', 'heroSet.sample.ts'); + +test('load + getKeys', () => { + const f = new TsConfigFile(fixture, 'HeroInfo'); + f.load(); + assert.deepEqual(f.getKeys(), ['5011', '6001']); +}); +test('read returns structured value with speed/enumRef preserved', () => { + const f = new TsConfigFile(fixture, 'HeroInfo'); + f.load(); + const v = f.read('5011')!; + assert.equal(v.kind, 'obj'); + assert.deepEqual(v.props['fac'], { kind: 'enumRef', qualifier: 'FacSet', member: 'HERO' }); + const skill = v.props['skills'].props['6001']; + assert.deepEqual(skill.props['cd'], { kind: 'speed', level: 'Slow3' }); +}); +test('getText after load equals original file (no mutation on load)', () => { + const f = new TsConfigFile(fixture, 'HeroInfo'); + f.load(); + assert.equal(f.getText(), readFileSync(fixture, 'utf8')); +}); +test('read missing key returns null', () => { + const f = new TsConfigFile(fixture, 'HeroInfo'); + f.load(); + assert.equal(f.read('9999'), null); +}); +``` + +- [ ] **Step 2: 运行测试,确认失败** + +```bash +npx tsx --test __tests__/tsConfigFile.test.ts +``` +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 写 TsConfigFile.ts(先实现 load/getKeys/read/getText)** + +```ts +import * as fs from 'node:fs'; +import * as ts from 'typescript'; +import { RecordValue } from './recordValue'; +import { findExportObjectLiteral, parseExpression } from './parser'; + +export class TsConfigFile { + private sourceText = ''; + private sourceFile!: ts.SourceFile; + private targetNode!: ts.ObjectLiteralExpression; + private dirty = false; + + constructor(public readonly filePath: string, public readonly exportName: string) {} + + load(): void { + this.sourceText = fs.readFileSync(this.filePath, 'utf8'); + this.reparse(); + this.dirty = false; + } + + private reparse(): void { + this.sourceFile = ts.createSourceFile(this.filePath, this.sourceText, ts.ScriptTarget.Latest, true); + const node = findExportObjectLiteral(this.sourceFile, this.exportName); + if (!node) throw new Error(`export const ${this.exportName} not found or not an object literal in ${this.filePath}`); + this.targetNode = node; + } + + getKeys(): string[] { + return this.targetNode.properties.map(p => (p.name as ts.PropertyName).getText()); + } + + read(key: string): RecordValue | null { + const entry = this.findEntry(key); + if (!entry) return null; + return parseExpression(entry.initializer); + } + + private findEntry(key: string): ts.PropertyAssignment | undefined { + return this.targetNode.properties.find(p => (p.name as ts.PropertyName).getText() === key) as ts.PropertyAssignment | undefined; + } + + getText(): string { return this.sourceText; } + isDirty(): boolean { return this.dirty; } + + // —— patch/add/delete/save 在 Task 8 实现 —— + patch(_key: string, _value: RecordValue): void { throw new Error('not implemented'); } + add(_key: string, _value: RecordValue): void { throw new Error('not implemented'); } + delete(_key: string): void { throw new Error('not implemented'); } + save(): { ok: true } | { ok: false; error: string } { throw new Error('not implemented'); } +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +```bash +npx tsx --test __tests__/tsConfigFile.test.ts +``` +Expected: PASS(4 个用例) + +- [ ] **Step 5: 提交** + +```bash +git add extensions/pixelhero-config-editor/src/io/TsConfigFile.ts extensions/pixelhero-config-editor/__tests__/tsConfigFile.test.ts +git commit -m "feat(config-editor): TsConfigFile load/getKeys/read with tests" +``` + +--- + +## Task 8: TsConfigFile — patch / add / delete / save(含 .bak + 语法校验) + +**Files:** +- Modify: `extensions/pixelhero-config-editor/src/io/TsConfigFile.ts`(替换 patch/add/delete/save 桩,并 import serializeEntry) +- Test: append to `extensions/pixelhero-config-editor/__tests__/tsConfigFile.test.ts` + +- [ ] **Step 1: 在测试文件追加失败用例** + +```ts +function withTempFixture(): { dir: string; file: string } { + const dir = mkdtempSync(join(tmpdir(), 'phcfg-')); + const file = join(dir, 'heroSet.sample.ts'); + copyFileSync(fixture, file); + return { dir, file }; +} + +test('patch updates one entry; reload reads new value; other entries intact', () => { + const { file } = withTempFixture(); + const f = new TsConfigFile(file, 'HeroInfo'); f.load(); + const v = f.read('5011')!; + v.props['ap'] = { kind: 'num', value: 99 }; + f.patch('5011', v); + assert.equal(f.isDirty(), true); + assert.equal(f.save().ok, true); + const f2 = new TsConfigFile(file, 'HeroInfo'); f2.load(); + assert.deepEqual(f2.read('5011')!.props['ap'], { kind: 'num', value: 99 }); + assert.equal(f2.read('6001')!.props['name'].value, '兽人战士'); // 另一条未破坏 +}); + +test('patch preserves speed + enumRef on reload', () => { + const { file } = withTempFixture(); + const f = new TsConfigFile(file, 'HeroInfo'); f.load(); + const v = f.read('5011')!; + v.props['hp'] = { kind: 'num', value: 500 }; + f.patch('5011', v); f.save(); + const f2 = new TsConfigFile(file, 'HeroInfo'); f2.load(); + const again = f2.read('5011')!; + assert.deepEqual(again.props['fac'], { kind: 'enumRef', qualifier: 'FacSet', member: 'HERO' }); + assert.deepEqual(again.props['skills'].props['6001'].props['cd'], { kind: 'speed', level: 'Slow3' }); +}); + +test('add appends entry readable after save+reload', () => { + const { file } = withTempFixture(); + const f = new TsConfigFile(file, 'HeroInfo'); f.load(); + f.add('5099', { kind: 'obj', props: { uuid: { kind: 'num', value: 5099 }, name: { kind: 'str', value: '新英雄' } } }); + f.save(); + const f2 = new TsConfigFile(file, 'HeroInfo'); f2.load(); + assert.ok(f2.getKeys().includes('5099')); + assert.equal(f2.read('5099')!.props['name'].value, '新英雄'); +}); + +test('delete removes entry', () => { + const { file } = withTempFixture(); + const f = new TsConfigFile(file, 'HeroInfo'); f.load(); + f.delete('6001'); f.save(); + const f2 = new TsConfigFile(file, 'HeroInfo'); f2.load(); + assert.ok(!f2.getKeys().includes('6001')); + assert.ok(f2.getKeys().includes('5011')); +}); + +test('save writes a .bak backup of the pre-save file', () => { + const { file } = withTempFixture(); + const f = new TsConfigFile(file, 'HeroInfo'); f.load(); + const v = f.read('5011')!; v.props['hp'] = { kind: 'num', value: 1 }; f.patch('5011', v); + f.save(); + assert.equal(readFileSync(file + '.bak', 'utf8'), readFileSync(fixture, 'utf8')); +}); + +test('save with no edits is a no-op (ok, no .bak written)', () => { + const { file, dir } = withTempFixture(); + const f = new TsConfigFile(file, 'HeroInfo'); f.load(); + assert.equal(f.save().ok, true); + assert.equal(readdirSync(dir).some(p => p.endsWith('.bak')), false); +}); +``` + +- [ ] **Step 2: 运行测试,确认新增用例失败** + +```bash +npx tsx --test __tests__/tsConfigFile.test.ts +``` +Expected: 6 个新用例 FAIL(not implemented) + +- [ ] **Step 3: 实现 patch/add/delete/save(替换 TsConfigFile 中四个桩;文件顶部 import 补 `import { serializeEntry } from './serializer';`)** + +```ts + patch(key: string, value: RecordValue): void { + const entry = this.findEntry(key); + const newText = serializeEntry(key, value); + if (entry) { + const { start, end } = this.entrySpan(entry); + this.sourceText = this.sourceText.slice(0, start) + newText + this.sourceText.slice(end); + } else { + this.insertEntry(newText); + } + this.reparse(); + this.dirty = true; + } + + add(key: string, value: RecordValue): void { + if (this.findEntry(key)) throw new Error(`key ${key} already exists`); + this.insertEntry(serializeEntry(key, value)); + this.reparse(); + this.dirty = true; + } + + delete(key: string): void { + const entry = this.findEntry(key); + if (!entry) return; + const { start, end } = this.entrySpan(entry); + this.sourceText = this.sourceText.slice(0, start) + this.sourceText.slice(end); + this.reparse(); + this.dirty = true; + } + + save(): { ok: true } | { ok: false; error: string } { + if (!this.dirty) return { ok: true }; + const check = ts.createSourceFile(this.filePath, this.sourceText, ts.ScriptTarget.Latest, true); + if (check.parseDiagnostics.length > 0) { + const msg = check.parseDiagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')).join('; '); + return { ok: false, error: `syntax error after edit: ${msg}` }; + } + fs.writeFileSync(this.filePath + '.bak', fs.readFileSync(this.filePath)); + fs.writeFileSync(this.filePath, this.sourceText); + this.dirty = false; + return { ok: true }; + } + + /** 条目文本区间,含尾随逗号;不含前导缩进 trivia(避免吞掉上一行换行)。 */ + private entrySpan(entry: ts.PropertyAssignment): { start: number; end: number } { + const start = entry.getStart(); + let end = entry.getEnd(); + const comma = /^\s*,/.exec(this.sourceText.slice(end)); + if (comma) end += comma[0].length; + return { start, end }; + } + + private insertEntry(entryText: string): void { + const closeBrace = this.targetNode.getLastToken(); + if (!closeBrace) throw new Error('object literal has no closing brace'); + const pos = closeBrace.getStart(); + this.sourceText = this.sourceText.slice(0, pos) + ` ${entryText}\n ` + this.sourceText.slice(pos); + } +``` + +- [ ] **Step 4: 运行该测试,确认通过** + +```bash +npx tsx --test __tests__/tsConfigFile.test.ts +``` +Expected: PASS(10 个用例) + +- [ ] **Step 5: 跑全部测试套件,确认无回归** + +```bash +npx tsx --test __tests__/*.test.ts +``` +Expected: 全部 PASS + +- [ ] **Step 6: 提交** + +```bash +git add extensions/pixelhero-config-editor/src/io/TsConfigFile.ts extensions/pixelhero-config-editor/__tests__/tsConfigFile.test.ts +git commit -m "feat(config-editor): TsConfigFile patch/add/delete/save with .bak + syntax check" +``` + +--- + +## Task 9: 校验层 validation + +**Files:** +- Create: `extensions/pixelhero-config-editor/src/shared/validation/index.ts` +- Test: `extensions/pixelhero-config-editor/__tests__/validation.test.ts` + +校验上下文需引用他表(hero 触发槽引用 skill、field 引用驻场)。`context` 提供 `hasSkill(uuid)`、`hasField(uuid)`、`hasHero(uuid)`、`heroListKeys: Set`。 + +- [ ] **Step 1: 写失败测试 `validation.test.ts`** + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { validate } from '../src/shared/validation'; +import { RecordValue } from '../src/io/recordValue'; + +const ctx = { + hasSkill: (u: number) => [6001, 6301, 6501].includes(u), + hasField: (u: number) => [7015].includes(u), + hasHero: (u: number) => [5011, 6001].includes(u), + heroListKeys: new Set(['5011']), +}; + +function heroObj(extra: Record = {}): RecordValue { + return { kind: 'obj', props: { + uuid: { kind: 'num', value: 5011 }, name: { kind: 'str', value: 'x' }, path: { kind: 'str', value: 'p' }, + fac: { kind: 'enumRef', qualifier: 'FacSet', member: 'HERO' }, lv: { kind: 'num', value: 1 }, + type: { kind: 'enumRef', qualifier: 'HType', member: 'Melee' }, hp: { kind: 'num', value: 400 }, + ap: { kind: 'num', value: 20 }, + skills: { kind: 'obj', props: { 6001: { kind: 'obj', props: { uuid: { kind: 'num', value: 6001 }, lv: { kind: 'num', value: 1 }, cd: { kind: 'speed', level: 'Slow3' }, ccd: { kind: 'num', value: 0 } } } } }, + info: { kind: 'str', value: 'ok' }, + ...extra, + } }; +} + +test('valid hero → no errors', () => { + const issues = validate('hero', new Map([['5011', heroObj()]]), ctx); + assert.equal(issues.filter(i => i.severity === 'error').length, 0); +}); +test('duplicate uuid → error', () => { + const issues = validate('hero', new Map([['5011', heroObj()], ['5011', heroObj()]]), ctx); + assert.ok(issues.some(i => i.severity === 'error' && i.code === 'dup-uuid')); +}); +test('missing required field → error', () => { + const h = heroObj(); delete h.props['name']; + const issues = validate('hero', new Map([['5011', h]]), ctx); + assert.ok(issues.some(i => i.code === 'missing-required' && i.fieldPath === 'name')); +}); +test('trigger slot references missing skill → error', () => { + const h = heroObj({ atked: { kind: 'arr', items: [{ kind: 'obj', props: { + s_uuid: { kind: 'num', value: 9999 }, t_num: { kind: 'num', value: 3 } } }] } }); + const issues = validate('hero', new Map([['5011', h]]), ctx); + assert.ok(issues.some(i => i.code === 'dangling-ref')); +}); +test('field list references missing field skill → error', () => { + const h = heroObj({ field: { kind: 'arr', items: [{ kind: 'num', value: 8888 }] } }); + const issues = validate('hero', new Map([['5011', h]]), ctx); + assert.ok(issues.some(i => i.code === 'dangling-ref')); +}); +test('HeroList/hero consistency: fac=HERO entry missing from HeroList → error', () => { + const ctx2 = { ...ctx, heroListKeys: new Set([]) }; + const issues = validate('hero', new Map([['5011', heroObj()]]), ctx2); + assert.ok(issues.some(i => i.code === 'herolist-inconsistent')); +}); +test('invalid enum value → error', () => { + const h = heroObj({ fac: { kind: 'enumRef', qualifier: 'FacSet', member: 'NOPE' } }); + const issues = validate('hero', new Map([['5011', h]]), ctx); + assert.ok(issues.some(i => i.code === 'bad-enum')); +}); +test('unknown overrides key → error', () => { + const h = heroObj({ atked: { kind: 'arr', items: [{ kind: 'obj', props: { + s_uuid: { kind: 'num', value: 6301 }, t_num: { kind: 'num', value: 3 }, + overrides: { kind: 'obj', props: { bogus: { kind: 'num', value: 1 } } } } }] } }); + const issues = validate('hero', new Map([['5011', h]]), ctx); + assert.ok(issues.some(i => i.code === 'bad-override-key')); +}); +``` + +- [ ] **Step 2: 运行测试,确认失败** + +```bash +npx tsx --test __tests__/validation.test.ts +``` +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 写 validation/index.ts** + +```ts +import { RecordValue } from '../../io/recordValue'; +import { TableId } from '../schema/types'; +import { ENUMS } from '../schema/enums'; + +export type Severity = 'error' | 'warn'; +export interface Issue { + tableId: TableId; key: string; fieldPath: string; + severity: Severity; code: string; message: string; +} +export interface ValidationContext { + hasSkill: (u: number) => boolean; + hasField: (u: number) => boolean; + hasHero: (u: number) => boolean; + heroListKeys: Set; +} + +const OVERRIDE_KEYS = new Set(['TGroup', 'ap', 'gold', 'hit_count', 'hitcd', 'crt', 'frz', 'stun', 'bck', 'buff_type', 'call_hero']); +const TRIGGER_ARRAY_KEYS = ['call', 'dead', 'fstart', 'fend', 'atking', 'atked']; + +function num(v: RecordValue | undefined): number | undefined { + return v && v.kind === 'num' ? v.value : undefined; +} +function isKnownEnum(qualifier: string, member: string): boolean { + const def = ENUMS[qualifier] ?? Object.values(ENUMS).find(e => e.qualifier === qualifier); + return !!def && def.members.some(m => String(m.value) === member || m.label.split(' ').pop() === member); +} + +export function validate(tableId: TableId, records: Map, ctx: ValidationContext): Issue[] { + const issues: Issue[] = []; + const seen = new Set(); + + for (const [key, rec] of records) { + const push = (fieldPath: string, severity: Severity, code: string, message: string) => + issues.push({ tableId, key, fieldPath, severity, code, message }); + + if (seen.has(key)) push('uuid', 'error', 'dup-uuid', `重复 uuid: ${key}`); + seen.add(key); + + if (rec.kind !== 'obj') { push('', 'error', 'not-object', '记录不是对象'); continue; } + const p = rec.props; + + // 必填(按表最小集合) + const required: Record = { + hero: ['uuid', 'name', 'path', 'fac', 'lv', 'type', 'hp', 'ap', 'skills', 'info'], + skill: ['uuid', 'name', 'sp_name', 'icon', 'act', 'TGroup', 'DTType', 'IType', 'RType', 'EType', 'ap', 'hit_count', 'hitcd', 'speed', 'ready', 'info'], + field: ['uuid', 'name', 'icon', 'type', 'value', 'info'], + }; + for (const f of required[tableId]) { + if (p[f] === undefined) push(f, 'error', 'missing-required', `缺少必填字段 ${f}`); + } + + // 枚举值合法 + for (const [f, v] of Object.entries(p)) { + if (v.kind === 'enumRef' && !isKnownEnum(v.qualifier, v.member)) { + push(f, 'error', 'bad-enum', `非法枚举值 ${v.qualifier}.${v.member}`); + } + } + + if (tableId === 'hero') { + // 触发槽数组引用 + overrides 键 + for (const tk of TRIGGER_ARRAY_KEYS) { + const arr = p[tk]; + if (!arr || arr.kind !== 'arr') continue; + for (const it of arr.items) { + if (it.kind !== 'obj') continue; + const su = num(it.props['s_uuid']); + if (su !== undefined && !ctx.hasSkill(su)) push(tk, 'error', 'dangling-ref', `引用了不存在的技能 ${su}`); + const ov = it.props['overrides']; + if (ov && ov.kind === 'obj') { + for (const k of Object.keys(ov.props)) { + if (!OVERRIDE_KEYS.has(k)) push(`${tk}.overrides`, 'error', 'bad-override-key', `非法覆盖键 ${k}`); + } + } + } + } + // field 列表 + const fl = p['field']; + if (fl && fl.kind === 'arr') { + for (const it of fl.items) { const u = num(it); if (u !== undefined && !ctx.hasField(u)) push('field', 'error', 'dangling-ref', `引用了不存在的驻场技能 ${u}`); } + } + // revive + const rv = p['revive']; + if (rv && rv.kind === 'obj') { + const su = num(rv.props['s_uuid']); if (su !== undefined && !ctx.hasSkill(su)) push('revive', 'error', 'dangling-ref', `引用了不存在的技能 ${su}`); + } + // call_hero(技能表里的 ref 也用 hasHero) + } + if (tableId === 'skill') { + const ch = num(p['call_hero']); if (ch !== undefined && !ctx.hasHero(ch)) push('call_hero', 'error', 'dangling-ref', `引用了不存在的英雄 ${ch}`); + } + + // HeroList 一致性(仅 hero 表) + if (tableId === 'hero') { + const u = num(p['uuid']); const fac = p['fac']; + const isHero = fac && fac.kind === 'enumRef' && fac.member === 'HERO'; + if (isHero && u !== undefined && !ctx.heroListKeys.has(String(u))) { + push('uuid', 'error', 'herolist-inconsistent', `英雄 ${u} 不在 HeroList 中`); + } + } + } + return issues; +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +```bash +npx tsx --test __tests__/validation.test.ts +``` +Expected: PASS(8 个用例) + +- [ ] **Step 5: 提交** + +```bash +git add extensions/pixelhero-config-editor/src/shared/validation extensions/pixelhero-config-editor/__tests__/validation.test.ts +git commit -m "feat(config-editor): add validation rules (dup/required/enum/ref/overrides/herolist) with tests" +``` + +--- + +## Task 10: buildSkillDesc 的 JS 移植(描述预览) + +**Files:** +- Create: `extensions/pixelhero-config-editor/src/shared/desc/buildSkillDesc.ts` +- Test: `extensions/pixelhero-config-editor/__tests__/buildSkillDesc.test.ts` + +移植自 `assets/script/game/common/config/HeroSkillDesc.ts`,输入 `hero RecordValue`(含已合并的触发槽)+ 一个查 `SkillSet`/`FieldSkillSet` 的函数,输出多行描述。Plan A 只需纯函数(UI 预览在 Plan B 接线)。 + +- [ ] **Step 1: 写失败测试** + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildSkillDesc } from '../src/shared/desc/buildSkillDesc'; +import { RecordValue } from '../src/io/recordValue'; + +const skillSet: Record = { + 6301: { kind: 'obj', props: { name: { kind: 'str', value: '护盾' }, kind: { kind: 'enumRef', qualifier: 'SkillKind', member: 'Shield' }, ap: { kind: 'num', value: 4 } } }, +}; +const fieldSet: Record = { + 7015: { kind: 'obj', props: { name: { kind: 'str', value: '死亡强化' }, info: { kind: 'str', value: '死亡触发技能次数+1' } } }, +}; +const hero: RecordValue = { kind: 'obj', props: { + atked: { kind: 'arr', items: [{ kind: 'obj', props: { s_uuid: { kind: 'num', value: 6301 }, t_num: { kind: 'num', value: 3 }, overrides: { kind: 'obj', props: { ap: { kind: 'num', value: 4 } } } } }] }, + field: { kind: 'arr', items: [{ kind: 'num', value: 7015 }] }, +} }; + +test('renders atked trigger with shield effect', () => { + const out = buildSkillDesc(hero, skillSet, fieldSet); + assert.match(out, /受击3次:护盾/); + assert.match(out, /护盾4次/); +}); +test('renders field aura', () => { + const out = buildSkillDesc(hero, skillSet, fieldSet); + assert.match(out, /场上存活:死亡强化 死亡触发技能次数\+1/); +}); +``` + +- [ ] **Step 2: 运行测试,确认失败** + +```bash +npx tsx --test __tests__/buildSkillDesc.test.ts +``` +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 写 buildSkillDesc.ts**(语义对齐 HeroSkillDesc.ts;触发模板与 buildEffectDesc 等价) + +```ts +import { RecordValue } from '../../io/recordValue'; + +const TRIGGER_KEYS = ['call', 'dead', 'fstart', 'fend', 'atking', 'atked'] as const; +const TRIGGER_DESC: Record = { + call: '召唤时', dead: '死亡时', fstart: '战斗开始时', fend: '战斗结束时', + field: '场上存活', atking: '攻击n次', atked: '受击n次', revive: '复活时', +}; + +function num(v: RecordValue | undefined): number | undefined { return v && v.kind === 'num' ? v.value : undefined; } +function str(v: RecordValue | undefined): string | undefined { return v && v.kind === 'str' ? v.value : undefined; } +function member(v: RecordValue | undefined): string | undefined { return v && v.kind === 'enumRef' ? v.member : undefined; } + +function buildEffect(merged: RecordValue): string { + if (merged.kind !== 'obj') return ''; + const kind = member(merged.props['kind']); + const ap = num(merged.props['ap']) ?? 0; + const parts: string[] = []; + if (kind === 'Heal') parts.push(`治疗伙伴${ap}`); + else if (kind === 'Shield') parts.push(`护盾${ap}次`); + else if (kind === 'Gold') parts.push(`金币+${num(merged.props['gold']) ?? 0}`); + else if (kind === 'Support') parts.push(String(str(merged.props['info']) ?? '')); + else { // Damage / undefined + parts.push(`伤害${ap}%`); + if ((num(merged.props['hit_count']) ?? 1) > 1) parts.push(`${num(merged.props['hit_count'])}段`); + if (num(merged.props['crt'])) parts.push(`暴击+${num(merged.props['crt'])}%`); + if (num(merged.props['stun'])) parts.push(`击晕+${num(merged.props['stun'])}%`); + } + return parts.join(' '); +} + +/** 合并 overrides 到 base(浅合并对象 props) */ +function merge(base: RecordValue, overrides: RecordValue | undefined): RecordValue { + if (!overrides || overrides.kind !== 'obj' || base.kind !== 'obj') return base; + return { kind: 'obj', props: { ...base.props, ...overrides.props } }; +} + +export function buildSkillDesc(hero: RecordValue, skillSet: Record, fieldSet: Record): string { + if (hero.kind !== 'obj') return ''; + const lines: string[] = []; + for (const key of TRIGGER_KEYS) { + const arr = hero.props[key]; + if (!arr || arr.kind !== 'arr') continue; + const tpl = TRIGGER_DESC[key] ?? key; + for (const it of arr.items) { + if (it.kind !== 'obj') continue; + const su = num(it.props['s_uuid']); if (su === undefined) continue; + const base = skillSet[su]; if (!base) continue; + const merged = merge(base, it.props['overrides']); + const trigger = tpl.includes('n') ? tpl.replace('n', String(num(it.props['t_num']) ?? '')) : tpl; + lines.push(`${trigger}:${str(base.props['name'])} ${buildEffect(merged)}`); + } + } + const fl = hero.props['field']; + if (fl && fl.kind === 'arr') { + for (const it of fl.items) { const u = num(it); if (u === undefined) continue; const fs = fieldSet[u]; if (fs) lines.push(`${TRIGGER_DESC.field}:${str(fs.props['name'])} ${str(fs.props['info'])}`); } + } + const rv = hero.props['revive']; + if (rv && rv.kind === 'obj') { + const su = num(rv.props['s_uuid']); const base = su !== undefined ? skillSet[su] : undefined; + if (base) lines.push(`${TRIGGER_DESC.revive} : ${str(base.props['name'])} ${buildEffect(merge(base, undefined))}`); + } + return lines.join('\n'); +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +```bash +npx tsx --test __tests__/buildSkillDesc.test.ts +``` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add extensions/pixelhero-config-editor/src/shared/desc extensions/pixelhero-config-editor/__tests__/buildSkillDesc.test.ts +git commit -m "feat(config-editor): port buildSkillDesc to JS for panel preview" +``` + +--- + +## Task 11: 主进程 store(内存真理源 + 消息实现) + +**Files:** +- Create: `extensions/pixelhero-config-editor/src/main/store.ts` + +主进程持有三张表的 `TsConfigFile`(hero 用 heroSet.ts 的 `HeroInfo`;skill/field 共用 SkillSet.ts,分别 `SkillSet`/`FieldSkillSet` 两个实例指向同一文件)。所有读写消息在此实现;save 成功后触发 asset-db refresh。 + +- [ ] **Step 1: 写 store.ts** + +```ts +import { join } from 'node:path'; +import { TsConfigFile } from '../io/TsConfigFile'; +import { allSchemas } from '../shared/schema/registry'; +import { ENUMS } from '../shared/schema/enums'; +import { validate, ValidationContext } from '../shared/validation'; +import { buildSkillDesc } from '../shared/desc/buildSkillDesc'; +import { RecordValue } from '../io/recordValue'; +import { TableId } from '../shared/schema/types'; + +/** 游戏配置目录(相对项目根)。主进程用 __dirname 解析到项目根的 assets 配置目录。 */ +const CONFIG_DIR = join(Editor.Project.path, 'assets/script/game/common/config'); + +interface Entry { file: TsConfigFile; } +const tables: Partial> = {}; +// skill 与 field 共享 SkillSet.ts,但用不同 exportName 的 TsConfigFile 实例 +const fileCache: Record = {}; + +function getTable(id: TableId): Entry { + if (tables[id]) return tables[id]!; + const schema = allSchemas().find(s => s.id === id)!; + const cacheKey = `${schema.sourceFile}:${schema.exportName}`; + let file = fileCache[cacheKey]; + if (!file) { file = new TsConfigFile(join(CONFIG_DIR, schema.sourceFile), schema.exportName); file.load(); fileCache[cacheKey] = file; } + const entry = { file }; + tables[id] = entry; + return entry; +} + +function recordsOf(id: TableId): Map { + const { file } = getTable(id); + const m = new Map(); + for (const k of file.getKeys()) { const v = file.read(k); if (v) m.set(k, v); } + return m; +} + +function buildContext(): ValidationContext { + const hero = recordsOf('hero'); + const skill = recordsOf('skill'); + const field = recordsOf('field'); + const heroKeys = Array.from(hero.keys()).map(Number); + const skillKeys = Array.from(skill.keys()).map(Number); + const fieldKeys = Array.from(field.keys()).map(Number); + // HeroList 读取(hero 表的 listExportName) + const heroList = new TsConfigFile(join(CONFIG_DIR, 'heroSet.ts'), 'HeroList'); + heroList.load(); + const listRaw = heroList.getText(); + const heroListKeys = new Set( + (listRaw.match(/HeroList[\s\S]*?=\s*\[([^\]]*)\]/)?.[1].match(/-?\d+/g) ?? []).map(String) + ); + return { + hasHero: (u) => heroKeys.includes(u), + hasSkill: (u) => skillKeys.includes(u), + hasField: (u) => fieldKeys.includes(u), + heroListKeys, + }; +} + +export const store = { + queryEnums: () => ENUMS, + querySchema: (id?: TableId) => id ? allSchemas().find(s => s.id === id) : allSchemas(), + queryKeys: (id: TableId) => getTable(id).file.getKeys(), + queryRecord: (id: TableId, key: string) => getTable(id).file.read(key), + queryPreviewDesc: (hero: RecordValue) => { + const skill = recordsOf('skill'); const field = recordsOf('field'); + const skillSet: Record = {}; + for (const [k, v] of skill) skillSet[Number(k)] = v; + const fieldSet: Record = {}; + for (const [k, v] of field) fieldSet[Number(k)] = v; + return buildSkillDesc(hero, skillSet, fieldSet); + }, + validate: (id: TableId) => validate(id, recordsOf(id), buildContext()), + saveRecord: (id: TableId, key: string, value: RecordValue) => { + // 先在副本上试写并校验 + const { file } = getTable(id); + file.patch(key, value); + const issues = validate(id, recordsOf(id), buildContext()).filter(i => i.key === key && i.severity === 'error'); + if (issues.length) { file.load(); return { ok: false as const, issues }; } // 回滚内存 + const r = file.save(); + if (!r.ok) { file.load(); return { ok: false as const, issues: [] }; } + void Editor.Message.request('asset-db', 'refresh-asset', `db://assets/script/game/common/config/${allSchemas().find(s => s.id === id)!.sourceFile}`); + return { ok: true as const, issues: [] }; + }, + revertRecord: (id: TableId, key: string) => { getTable(id).file.load(); return getTable(id).file.read(key); }, + reloadAll: () => { for (const k of Object.keys(fileCache)) fileCache[k].load(); }, +}; +``` + +> 说明:`saveRecord` 采用"先 patch 到内存 → 校验该 key 的 error → 有则 load 回滚 → 否则 save"。这保证保存被 error 阻断时不落盘。Plan B 再加 add/delete + HeroList 写同步。 + +- [ ] **Step 2: 提交** + +```bash +git add extensions/pixelhero-config-editor/src/main/store.ts +git commit -m "feat(config-editor): main-process store (in-memory truth + message impls + asset-db refresh)" +``` + +--- + +## Task 12: 扩展入口 + 最小面板(端到端 IPC 证明) + +**Files:** +- Create: `extensions/pixelhero-config-editor/src/main/index.ts` +- Create: `extensions/pixelhero-config-editor/src/panels/default/index.ts` +- Create: `extensions/pixelhero-config-editor/src/panels/default/app.ts` +- Create: `extensions/pixelhero-config-editor/static/template/default/index.html` +- Create: `extensions/pixelhero-config-editor/static/style/default/index.css` + +- [ ] **Step 1: 写入口 `src/main/index.ts`**(注册消息→store) + +> **Cocos 3.x 消息回复机制(已核实):** 主进程消息处理函数的**返回值**即 `Editor.Message.request(...)` 的 Promise resolve 值(3.x 起,无需 `event.reply`;旧式 `event.reply` 仍兼容但非首选)。异步处理函数可直接返回 Promise。handler 签名为 `(event, ...args)`,此处不使用 event。**验证点:** 若面板收到 `undefined` 响应,先回到 https://docs.cocos.com/creator/3.8/manual/en/editor/extension/messages.html 核对,必要时改为 `event.reply(result)`。 + +```ts +import { store } from './store'; + +module.exports = { + onLoad() { store.reloadAll(); }, + + 'open-panel'() { Editor.Panel.open('pixelhero-config-editor'); }, + + 'query-schema'(_event: unknown, id?: string) { return store.querySchema(id as any); }, + 'query-enums'() { return store.queryEnums(); }, + 'query-keys'(_event: unknown, id: string) { return store.queryKeys(id as any); }, + 'query-record'(_event: unknown, id: string, key: string) { return store.queryRecord(id as any, key); }, + 'query-preview-desc'(_event: unknown, hero: any) { return store.queryPreviewDesc(hero); }, + 'validate'(_event: unknown, id: string) { return store.validate(id as any); }, + 'save-record'(_event: unknown, id: string, key: string, value: any) { return store.saveRecord(id as any, key, value); }, + 'revert-record'(_event: unknown, id: string, key: string) { return store.revertRecord(id as any, key); }, +}; +``` + +- [ ] **Step 2: 写面板宿主 `static/template/default/index.html`** + +```html +
+``` + +- [ ] **Step 3: 写面板样式 `static/style/default/index.css`** + +```css +#app { color: var(--color-font-normal); font-size: 12px; } +.row { margin: 6px 0; } +button { margin-right: 8px; } +ul { list-style: none; padding: 0; max-height: 360px; overflow:auto; } +li { padding: 3px 6px; cursor: pointer; } +li:hover { background: var(--color-hover-bg); } +.err { color: var(--color-warn); } +``` + +- [ ] **Step 4: 写最小 Vue app `src/panels/default/app.ts`** + +```ts +import { createApp, defineComponent, reactive } from 'vue'; + +export const App = defineComponent({ + setup() { + const state = reactive({ table: 'hero' as string, keys: [] as string[], picked: '' as string, detail: '' as string }); + async function load() { + const keys = await Editor.Message.request('pixelhero-config-editor', 'query-keys', state.table); + state.keys = keys || []; + state.picked = ''; state.detail = ''; + } + async function pick(k: string) { + state.picked = k; + const v = await Editor.Message.request('pixelhero-config-editor', 'query-record', state.table, k); + state.detail = JSON.stringify(v, null, 2); + } + load(); + return { state, load, pick }; + }, + template: ` +
+
+ + + 共 {{ state.keys.length }} 条(端到端 IPC 已打通) +
+
    +
  • {{ k }}
  • +
+
{{ state.detail }}
+
+ `, +}); + +export function mount(el: HTMLElement) { createApp(App).mount(el); } +``` + +- [ ] **Step 5: 写面板入口 `src/panels/default/index.ts`** + +```ts +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { mount } from './app'; + +const template = readFileSync(join(__dirname, '../../../static/template/default/index.html'), 'utf-8'); +const style = readFileSync(join(__dirname, '../../../static/style/default/index.css'), 'utf-8'); + +module.exports = Editor.Panel.define({ + template, + style, + $: { app: '#app' }, + ready() { mount(this.$.app); }, + close() { /* Vue 卸载随面板进程退出自动清理 */ }, +}); +``` + +- [ ] **Step 6: 打包** + +```bash +cd extensions/pixelhero-config-editor && npm run build +``` +Expected: 生成 `dist/main.js` 与 `dist/panels/default.js`,无错误。 + +- [ ] **Step 7: 提交** + +```bash +git add extensions/pixelhero-config-editor/src/main/index.ts extensions/pixelhero-config-editor/src/panels extensions/pixelhero-config-editor/static +git commit -m "feat(config-editor): extension entry + minimal Vue panel proving end-to-end IPC" +``` + +--- + +## Task 13: 全套测试 + 手动集成验证 + +- [ ] **Step 1: 跑全部单元测试** + +```bash +cd extensions/pixelhero-config-editor && npx tsx --test __tests__/*.test.ts +``` +Expected: 全部 PASS(serializer/parser/tsConfigFile/validation/buildSkillDesc)。记录通过数到 `production/qa/evidence/`(见 Step 3)。 + +- [ ] **Step 2: 打包扩展** + +```bash +npm run build +``` +Expected: `dist/main.js`、`dist/panels/default.js` 生成成功。 + +- [ ] **Step 3: 写手动验证证据 `production/qa/evidence/2026-06-20-config-editor-plan-a.md`** + +```markdown +# 配置编辑器 Plan A — 验证证据 + +## 自动化测试(BLOCKING) +- 命令:`npx tsx --test __tests__/*.test.ts` +- 结果:[填写通过用例数] / 全部 PASS +- 覆盖:序列化、解析(speed/enumRef/raw)、TsConfigFile 往返(load/patch/add/delete/save + .bak + 语法校验)、校验规则、buildSkillDesc 移植。 + +## 编辑器内集成(ADVISORY) +1. Cocos Creator → 扩展管理器 → 重载 `pixelhero-config-editor`。 +2. 菜单"面板 → 英雄技能配置"打开面板。 +3. 截图:面板可见,下拉切换 hero/skill/field,列表显示真实 uuid(hero 表应含 5011/5012/.../6106 等),点选任一条右侧显示结构化 JSON(含 `cd` 为 `{kind:'speed',level:'Slow3'}`、`fac` 为 `{kind:'enumRef',...}`)。 +4. 结论:端到端 IPC 打通,IO 正确解析真实 `heroSet.ts`/`SkillSet.ts`。 +``` + +- [ ] **Step 4: 提交证据** + +```bash +cd ../.. +git add production/qa/evidence/2026-06-20-config-editor-plan-a.md +git commit -m "test(config-editor): record Plan A verification evidence" +``` + +--- + +## Plan A 完成标准(Definition of Done) + +1. 扩展可被 Cocos 3.8.6 加载,菜单可打开面板。 +2. IO 层对真实 `heroSet.ts`/`SkillSet.ts` 正确解析(含 speed 表达式与枚举引用)。 +3. patch 一条英雄保存后:磁盘文件更新且为合法 TS;其他条目与符号表达式原样保留;`.bak` 已生成;asset-db 已刷新。 +4. 校验层对各类非法数据正确报错(error 级阻断保存)。 +5. 全部单元测试 PASS(BLOCKING)。 +6. 未改动 `assets/script/game/**` 任何游戏运行时代码。 + +## Plan B 预告(下一计划) + +完整 UI:master-detail 布局、schema 驱动的字段表单(含 enum/ref/speedExpr 控件)、嵌套编辑器(TriggerSlots/SkillMap/FieldList/Revive/Override)、描述实时预览、校验面板、新建/复制/删除、`HeroList` 写同步、还原。Plan B 仅新增/改动 `src/panels/**` 与 store 的 add/delete 消息;逻辑层(Plan A)无需改动。 + +--- + +## 自审(Self-Review) + +**1. Spec 覆盖:** Plan A 覆盖 spec §4(五层架构中的 IO/校验/schema/主进程)、§5.1–5.4、§7(消息,除 add/delete/record-changed 列入 Plan B)、§11(测试)。§5.5 完整 UI 与 §14.5–14.6(新建/删除/HeroList)显式归入 Plan B —— 已在"范围"声明。无遗漏。 + +**2. 占位符扫描:** 各步均含完整代码或确切命令;无 TBD/TODO/"适当处理"等。 + +**3. 类型一致性:** `RecordValue` 各 kind(num/str/bool/enumRef/speed/obj/arr/raw)在 parser/serializer/validation/buildSkillDesc/store 中使用一致;`TsConfigFile` 方法签名(load/getKeys/read/patch/add/delete/save/getText/isDirty)跨任务一致;`TableId = 'hero'|'skill'|'field'`、`FieldSchema`、`ENUMS`、`validate`/`ValidationContext`/`Issue` 在任务间命名一致。`serializeEntry` 首行无缩进的约定(Task 4 测试 + Task 8 实现一致)。 + +**4. 已知简化(相对 spec):** v1 不输出 `info` 行注释(避免 patch 注释重复,已声明);add/delete 记录与 `HeroList` 写同步归 Plan B;`record-changed` 广播归 Plan B(Plan A 单面板无需)。 +