dsh-muv-table 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,95 @@
1
- # Changelog
1
+ # Changelog
2
+
3
+ ## v0.2.8 (2026-09-20)
4
+
5
+ ### 🐛 修复:Zod「变量结构」整类读不到(覆盖面最大的一个)
6
+
7
+ parseMuvCard() 取 Zod schema 时用的是**精确等值** `s.name === 'zod'`,而真机上的卡
8
+ 几乎不这么命名(「变量结构」「星辉zod」…)——**一个都对不上**,zodSource 恒为空。
9
+ Zod(`z.object({...})` / `registerMvuSchema`)是 MUV 最主流的 schema 载体,
10
+ 读不到等于整张卡的变量表建不出来,而且是静默失败。
11
+
12
+ 现在按两条线索找:**名字含 zod**,或**内容像 Zod schema**(`z.object(` /
13
+ `registerMvuSchema` / `prefault(` 等)。
14
+ 实测:`_足控天堂2` 0 → **148106** 字符;`异世界农场` 0 → **953** 字符。
15
+
16
+ ### 🐛 修复:面板把 `@deepseek-ai/dsh-persona` 当卡名显示
17
+
18
+ `extractMuvFromYml()` 的 `^\s*name:` 兜底会吃到 YAML 里的**服务声明**
19
+ (`name: '@deepseek-ai/dsh-persona'`)。隔壁 `extractCardNameFromYml()` 有
20
+ `@`/`/` 守卫,这里漏了。面板会把它显示成卡名并写进 localStorage 粘住。已补上同一守卫。
21
+
22
+ ### 🐛 修复:`<VariableInsert>` 形态的卡建不出变量表
23
+
24
+ (由上一版引入的 `extractVariableInsert()` 修复,此处一并记录)
25
+ `_足控天堂2` 的变量树在 `first_mes` 的 `<VariableInsert>{JSON}</VariableInsert>` 里,
26
+ 卡中**没有 `<initvar>`**,而 PNG 卡主路径走的 `parseMuvCard()` 只扫
27
+ `alternate_greetings` 的 `<initvar>` → `schemas: 0`。现在 `parseMuvCard()` 在
28
+ `initvarBlocks` 为空时回退到 `<VariableInsert>`,`<initvar>` 卡行为完全不变。
29
+ 实测 `schemas` 0 → **8**(世界信息/主角信息/公司/道具系统/剧情事件/因特网/剧情选项/主播档案)。
30
+
31
+ > 回归:`node test-png-card.mjs` 12 → **28** 项;
32
+ > 复现脚本 `repro-variableinsert.mjs` 留在仓库里可直接跑(进程内挂真实路由,不需要重启 DSH)。
33
+
34
+ ## Unreleased
35
+
36
+ ### 🐛 修复:变量面板读不到「足控天堂2」这种卡的变量表
37
+
38
+ **现象**(已复现):`GET /api/muv-table/tavern-card?presetId=…` 对这张卡返回
39
+
40
+ ```
41
+ cardName : _足控天堂2
42
+ cardSource : library
43
+ fileName : _足控天堂2.png
44
+ schemas : 0 ← 面板靠 schemas 建表,为 0 就什么都显示不出来
45
+ initvarData : {}
46
+ initvarBlocks: 0
47
+ ```
48
+
49
+ `lib/client.js` 里 `schemas.length === 0` 直接渲染空态,所以整张变量表是空的。
50
+
51
+ **根因**:这张卡把变量定义写在一句话的
52
+ `<VariableInsert>{ …JSON… }</VariableInsert>` 里(在 `first_mes` = 【主页】),
53
+ **卡里根本没有 `<initvar>`**。而 PNG 卡主路径调用的 `parseMuvCard()`
54
+ 只扫 `alternate_greetings` 里的 `<initvar>`,扫不到就返回 0 个 schema。
55
+
56
+ 仓库里本来就有能处理 `<VariableInsert>` 的 `extractMuvFromCharactersJson()`,
57
+ 但它只在退路分支(`loadRawCardForPreset` 返回 null、退回 `characters.json`)里被调用。
58
+ 主路径从卡库匹配到 PNG 后直接 `return`,把它整个跳过了 —— 数据就在卡里,没人读。
59
+
60
+ **修法**:把 `<VariableInsert>` 支持下沉到 `lib/muv-parser.js` 的 `parseMuvCard()`,
61
+ 让主路径和退路共用同一份解析(`dsh-muv-engine` 也从这里 import `parseMuvCard`,一并受益):
62
+
63
+ - 新增导出 `extractVariableInsert(data)`:按 `first_mes` → `description` →
64
+ `scenario` → `alternate_greetings` 顺序找 `<VariableInsert>`,取第一个能
65
+ `JSON.parse` 成对象的块;这一块不是 JSON 就继续往后找,全程不抛异常。
66
+ - **只有一个 `<initvar>` 都没找到时才走这条兜底**,所以原先正常的 `<initvar>` 卡
67
+ 行为完全不变(回归用例 [6] 守住这一点)。
68
+ - 顺带补上:`<initvar>` 也可能写在 `first_mes` / `description` 里,原先只看
69
+ `alternate_greetings` 同样会漏。该分支也只在 `alternate_greetings` 没找到时才生效。
70
+ - 新增导出 `stripPlaceholderEntries(data)`:去掉 `主播档案.$template`。它是卡自己
71
+ 留下的「一个主播长什么样」的占位模板,不是真实主播,当角色渲染会在表里多出一行
72
+ 假主播。(这条规则原先只写在 `extractMuvFromCharactersJson()` 里,现在两处共用。)
73
+
74
+ **测试**:`test-png-card.mjs` 12 项 → **28 项**,新增 [5][6][7] 三节:
75
+
76
+ - **[5]** 合成一张 `<VariableInsert>` PNG 卡(不依赖本机卡库)验证:能建表、顶层键
77
+ 齐全、`$template` 不成行、数值仍是 `number` 不是 `string`。
78
+ - **[6]** 反向保护:`<initvar>` 卡不会被 `<VariableInsert>` 抢走、`first_mes` 里的
79
+ `<initvar>` 也认、坏 JSON 不抛异常、没有变量块的卡照样返回有效结构。
80
+ - **[7]** 真实卡 `_足控天堂2.png`:`schemas` 非空、顶层键含
81
+ `世界信息/主角信息/公司/道具系统/剧情事件/因特网/剧情选项/主播档案`、
82
+ `主播档案` 下是 `超天酱`(不是 `$template`)、`数值.压力值` 可编辑。
83
+
84
+ 新增 `repro-variableinsert.mjs`:用 `apply()` 挂载真实路由、在进程内请求
85
+ `/api/muv-table/tavern-card`(等价于浏览器那条路,但不需要重启 DSH)。
86
+ 改前 5 项不通过(复现 `schemas: 0`),改后全通过。这个脚本留在仓库里,
87
+ 以后再有人动 `parseMuvCard` 可以拿它当哨兵。
88
+
89
+ > 已知相邻问题(本次**未**修):`异世界农场.png` 的初始变量不是放在卡文本里,
90
+ > 而是放在世界书条目 `[initvar]变量初始化勿开` 的 `content` 里,因此它的
91
+ > `schemas` 依然是 0。那是另一套投递方式(世界书),不是本次的
92
+ > `<VariableInsert>` 问题,需要单独决定是否要让面板读世界书。
2
93
 
3
94
  ## v0.2.7 (2026-09-20)
4
95
 
package/README.md CHANGED
@@ -42,7 +42,7 @@ MUV 变量表格编辑器 —— 酒馆伴生插件。
42
42
  **这是变量数据唯一还存在的地方**。现在会从这里提取,支持
43
43
  `<VariableInsert>{…JSON…}` 与 MUV 原生的 `<UpdateVariable><initvar>…</initvar>` 两种编码。
44
44
 
45
- > 回归测试:`node test-png-card.mjs`(12 项,含真实卡库验证)
45
+ > 回归测试:`node test-png-card.mjs`(28 项,含真实卡库验证)
46
46
 
47
47
 
48
48
  ## 是什么
@@ -94,7 +94,8 @@ dsh plugin --profile web add dsh-muv-table
94
94
  | 类型 | 说明 |
95
95
  |------|------|
96
96
  | 有 `<initvar>` 默认数据 | 直接显示完整变量表 |
97
- | 无 `<initvar>`(如瑟瑟提瓦特) | 显示空表,LLM 输出 UpdateVariable 后自动填充 |
97
+ | 有 `<VariableInsert>{…JSON…}`(如足控天堂2) | 直接显示完整变量表(`主播档案.$template` 不算条目) |
98
+ | 两者都没有(如瑟瑟提瓦特) | 显示空表,LLM 输出 UpdateVariable 后自动填充 |
98
99
 
99
100
  ## 协议
100
101
 
package/lib/index.js CHANGED
@@ -419,7 +419,11 @@ function extractMuvFromYml(yml) {
419
419
  if (!yml) return null
420
420
  try {
421
421
  const nameMatch = yml.match(/角色名[::]\s*([^\s#\n]+)/) || yml.match(/^\s*name:\s*(.+)$/m)
422
- const name = nameMatch ? nameMatch[1].trim().replace(/^['"]|['"]$/g, '') : 'MUV Card'
422
+ let name = nameMatch ? nameMatch[1].trim().replace(/^['"]|['"]$/g, '') : 'MUV Card'
423
+ // `^\s*name:` 兜底会吃到 YAML 里的**服务声明**(`name: '@deepseek-ai/dsh-persona'`),
424
+ // 于是面板把插件包名当卡名显示出来。隔壁 extractCardNameFromYml() 有同样的守卫,
425
+ // 这里补上——否则面板会显示 `@deepseek-ai/dsh-persona` 并把它写进 localStorage 粘住。
426
+ if (/^@/.test(name) || name.includes('/')) name = 'MUV Card'
423
427
  // Try to find an <initvar> block in the persona
424
428
  const m = yml.match(/<initvar>([\s\S]*?)<\/initvar>/i)
425
429
  if (m) {
package/lib/muv-parser.js CHANGED
@@ -3,22 +3,97 @@
3
3
 
4
4
  import { parseInitvar } from './initvar-parser.js'
5
5
 
6
+ // Where a card keeps its variable block.
7
+ //
8
+ // Two shapes are in the wild:
9
+ //
10
+ // 1) `<initvar>…</initvar>` — MUV-native, YAML-ish, handled by initvar-parser.
11
+ // 2) `<VariableInsert>{ …plain JSON… }</VariableInsert>` — the community
12
+ // form. These cards carry *no* `<initvar>` anywhere, so a parser that only
13
+ // looked for shape 1 returned `schemas: 0` and the variable panel came up
14
+ // empty even though the whole tree was sitting in the opening greeting.
15
+ //
16
+ // Shape 2 is normally in the greeting that opens the chat, but a card may just
17
+ // as well park it in description/scenario, so all three are scanned.
18
+ const VARIABLE_TEXT_FIELDS = ['first_mes', 'description', 'scenario']
19
+
20
+ /**
21
+ * Drop a collection's `$template` placeholder.
22
+ *
23
+ * `主播档案.$template` is the card's own sketch of "what an主播 looks like" —
24
+ * not an actual character. Rendering it put a bogus 主播 row in the panel.
25
+ * @param {object} data
26
+ * @returns {object} a shallow copy with `$template` removed from 主播档案
27
+ */
28
+ export function stripPlaceholderEntries(data) {
29
+ if (!data || typeof data !== 'object') return data
30
+ const clean = { ...data }
31
+ const anchors = clean['主播档案']
32
+ if (anchors && typeof anchors === 'object' && !Array.isArray(anchors) && '$template' in anchors) {
33
+ const inner = { ...anchors }
34
+ delete inner.$template
35
+ clean['主播档案'] = inner
36
+ }
37
+ return clean
38
+ }
39
+
40
+ /**
41
+ * Find a `<VariableInsert>{…JSON…}</VariableInsert>` block in a card and return
42
+ * it as usable variable data.
43
+ * @param {object} data - the card's `data` object
44
+ * @returns {{ data: object, raw: string }|null}
45
+ */
46
+ export function extractVariableInsert(data) {
47
+ if (!data || typeof data !== 'object') return null
48
+
49
+ const candidates = []
50
+ for (const field of VARIABLE_TEXT_FIELDS) {
51
+ if (typeof data[field] === 'string') candidates.push(data[field])
52
+ }
53
+ for (const greeting of data.alternate_greetings || []) {
54
+ if (typeof greeting === 'string') candidates.push(greeting)
55
+ }
56
+
57
+ for (const text of candidates) {
58
+ const match = text.match(/<VariableInsert>([\s\S]*?)<\/VariableInsert>/i)
59
+ if (!match) continue
60
+ const raw = match[1].trim()
61
+ try {
62
+ const parsed = JSON.parse(raw)
63
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
64
+ return { data: stripPlaceholderEntries(parsed), raw }
65
+ }
66
+ } catch (_) {
67
+ // Not JSON — keep looking; another block might be the real one.
68
+ }
69
+ }
70
+ return null
71
+ }
72
+
6
73
  /**
7
74
  * Parse a MUV character card JSON and extract schema + variable data.
8
75
  * @param {object} cardJson - The full character card JSON
9
76
  * @returns {{ name: string, schemas: object[], initvarData: object, zodSource: string }}
10
77
  */
11
78
  export function parseMuvCard(cardJson) {
12
- const name = cardJson?.data?.name || cardJson?.name || 'Unnamed'
13
- const extensions = cardJson?.data?.extensions
14
- const tavernHelper = extensions?.tavern_helper
79
+ const data = cardJson?.data || {}
80
+ const name = data.name || cardJson?.name || 'Unnamed'
81
+ const tavernHelper = data.extensions?.tavern_helper
15
82
 
16
83
  // Extract Zod schema source (for reference)
17
- const zodScript = tavernHelper?.scripts?.find(s => s.name === 'zod')
84
+ //
85
+ // 原先这里是 `s.name === 'zod'` 的精确等值匹配,而真机上的卡几乎不这么命名:
86
+ // 「变量结构」「星辉zod」…… 一个都对不上,于是 zodSource 恒为空。而 Zod
87
+ // (`z.object({...})` / `registerMvuSchema`)其实是 MUV 最主流的 schema 载体,
88
+ // 读不到就等于整张卡的变量表建不出来。
89
+ // 现在按「名字含 zod」或「内容像 Zod schema」两条线索找,兼容各种命名。
90
+ const scripts = tavernHelper?.scripts || []
91
+ const zodScript = scripts.find(s => /zod/i.test(String(s?.name || '')))
92
+ || scripts.find(s => /z\.object\s*\(|registerMvuSchema|Mvu\.|z\.string\s*\(|prefault\s*\(/.test(String(s?.content || '')))
18
93
  const zodSource = zodScript?.content || ''
19
94
 
20
95
  // Extract initvar blocks from alternate_greetings
21
- const greetings = cardJson?.data?.alternate_greetings || []
96
+ const greetings = data.alternate_greetings || []
22
97
  const initvarBlocks = []
23
98
 
24
99
  for (const greeting of greetings) {
@@ -32,8 +107,35 @@ export function parseMuvCard(cardJson) {
32
107
  }
33
108
  }
34
109
 
110
+ // An <initvar> may live in the opening greeting instead. Only consulted when
111
+ // alternate_greetings had none, so a card that already worked keeps reading
112
+ // from exactly the same place it used to.
113
+ if (initvarBlocks.length === 0) {
114
+ for (const text of [data.first_mes, data.description]) {
115
+ if (typeof text !== 'string') continue
116
+ const match = text.match(/<initvar>([\s\S]*?)<\/initvar>/i)
117
+ if (match) {
118
+ initvarBlocks.push({
119
+ raw: match[1].trim(),
120
+ parsed: parseInitvar(match[1])
121
+ })
122
+ break
123
+ }
124
+ }
125
+ }
126
+
35
127
  // Use the first initvar block as default data
36
- const initvarData = initvarBlocks.length > 0 ? initvarBlocks[0].parsed : {}
128
+ let initvarData = initvarBlocks.length > 0 ? initvarBlocks[0].parsed : {}
129
+ let initvarRaw = initvarBlocks.map(b => b.raw)
130
+
131
+ // No <initvar> anywhere → fall back to the community <VariableInsert> shape.
132
+ if (initvarRaw.length === 0) {
133
+ const inserted = extractVariableInsert(data)
134
+ if (inserted) {
135
+ initvarData = inserted.data
136
+ initvarRaw = [inserted.raw]
137
+ }
138
+ }
37
139
 
38
140
  // Infer schema from initvar data
39
141
  const schemas = inferSchemaFromData(initvarData)
@@ -42,7 +144,7 @@ export function parseMuvCard(cardJson) {
42
144
  name,
43
145
  schemas,
44
146
  initvarData,
45
- initvarBlocks: initvarBlocks.map(b => b.raw),
147
+ initvarBlocks: initvarRaw,
46
148
  zodSource
47
149
  }
48
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-muv-table",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "MUV 变量表格编辑器 —— 树形变量面板、宏测试浮窗(random/pick/roll)、骰子快捷栏、pick 缓存管理;支持直接读取 SillyTavern PNG 角色卡(含正则脚本、世界书、变量表)。dsh-muv-engine 的伴生插件",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/test-png-card.mjs CHANGED
@@ -7,7 +7,8 @@
7
7
  // keeps only {name, desc, first}, so if this breaks, a card dragged into
8
8
  // SillyTavern silently arrives with none of its rendering machinery.
9
9
  import { readPngCard, readPngChunks, extractCharaPayload, isPngCard } from './lib/png-card.js'
10
- import { readdirSync, existsSync } from 'node:fs'
10
+ import { parseMuvCard } from './lib/muv-parser.js'
11
+ import { readdirSync, existsSync, writeFileSync, rmSync } from 'node:fs'
11
12
  import path from 'node:path'
12
13
  import os from 'node:os'
13
14
 
@@ -112,5 +113,110 @@ if (realCards === 0) {
112
113
  check('其中带正则脚本的 (' + realWithRegex + ' 张)', realWithRegex > 0)
113
114
  }
114
115
 
116
+ // 5) A PNG card may declare its variables as
117
+ // `<VariableInsert>{…plain JSON…}</VariableInsert>` instead of `<initvar>`.
118
+ // Reading the PNG correctly is not enough — if the schema comes out empty
119
+ // the variable panel has nothing to build a table from, which is exactly how
120
+ // 「足控天堂2」 showed up as `schemas: 0`.
121
+ const TOP_LEVEL_KEYS = ['世界信息', '主角信息', '公司', '道具系统', '剧情事件', '因特网', '剧情选项', '主播档案']
122
+
123
+ function variableInsertCard() {
124
+ const tree = {
125
+ 世界信息: { 时间: { 日期: '2026年8月26日', 时间详情: '10:00' }, 游戏模式: '香脚模式' },
126
+ 主角信息: { 名称: '{{user}}', 年龄: '20岁' },
127
+ 公司: { 总现金: 40000, 濒死状态: false },
128
+ 道具系统: { $template: { 库存: 0 }, 安神片: { 库存: 2, 描述: '降低压力值10' } },
129
+ 剧情事件: { $template: { 事件描述: '' }, 事件描述: '先撑过本周现金关' },
130
+ 因特网: { 公司主播推特1: '超天酱:袜子穿久了有点潮……' },
131
+ 剧情选项: { 选项1: '', 选项2: '', 选项3: '' },
132
+ 主播档案: {
133
+ $template: { 直播时人格: '', 数值: { 压力值: 0 } },
134
+ 超天酱: { 直播时人格: '超天酱', 数值: { 压力值: 45 } },
135
+ },
136
+ }
137
+ return {
138
+ name: 'vi',
139
+ description: 'desc',
140
+ first_mes: '【主页】\n\n<VariableInsert>\n' + JSON.stringify(tree, null, 2) + '\n</VariableInsert>',
141
+ data: { name: 'vi', description: 'desc', first_mes: '【主页】\n\n<VariableInsert>\n' + JSON.stringify(tree, null, 2) + '\n</VariableInsert>' },
142
+ }
143
+ }
144
+
145
+ console.log('\n[5] <VariableInsert> 卡能建出变量表')
146
+ // readPngCard takes a *path*, so the synthetic card has to touch disk. It is
147
+ // written to a temp dir and removed again so the test stays self-contained.
148
+ function buildPngFile(cardObj) {
149
+ const file = path.join(os.tmpdir(), 'dsh-muv-table-' + process.pid + '-' + Math.random().toString(36).slice(2) + '.png')
150
+ writeFileSync(file, buildPng(cardObj))
151
+ return file
152
+ }
153
+ const viFile = buildPngFile(variableInsertCard())
154
+ let viParsed = { schemas: [], initvarData: {} }
155
+ try {
156
+ const viCard = readPngCard(viFile)
157
+ check('PNG 里的 VariableInsert 卡读得出来', !!viCard && viCard.data.name === 'vi')
158
+ viParsed = parseMuvCard(viCard)
159
+ } finally {
160
+ rmSync(viFile, { force: true })
161
+ }
162
+ check('schemas 非空', viParsed.schemas.length > 0, 'schemas=' + viParsed.schemas.length)
163
+ const viKeys = viParsed.schemas.map(s => s.name)
164
+ check('顶层键齐全', TOP_LEVEL_KEYS.every(k => viKeys.includes(k)),
165
+ '缺: ' + TOP_LEVEL_KEYS.filter(k => !viKeys.includes(k)).join(','))
166
+ check('initvarData 里有真实变量树', Object.keys(viParsed.initvarData || {}).length === TOP_LEVEL_KEYS.length)
167
+ check('主播档案.$template 不是条目(不渲染假主播)', !viKeys.includes('$template'))
168
+ const viAnchor = viParsed.schemas.find(s => s.name === '主播档案')
169
+ check('主播档案 下是真实主播而不是 $template',
170
+ !!viAnchor && (viAnchor.children || []).some(c => c.name === '超天酱')
171
+ && !(viAnchor.children || []).some(c => c.name === '$template'),
172
+ 'children=' + (viAnchor ? (viAnchor.children || []).map(c => c.name).join(',') : 'n/a'))
173
+ check('数值是 number 不是 string',
174
+ JSON.stringify(viParsed.schemas.find(s => s.name === '公司')?.children?.find(c => c.name === '总现金'))
175
+ .includes('"type":"number"'))
176
+
177
+ console.log('\n[6] <VariableInsert> 兜底不能踩到别的东西')
178
+ const nativeCard = {
179
+ data: {
180
+ name: 'native',
181
+ alternate_greetings: ['<initvar>\n名字: 原生\n年龄: 3\n</initvar>'],
182
+ first_mes: '<VariableInsert>{"不该被用":1}</VariableInsert>',
183
+ },
184
+ }
185
+ const nativeParsed = parseMuvCard(nativeCard)
186
+ check('有 <initvar> 时不走 VariableInsert', Object.keys(nativeParsed.initvarData).join(',') === '名字,年龄',
187
+ Object.keys(nativeParsed.initvarData).join(','))
188
+ const initvarInFirstMes = parseMuvCard({
189
+ data: { name: 'x', first_mes: '<initvar>\n名字: 开头问候里\n</initvar>' },
190
+ })
191
+ check('first_mes 里的 <initvar> 也认', Object.keys(initvarInFirstMes.initvarData).join(',') === '名字',
192
+ Object.keys(initvarInFirstMes.initvarData).join(','))
193
+ const badJson = parseMuvCard({
194
+ data: { name: 'y', first_mes: '<VariableInsert>不是 JSON {</VariableInsert>', description: '' },
195
+ })
196
+ check('VariableInsert 不是 JSON 时返回空表而不抛异常', badJson.schemas.length === 0 && badJson.initvarData != null)
197
+ const noVars = parseMuvCard({ data: { name: 'z', first_mes: '普通问候' } })
198
+ check('没有变量块的卡照样返回有效结构', noVars.schemas.length === 0 && typeof noVars.initvarData === 'object')
199
+
200
+ console.log('\n[7] 真实卡:_足控天堂2(存在才测)')
201
+ const target = dirs.map(d => path.join(d, '_足控天堂2.png')).find(p => existsSync(p))
202
+ if (!target) {
203
+ console.log(' SKIP 未找到 _足控天堂2.png')
204
+ } else {
205
+ const real = readPngCard(target)
206
+ const realParsed = parseMuvCard(real)
207
+ check('schemas 不为空', realParsed.schemas.length > 0, 'schemas=' + realParsed.schemas.length)
208
+ const keys = realParsed.schemas.map(s => s.name)
209
+ check('顶层键含 ' + TOP_LEVEL_KEYS.join('/'),
210
+ TOP_LEVEL_KEYS.every(k => keys.includes(k)),
211
+ '缺: ' + TOP_LEVEL_KEYS.filter(k => !keys.includes(k)).join(','))
212
+ check('initvarData 有真实变量树', Object.keys(realParsed.initvarData || {}).length === TOP_LEVEL_KEYS.length,
213
+ 'keys=' + Object.keys(realParsed.initvarData || {}).join(','))
214
+ const star = realParsed.schemas.find(s => s.name === '主播档案')
215
+ check('主播档案 下是 超天酱', !!star && (star.children || []).some(c => c.name === '超天酱'),
216
+ 'children=' + (star ? (star.children || []).map(c => c.name).join(',') : 'n/a'))
217
+ check('超天酱 的 数值.压力值 可编辑', !!star
218
+ && !!star.children.find(c => c.name === '超天酱')?.children?.find(c => c.name === '数值')?.children?.some(c => c.name === '压力值'))
219
+ }
220
+
115
221
  console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`)
116
222
  process.exit(fail ? 1 : 0)