dsh-muv-table 0.2.9 → 0.2.11

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.
@@ -0,0 +1,488 @@
1
+ // Regression: the MUV card/variable parser.
2
+ //
3
+ // Run: node test-muv-parser.mjs
4
+ //
5
+ // Covers the "silent data loss / robustness" round of fixes:
6
+ // ① `空值 + 下一行 - ` 列表被吞成空对象 → 内容既不在数据里也不在 schema 里
7
+ // ③ 变量块的候选来源只扫 4 个字段,世界书条目 / tavern_helper 脚本读不到
8
+ // ④ alternate_greetings(或 entries / scripts)是对象时 for...of 抛 TypeError → 端点 500
9
+ // ⑤ 扁平 V1 卡(没有 data 包装)能被 findJsonMatch 找到,却被 parseMuvCard 读成 0 字段
10
+ // ⑥ `键: ""` 空字符串被序列化成裸的 `键: `,再解析回来变成 `{}`(类型静默翻转)
11
+ // ⑦ 世界书里只有注释前缀 `[initvar]`、没有 `<initvar>` 标签的裸 YAML 条目读不到
12
+ // → 该卡 schemas 永远是 0(`异世界农场`);[11] 节连四道闸一起钉住
13
+ //
14
+ // 真实卡只在存在时才测(缺失 → SKIP),合成卡负责精确覆盖每条分支。
15
+
16
+ import { parseInitvar, serializeInitvar } from './lib/initvar-parser.js'
17
+ import { parseMuvCard, extractVariableInsert } from './lib/muv-parser.js'
18
+ import { applyEditsAndGenerate, generateMuvBlock } from './lib/block-generator.js'
19
+ import { readPngCard } from './lib/png-card.js'
20
+ import { findCard } from './test-cards.mjs'
21
+ import fs from 'node:fs'
22
+
23
+ let pass = 0, fail = 0
24
+ function check(name, cond, detail) {
25
+ if (cond) { pass++; console.log(' OK ' + name) }
26
+ else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
27
+ }
28
+
29
+ /** Find a dot-path in a schema tree; returns the field object or undefined. */
30
+ function fieldAt(schemas, path) {
31
+ const keys = path.split('.')
32
+ let list = schemas
33
+ let found
34
+ for (const key of keys) {
35
+ found = (list || []).find(f => f.name === key)
36
+ if (!found) return undefined
37
+ list = found.children
38
+ }
39
+ return found
40
+ }
41
+ function at(obj, path) {
42
+ return path.split('.').reduce((o, k) => (o === undefined || o === null ? undefined : o[k]), obj)
43
+ }
44
+ function leafPaths(schemas, prefix = '') {
45
+ const out = []
46
+ for (const f of schemas || []) {
47
+ const p = prefix ? prefix + '.' + f.name : f.name
48
+ if (f.type === 'object' && (f.children || []).length > 0) out.push(...leafPaths(f.children, p))
49
+ else out.push(p)
50
+ }
51
+ return out
52
+ }
53
+
54
+ console.log('=== MUV 解析器回归 ===\n')
55
+
56
+ // ─────────────────────────────────────────────────────────
57
+ console.log('[1] ① 列表值:`键: ` + 下一行 `- item` 必须是数组')
58
+ const listText = [
59
+ '人际交往:',
60
+ ' 结识道友录:',
61
+ ' 沈慕微:',
62
+ ' 关系标签: 师尊',
63
+ ' 最近互动记录: ',
64
+ ' - 因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。',
65
+ ' 江念:',
66
+ ' 最近互动记录:',
67
+ ' - (呼呼大睡中,听到动静也只是翻了个身)。',
68
+ '世界系统:',
69
+ ' 在场角色: ""',
70
+ ].join('\n')
71
+ const listParsed = parseInitvar(listText)
72
+ check('列表成为数组', Array.isArray(at(listParsed, '人际交往.结识道友录.沈慕微.最近互动记录')),
73
+ JSON.stringify(at(listParsed, '人际交往.结识道友录.沈慕微.最近互动记录')))
74
+ check('列表内容原样保留',
75
+ at(listParsed, '人际交往.结识道友录.沈慕微.最近互动记录')?.[0] === '因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。')
76
+ check('同缩进的列表也认(键与 `- ` 对齐)',
77
+ Array.isArray(at(listParsed, '人际交往.结识道友录.江念.最近互动记录'))
78
+ && at(listParsed, '人际交往.结识道友录.江念.最近互动记录')[0] === '(呼呼大睡中,听到动静也只是翻了个身)。')
79
+ check('列表后面的兄弟键仍然归到上一层', at(listParsed, '世界系统.在场角色') === '',
80
+ JSON.stringify(at(listParsed, '世界系统.在场角色')))
81
+ check('普通嵌套对象没被改成数组', !Array.isArray(at(listParsed, '人际交往.结识道友录.沈慕微')))
82
+
83
+ const multi = parseInitvar('甲:\n - 一\n - 二\n - 三\n乙: 1')
84
+ check('多条目列表', JSON.stringify(at(multi, '甲')) === '["一","二","三"]', JSON.stringify(at(multi, '甲')))
85
+ check('列表里的数字/布尔按标量解析', JSON.stringify(at(parseInitvar('甲:\n - 3\n - true\n - 文字'), '甲')) === '[3,true,"文字"]',
86
+ JSON.stringify(at(parseInitvar('甲:\n - 3\n - true\n - 文字'), '甲')))
87
+ check('孤立的 `- item`(上面没有待定键)不会污染根对象',
88
+ Object.keys(parseInitvar('- 野生条目\n名字: 甲')).join(',') === '名字',
89
+ Object.keys(parseInitvar('- 野生条目\n名字: 甲')).join(','))
90
+ check('列表写回仍是列表(往返)',
91
+ serializeInitvar(listParsed).includes(' - 因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。')
92
+ && Array.isArray(at(parseInitvar(serializeInitvar(listParsed)), '人际交往.结识道友录.沈慕微.最近互动记录')))
93
+ check('空列表往返为 [](不是 {})',
94
+ JSON.stringify(at(parseInitvar(serializeInitvar(parseInitvar('甲: []'))), '甲')) === '[]',
95
+ JSON.stringify(at(parseInitvar(serializeInitvar(parseInitvar('甲: []'))), '甲')))
96
+
97
+ // ─────────────────────────────────────────────────────────
98
+ console.log('\n[2] ⑥ 空字符串往返:`键: ""` 不能再变成 `{}`')
99
+ const emptyRound = parseInitvar(serializeInitvar(parseInitvar('在场角色: ""')))
100
+ check('`键: ""` 往返仍是空字符串', emptyRound['在场角色'] === '', JSON.stringify(emptyRound['在场角色']))
101
+ check('类型没变(不是 object)', typeof emptyRound['在场角色'] !== 'object', typeof emptyRound['在场角色'])
102
+
103
+ // ─────────────────────────────────────────────────────────
104
+ console.log('\n[3] ③ 变量块的候选来源:世界书条目 + tavern_helper 脚本')
105
+ const wbInitvarCard = {
106
+ data: {
107
+ name: 'wb-initvar',
108
+ first_mes: '普通问候',
109
+ character_book: {
110
+ entries: [
111
+ { comment: '无关设定', content: '这里没有变量块' },
112
+ { comment: '[initvar]变量初始化勿开', content: '<initvar>\n名字: 世界书\n年龄: 9\n</initvar>' },
113
+ ],
114
+ },
115
+ },
116
+ }
117
+ const wbInitvar = parseMuvCard(wbInitvarCard)
118
+ check('世界书条目里的 <initvar> 被读到', Object.keys(wbInitvar.initvarData).join(',') === '名字,年龄',
119
+ Object.keys(wbInitvar.initvarData).join(','))
120
+ check('世界书条目里的 <initvar> 建有 schema', wbInitvar.schemas.length === 2, 'schemas=' + wbInitvar.schemas.length)
121
+
122
+ const scriptTree = { 世界信息: { 时间: '10:00' }, 主播档案: { 超天酱: { 数值: { 压力值: 45 } } } }
123
+ const scriptCard = {
124
+ data: {
125
+ name: 'script-vi',
126
+ first_mes: '普通问候',
127
+ extensions: {
128
+ tavern_helper: {
129
+ scripts: [
130
+ { name: '注释脚本', content: '// 没有变量' },
131
+ { name: '变量初始化', content: 'const n = 1\n<VariableInsert>' + JSON.stringify(scriptTree) + '</VariableInsert>' },
132
+ ],
133
+ },
134
+ },
135
+ },
136
+ }
137
+ const scriptParsed = parseMuvCard(scriptCard)
138
+ check('脚本里的 <VariableInsert> 被读到',
139
+ Object.keys(scriptParsed.initvarData).join(',') === '世界信息,主播档案',
140
+ Object.keys(scriptParsed.initvarData).join(','))
141
+ check('脚本里的变量树建得出嵌套 schema',
142
+ !!fieldAt(scriptParsed.schemas, '主播档案.超天酱.数值.压力值'))
143
+
144
+ // 顺序:问候语里的真块永远优先于世界书/脚本(已经是好的卡不许变)
145
+ const precedence = parseMuvCard({
146
+ data: {
147
+ name: 'precedence',
148
+ first_mes: '<VariableInsert>' + JSON.stringify({ 真变量: 1 }) + '</VariableInsert>',
149
+ alternate_greetings: ['<initvar>\n名字: 问候语里的\n</initvar>'],
150
+ character_book: { entries: [{ comment: 'x', content: '<initvar>\n名字: 世界书里的\n</initvar>' }] },
151
+ extensions: { tavern_helper: { scripts: [{ name: 's', content: '<VariableInsert>{"脚本里的":1}</VariableInsert>' }] } },
152
+ },
153
+ })
154
+ check('<initvar> 优先于任何 <VariableInsert>', at(precedence.initvarData, '名字') === '问候语里的',
155
+ JSON.stringify(precedence.initvarData))
156
+ check('问候语里的 <VariableInsert> 优先于世界书里的 <initvar>',
157
+ parseMuvCard({
158
+ data: {
159
+ name: 'p2',
160
+ first_mes: '<VariableInsert>{"问候语里的":1}</VariableInsert>',
161
+ character_book: { entries: [{ comment: 'x', content: '<initvar>\n名字: 世界书示例\n</initvar>' }] },
162
+ },
163
+ }).initvarData['问候语里的'] === 1)
164
+
165
+ // 文档块(引用了标签但不是 JSON)不能抢走真块,也不能抛异常
166
+ const docCard = parseMuvCard({
167
+ data: {
168
+ name: 'doc',
169
+ first_mes: '【主页】\n<VariableInsert>' + JSON.stringify({ 真变量: { a: 1 } }) + '</VariableInsert>',
170
+ character_book: {
171
+ entries: [{ comment: '规则', content: '- 新增则 `<VariableInsert>`\n```json\n{ "示例": 1 }\n```\n</VariableInsert>' }],
172
+ },
173
+ },
174
+ })
175
+ check('世界书里的文档块不被误命中', Object.keys(docCard.initvarData).join(',') === '真变量',
176
+ Object.keys(docCard.initvarData).join(','))
177
+ check('只有非法文档块时返回空表而不抛异常',
178
+ (() => {
179
+ const d = parseMuvCard({
180
+ data: {
181
+ name: 'doc-only',
182
+ first_mes: '没有变量',
183
+ character_book: { entries: [{ comment: 'd', content: '<VariableInsert>不是 JSON {</VariableInsert>' }] },
184
+ },
185
+ })
186
+ return d.schemas.length === 0 && typeof d.initvarData === 'object'
187
+ })())
188
+
189
+ // ─────────────────────────────────────────────────────────
190
+ console.log('\n[4] ④ 畸形卡:该是数组的地方来了对象/字符串')
191
+ const malformed = {
192
+ name: 'malformed',
193
+ first_mes: 'hi',
194
+ alternate_greetings: { a: 'x' },
195
+ character_book: { entries: { not: 'an array' } },
196
+ extensions: { tavern_helper: { scripts: 'not an array' } },
197
+ }
198
+ let malformedError = ''
199
+ try {
200
+ const m = parseMuvCard(malformed)
201
+ check('parseMuvCard 不抛异常', true)
202
+ check('返回可用的空结构', Array.isArray(m.schemas) && m.schemas.length === 0, 'schemas=' + m.schemas.length)
203
+ } catch (e) { malformedError = e.message; check('parseMuvCard 不抛异常', false, e.message) }
204
+ check('extractVariableInsert 不抛异常', (() => {
205
+ try { extractVariableInsert({ alternate_greetings: { a: 1 }, first_mes: 'x' }); return true } catch { return false }
206
+ })())
207
+ check('alternate_greetings 是对象时仍能读到 first_mes 里的变量',
208
+ Object.keys(parseMuvCard({
209
+ data: { name: 'm2', alternate_greetings: {}, first_mes: '<initvar>\n甲: 1\n</initvar>' },
210
+ }).initvarData).join(',') === '甲')
211
+ check('entries 是数组但条目是 null 时不抛异常', (() => {
212
+ try {
213
+ const m = parseMuvCard({ data: { name: 'm3', first_mes: 'x', character_book: { entries: [null, 7, { content: 'ok' }] } } })
214
+ return Array.isArray(m.schemas)
215
+ } catch { return false }
216
+ })())
217
+ check('parseMuvCard(null) 不抛异常', (() => {
218
+ try { return parseMuvCard(null).name === 'Unnamed' } catch { return false }
219
+ })())
220
+ console.log(' (畸形卡: ' + (malformedError || '无异常') + ')')
221
+
222
+ // ─────────────────────────────────────────────────────────
223
+ console.log('\n[5] ⑤ 扁平 V1 卡(没有 data 包装)')
224
+ const flat = parseMuvCard({
225
+ name: 'flat',
226
+ description: '描述',
227
+ first_mes: '<initvar>\n名字: 扁平卡\n年龄: 18\n</initvar>',
228
+ spec: 'chara_card_v1',
229
+ extensions: { regex_scripts: [{ scriptName: 'r1' }] },
230
+ })
231
+ check('卡名读得到', flat.name === 'flat', 'name=' + flat.name)
232
+ check('initvarData 非空', Object.keys(flat.initvarData).join(',') === '名字,年龄',
233
+ Object.keys(flat.initvarData).join(','))
234
+ check('schemas 非空', flat.schemas.length === 2, 'schemas=' + flat.schemas.length)
235
+ check('扁平卡里的 <VariableInsert> 也认',
236
+ Object.keys(parseMuvCard({ name: 'f2', first_mes: '<VariableInsert>{"世界信息":1}</VariableInsert>' }).initvarData).join(',') === '世界信息')
237
+ check('有 data 包装时以 data 为准(不回归)', (() => {
238
+ const w = parseMuvCard({ name: 'outer', first_mes: '<initvar>\n外层: 1\n</initvar>', data: { name: 'inner', first_mes: '<initvar>\n内层: 1\n</initvar>' } })
239
+ return w.name === 'inner' && Object.keys(w.initvarData).join(',') === '内层'
240
+ })(), 'name=' + parseMuvCard({ name: 'outer', data: { name: 'inner' } }).name)
241
+
242
+ // ─────────────────────────────────────────────────────────
243
+ console.log('\n[6] 列表在 schema 里的形状 + 编辑后仍然写成列表')
244
+ const listSchema = parseMuvCard({ name: 'ls', first_mes: '<initvar>' + listText + '</initvar>' })
245
+ const listField = fieldAt(listSchema.schemas, '人际交往.结识道友录.沈慕微.最近互动记录')
246
+ check('列表字段进了 schema', !!listField)
247
+ check('类型是文本、标记 isList', listField?.type === 'string' && listField?.isList === true,
248
+ JSON.stringify({ type: listField?.type, isList: listField?.isList }))
249
+ check('默认值是多行文本', listField?.defaultValue === '因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。',
250
+ JSON.stringify(listField?.defaultValue))
251
+ const edited = applyEditsAndGenerate(listSchema.initvarData, [
252
+ { path: '人际交往.结识道友录.沈慕微.最近互动记录', value: '第一条\n第二条' },
253
+ ])
254
+ check('编辑列表行 → 生成块仍是 `- ` 列表',
255
+ edited.includes(' - 第一条') && edited.includes(' - 第二条'), edited.split('\n').filter(l => l.includes('第一条')).join('|'))
256
+ check('编辑列表行 → 路径没被拍平成一个字符串',
257
+ edited.includes('最近互动记录:\n'), edited.split('\n').slice(0, 14).join(' / '))
258
+ check('生成块包着 <initvar> 且可再解析',
259
+ generateMuvBlock(listSchema.initvarData).startsWith('<UpdateVariable>\n<initvar>'))
260
+
261
+ // ─────────────────────────────────────────────────────────
262
+ console.log('\n[7] 真实卡:往返一致性(解析→序列化→解析 必须一模一样)')
263
+ const REAL = ['苍玄界', '_足控天堂2', '食人世界', '涩涩提瓦特', '异世界农场']
264
+ let blocks = 0, notIdempotent = 0, realParsed = 0
265
+ const seen = []
266
+ for (const nm of REAL) {
267
+ const file = findCard(nm)
268
+ if (!file) continue
269
+ let card
270
+ try { card = file.endsWith('.png') ? readPngCard(file) : JSON.parse(fs.readFileSync(file, 'utf8')) } catch (_) { continue }
271
+ seen.push(nm)
272
+ // 解析不抛异常
273
+ let parsed
274
+ try { parsed = parseMuvCard(card) } catch (e) {
275
+ check(nm + ' parseMuvCard 不抛异常', false, e.message)
276
+ continue
277
+ }
278
+ realParsed++
279
+ const data = card.data && typeof card.data === 'object' && !Array.isArray(card.data) ? card.data : card
280
+ const texts = []
281
+ for (const k of ['first_mes', 'description', 'scenario']) if (typeof data[k] === 'string') texts.push(data[k])
282
+ for (const g of (Array.isArray(data.alternate_greetings) ? data.alternate_greetings : [])) if (typeof g === 'string') texts.push(g)
283
+ for (const t of texts) {
284
+ const m = t.match(/<initvar>([\s\S]*?)<\/initvar>/i)
285
+ if (!m) continue
286
+ blocks++
287
+ const once = parseInitvar(m[1])
288
+ const twice = parseInitvar(serializeInitvar(once))
289
+ if (JSON.stringify(once) !== JSON.stringify(twice)) {
290
+ notIdempotent++
291
+ if (notIdempotent <= 2) console.log(' 首个不一致: ' + nm + ' ' + firstDiff(once, twice))
292
+ }
293
+ }
294
+ check(nm + ' 解析成功(schemas=' + parsed.schemas.length + ', blocks=' + parsed.initvarBlocks.length + ')', true)
295
+ }
296
+ function firstDiff(x, y, pre = '') {
297
+ const keys = new Set([...Object.keys(x || {}), ...Object.keys(y || {})])
298
+ for (const k of keys) {
299
+ const xv = x?.[k], yv = y?.[k]
300
+ if (JSON.stringify(xv) === JSON.stringify(yv)) continue
301
+ if (xv && yv && typeof xv === 'object' && typeof yv === 'object' && !Array.isArray(xv) && !Array.isArray(yv)) {
302
+ return firstDiff(xv, yv, pre + k + '.')
303
+ }
304
+ return pre + k + ': ' + JSON.stringify(xv) + ' -> ' + JSON.stringify(yv)
305
+ }
306
+ return '(仅嵌套差异)'
307
+ }
308
+ if (!seen.length) {
309
+ console.log(' SKIP 本机没有真实卡样本')
310
+ } else {
311
+ console.log(' 真实卡: ' + seen.join(' / '))
312
+ check('真实卡的 initvar 块都被解析了 (' + blocks + ' 块)', blocks > 0)
313
+ check('每块都往返一致(0 处元数据丢失)', notIdempotent === 0, notIdempotent + ' 块不一致')
314
+ }
315
+
316
+ // ─────────────────────────────────────────────────────────
317
+ console.log('\n[8] 真实卡:苍玄界 的两个列表字段(本次修复的原始病灶)')
318
+ const cangxuan = findCard('苍玄界')
319
+ if (!cangxuan) {
320
+ console.log(' SKIP 找不到 苍玄界')
321
+ } else {
322
+ const card = cangxuan.endsWith('.png') ? readPngCard(cangxuan) : JSON.parse(fs.readFileSync(cangxuan, 'utf8'))
323
+ const parsed = parseMuvCard(card)
324
+ const WANT = ['人际交往.结识道友录.沈慕微.最近互动记录', '人际交往.结识道友录.江念.最近互动记录']
325
+ const leaves = leafPaths(parsed.schemas)
326
+ check('两条互动记录路径都在 schema 里', WANT.every(p => leaves.includes(p)),
327
+ '缺: ' + WANT.filter(p => !leaves.includes(p)).join(',') + ' (共 ' + leaves.length + ' 叶子)')
328
+ check('叶子行数 >= 67', leaves.length >= 67, 'leaves=' + leaves.length)
329
+ check('数据里是数组', Array.isArray(at(parsed.initvarData, WANT[0])), JSON.stringify(at(parsed.initvarData, WANT[0])))
330
+ }
331
+
332
+ // ─────────────────────────────────────────────────────────
333
+ console.log('\n[9] 真实卡:足控天堂2 的文档块不能顶掉真变量树')
334
+ const fk = findCard('_足控天堂2')
335
+ if (!fk) {
336
+ console.log(' SKIP 找不到 _足控天堂2')
337
+ } else {
338
+ const parsed = parseMuvCard(readPngCard(fk))
339
+ const keys = parsed.schemas.map(s => s.name)
340
+ check('schemas 仍是 8 组', parsed.schemas.length === 8, 'schemas=' + parsed.schemas.length)
341
+ check('读的是真树(公司.总现金=40000)', at(parsed.initvarData, '公司.总现金') === 40000,
342
+ '总现金=' + at(parsed.initvarData, '公司.总现金'))
343
+ check('世界书/脚本里的文档示例没被当变量', !keys.includes('示例') && !keys.includes('变量结构'), keys.join(','))
344
+ check('主播档案.$template 仍被剔除', !keys.includes('$template'))
345
+ }
346
+
347
+ // ─────────────────────────────────────────────────────────
348
+ // 世界书里还有第三种投递方式:**没有 <initvar> 标签**、只有注释前缀 `[initvar]`
349
+ // 的裸 YAML(`异世界农场` 的 `[initvar]变量初始化勿开`,content 里就是
350
+ // `时间:` / `种族好感度:` 这样的裸文本)。按标签扫描永远扫不到它,所以这张卡
351
+ // 以前是 schemas: 0。
352
+ console.log('\n[10] 真实卡:异世界农场 —— 裸 [initvar] 世界书条目(本轮修复)')
353
+ const farm = findCard('异世界农场')
354
+ if (!farm) {
355
+ console.log(' SKIP 找不到 异世界农场')
356
+ } else {
357
+ const card = readPngCard(farm)
358
+ const entry = (card.data.character_book?.entries || [])
359
+ .find(e => /^\[initvar\]/i.test(String(e?.comment || '')))
360
+ check('确实存在注释为 [initvar]… 的世界书条目', !!entry, entry?.comment)
361
+ check('那份 content 里没有 <initvar> 标签(所以按标签扫不到,这就是本轮要接的洞)',
362
+ !!entry && !/<initvar>/i.test(String(entry.content || '')),
363
+ 'content 前 40 字: ' + String(entry?.content || '').slice(0, 40))
364
+ const parsed = parseMuvCard(card)
365
+ check('★ schemas 由 0 变成 3 组', parsed.schemas.length === 3, 'schemas=' + parsed.schemas.length)
366
+ check('★ 组名是 时间 / 种族好感度 / 个人好感度',
367
+ parsed.schemas.map(s => s.name).join(',') === '时间,种族好感度,个人好感度',
368
+ parsed.schemas.map(s => s.name).join(','))
369
+ check('★ schemaSource 标成 worldbook-initvar', parsed.schemaSource === 'worldbook-initvar', parsed.schemaSource)
370
+ check('★ 数据读得到(时间.日期 含 05-20)', String(parsed.initvarData?.时间?.日期 || '').includes('05-20'),
371
+ JSON.stringify(parsed.initvarData?.时间))
372
+ check('没有产生垃圾 schema(组名里没有 [mvu_update] 那几张文档的键)',
373
+ !parsed.schemas.some(s => /变量输出格式|变量更新规则|rule/.test(s.name)),
374
+ parsed.schemas.map(s => s.name).join(','))
375
+ check('Zod 变量结构读得到(上一轮的修复仍然有效)', String(parsed.zodSource || '').length > 100,
376
+ 'zodSource=' + String(parsed.zodSource || '').length)
377
+ }
378
+
379
+ // ─────────────────────────────────────────────────────────
380
+ // schemaSource 的取值契约 + 三道闸(合成用例,精确覆盖每条分支)
381
+ console.log('\n[11] schemaSource 与裸 [initvar] 的三道闸')
382
+ check('没有变量来源时 schemaSource = none',
383
+ parseMuvCard({ data: { name: 'none', first_mes: '没变量' } }).schemaSource === 'none')
384
+ check('<initvar> 标签 -> initvar',
385
+ parseMuvCard({ data: { name: 'a', first_mes: '<initvar>\n甲: 1\n</initvar>' } }).schemaSource === 'initvar')
386
+ check('<VariableInsert> -> variable-insert',
387
+ parseMuvCard({ data: { name: 'b', first_mes: '<VariableInsert>{"甲":1}</VariableInsert>' } }).schemaSource === 'variable-insert')
388
+
389
+ // 闸① 只在其它来源全部落空时启用 —— 能解析的卡不许换数据源
390
+ const g1 = parseMuvCard({
391
+ data: {
392
+ name: 'g1',
393
+ first_mes: '<initvar>\n名字: 问候语里的\n</initvar>',
394
+ character_book: { entries: [{ comment: '[initvar]变量初始化勿开', content: '名字: 世界书里的\n年龄: 3' }] },
395
+ },
396
+ })
397
+ check('闸① 问候语里的 <initvar> 优先于裸 [initvar] 世界书条目',
398
+ at(g1.initvarData, '名字') === '问候语里的' && g1.schemaSource === 'initvar',
399
+ JSON.stringify(g1.initvarData) + ' source=' + g1.schemaSource)
400
+ const g1b = parseMuvCard({
401
+ data: {
402
+ name: 'g1b',
403
+ first_mes: '<VariableInsert>{"问候语里的":1}</VariableInsert>',
404
+ character_book: { entries: [{ comment: '[initvar]变量初始化勿开', content: '名字: 世界书里的' }] },
405
+ },
406
+ })
407
+ check('闸① <VariableInsert> 也优先于裸 [initvar] 世界书条目',
408
+ g1b.initvarData['问候语里的'] === 1 && g1b.schemaSource === 'variable-insert',
409
+ JSON.stringify(g1b.initvarData) + ' source=' + g1b.schemaSource)
410
+
411
+ // 闸② 解析出来是空的就不采用
412
+ for (const [label, content] of [['空串', ''], ['只有空白行', '\n\n \n'], ['只有注释', '# 变量初始化']]) {
413
+ const g2 = parseMuvCard({
414
+ data: { name: 'g2', first_mes: '没变量', character_book: { entries: [{ comment: '[initvar]x', content }] } },
415
+ })
416
+ check('闸② ' + label + ' -> 不采用(schemas=0 且 source=none)',
417
+ g2.schemas.length === 0 && g2.schemaSource === 'none' && Object.keys(g2.initvarData).length === 0,
418
+ 'schemas=' + g2.schemas.length + ' source=' + g2.schemaSource + ' data=' + JSON.stringify(g2.initvarData))
419
+ }
420
+
421
+ // 前缀边界:兄弟约定是**文档**不是数据(苍玄界 / 异世界农场 都有这几条)
422
+ const docOnly = parseMuvCard({
423
+ data: {
424
+ name: 'doconly',
425
+ first_mes: '没变量',
426
+ character_book: {
427
+ entries: [
428
+ { comment: '[mvu_update]变量输出格式', content: '---\n变量输出格式:\n rule:\n - must output the update analysis' },
429
+ { comment: '[mvu_update]变量更新规则', content: '变量更新规则:\n 种族好感度: 0' },
430
+ { comment: '[mvu_plot]插画强调', content: '---\n[插画强调]\n重要' },
431
+ ],
432
+ },
433
+ },
434
+ })
435
+ check('★ [mvu_update] / [mvu_plot] 条目不被当成变量(文档不是数据)',
436
+ docOnly.schemas.length === 0 && docOnly.schemaSource === 'none',
437
+ 'schemas=' + docOnly.schemas.length + ' source=' + docOnly.schemaSource + ' data=' + JSON.stringify(docOnly.initvarData))
438
+
439
+ // 闸③ 采用时标出来源
440
+ const g3 = parseMuvCard({
441
+ data: {
442
+ name: 'g3',
443
+ first_mes: '没变量',
444
+ character_book: { entries: [{ comment: '说明: [initvar] 是约定', content: '干扰: 1' }, { comment: '[initvar]变量初始化勿开', content: '甲: 1\n乙:\n 丙: 2' }] },
445
+ },
446
+ })
447
+ check('闸③ 裸 [initvar] 条目被采用并标出来源(注释里有 [initvar] 但不在行首的不算)',
448
+ g3.schemas.length === 2 && g3.schemaSource === 'worldbook-initvar' && at(g3.initvarData, '乙.丙') === 2,
449
+ 'schemas=' + g3.schemas.length + ' source=' + g3.schemaSource + ' data=' + JSON.stringify(g3.initvarData))
450
+
451
+ // 闸②的形状判定:content 必须**读起来像变量映射**。parseInitvar 从不抛异常(它丢掉
452
+ // 用不上的行、返回剩下的),所以"能解析"不能当证据 —— 散文/说明文档必须被挡住。
453
+ for (const [label, content] of [
454
+ ['全角冒号的散文', '变量初始化格式说明:请在每轮回复结尾输出 JSONPatch。'],
455
+ ['带一个键的说明文档', '说明:\n 本条目用于记录变量初始化格式,具体写法见下方的规则说明文字。'],
456
+ ['说明 + 段落(一行不像键就足够否决)', '时间:\n 日期: 05-20\n这一段是作者写给玩家看的说明文字,没有冒号。'],
457
+ ]) {
458
+ const g2b = parseMuvCard({
459
+ data: { name: 'g2b', first_mes: '没变量', character_book: { entries: [{ comment: '[initvar]变量初始化勿开', content }] } },
460
+ })
461
+ check('闸② 形状判定挡住:' + label,
462
+ g2b.schemas.length === 0 && g2b.schemaSource === 'none', 'schemas=' + g2b.schemas.length + ' source=' + g2b.schemaSource)
463
+ }
464
+ check('闸② 合法的列表值不会被误否决',
465
+ parseMuvCard({
466
+ data: { name: 'g2c', first_mes: '没变量', character_book: { entries: [{ comment: '[initvar]x', content: '人际交往:\n 沈慕微:\n 最近互动记录:\n - 因为想吃灵鹤被{{user}}抓包。\n 关系标签: 师尊' }] } },
467
+ }).schemaSource === 'worldbook-initvar')
468
+
469
+ // ★ `enabled: false` 的豁免是**有意**的,不是漏判:
470
+ // `异世界农场` 要读的那条本身就是 `enabled: false`(comment 还写着「勿开」)。
471
+ // 作者的约定是「把变量树放进一个禁用的条目,避免它被注入上下文,工具链仍可读取」。
472
+ // 若要求条目必须启用,这条兜底在唯一需要它的卡上就是空转(见 repro-wb-initvar.mjs 实测)。
473
+ // 挡住垃圾的是 `[initvar]` 标记 + 上面的形状判定,不是 enabled。
474
+ check('★ disabled 的 [initvar] 条目照样读(作者的约定就是把它关掉)',
475
+ parseMuvCard({
476
+ data: { name: 'dis', first_mes: '没变量', character_book: { entries: [{ comment: '[initvar]变量初始化勿开', content: '甲: 1', enabled: false }] } },
477
+ }).schemaSource === 'worldbook-initvar')
478
+ check('★ enabled 的 [initvar] 条目也读(两种都认)',
479
+ parseMuvCard({
480
+ data: { name: 'en', first_mes: '没变量', character_book: { entries: [{ comment: '[initvar]变量初始化', content: '甲: 1', enabled: true }] } },
481
+ }).schemaSource === 'worldbook-initvar')
482
+ check('★ disabled 且没有 [initvar] 标记的条目永远不读',
483
+ parseMuvCard({
484
+ data: { name: 'dis2', first_mes: '没变量', character_book: { entries: [{ comment: '变量初始化', content: '甲: 1', enabled: false }] } },
485
+ }).schemaSource === 'none')
486
+
487
+ console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`)
488
+ process.exit(fail ? 1 : 0)