dsh-muv-table 0.2.10 → 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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,44 @@
1
1
  # Changelog
2
2
 
3
- ## v0.2.10 (2026-09-20)
3
+ ## v0.2.11 (2026-09-20)
4
+ ### 🐛 修复:世界书里「没有 `<initvar>` 标签、只有 `[initvar]` 注释前缀」的裸 YAML 变量树读不到
5
+
6
+ **现象**:`异世界农场` 的整棵变量树**不在卡文本里**,而在世界书条目
7
+ `[initvar]变量初始化勿开` 的 `content` 里,是裸 YAML:
8
+
9
+ ```
10
+ 时间:
11
+ 日期: '05-20'
12
+ 星期: 周日
13
+ 种族好感度:
14
+ 凛原族: 0
15
+ …
16
+ ```
17
+
18
+ 它**没有 `<initvar>` 标签**,所以按标签扫描的候选集
19
+ (`first_mes` / `description` / `scenario` / `alternate_greetings` / 世界书 / helper)
20
+ 永远扫不到 → 该卡 `schemas` 恒为 `0`,面板一片空白。
21
+
22
+ **修法**(`lib/muv-parser.js`):新增 `worldbookInitvarEntries()`(找 `comment` 以
23
+ `[initvar]` 开头的世界书条目)与 `looksLikeInitvarMapping()`(content 必须**读起来像
24
+ 变量映射**:`键:` / `键: 值` 行算键、`- item` 算列表内容、空行与 `#` 注释跳过,
25
+ **只要有一行不属于这三类就否决**)。四道闸:① 只在上面所有来源都落空时启用;
26
+ ② content 必须过形状判定;③ 解析出的映射非空;④ 响应里标
27
+ `schemaSource: 'worldbook-initvar'`,来源可见(面板上显示在卡名后面)。
28
+
29
+ **★ 为什么它会读一条 `enabled: false` 的条目**(这条请照原样保留,否则会被当 bug 报回来):
30
+ 要读的那条条目**本身就是被禁用的**,而且 `comment` 里就写着「变量初始化**勿开**」。
31
+ 这是卡作者的**约定**:把变量树放进一条禁用条目 → 它不会被注入上下文,但工具链仍然
32
+ 可以读到(「勿开」正是这个意思的字面声明)。所以判定只认 `[initvar]` 这个**显式标记**,
33
+ `enabled` 不参与;没有该标记的禁用条目**永远不读**。挡住垃圾的是「标记 + 形状判定」,
34
+ 不是 `enabled`。若要求条目必须启用,这条兜底在唯一需要它的卡上就是空转。
35
+
36
+ **实测**:`异世界农场` schemas **0 → 3**(时间 / 种族好感度 / 个人好感度),
37
+ `schemaSource = worldbook-initvar`;`苍玄界`(对应条目叫 `[mvu_update]变量输出格式`,
38
+ 是**文档**不是数据)、`_足控天堂2`、`食人世界`、`涩涩提瓦特`、`default_Seraphina`
39
+ **逐字节无回归**;`test-muv-parser.mjs` 56 → 76 项。
40
+
41
+ ## v0.2.10 (2026-09-20)
4
42
  ### 🐛 修复:变量面板读不到「足控天堂2」这种卡的变量表
5
43
 
6
44
  **现象**(已复现):`GET /api/muv-table/tavern-card?presetId=…` 对这张卡返回
@@ -244,20 +282,20 @@ first_mes, …}`),但 `parseMuvCard` 只认 `cardJson.data` → 卡名对了
244
282
  但 `[initvar]` 万一也是文档就会解析出垃圾表)。留待决定。现状已用
245
283
  `test-muv-parser.mjs` 的第 [10] 节**钉住**(含「没有标签」这条断言),改动必然是有意的。
246
284
 
247
- ## v0.2.9 (2026-09-20)
248
- ### 🐛 修复:面板「不管点哪个会话都显示同一张卡」
249
-
250
- 服务端无问题(按 `presetId` 请求时每个预设都返回各自的卡)。根因在客户端:它一直发同一个
251
- 预设——因为它依赖酒馆 DOM,而 `#tavern-session-preset-label` 的 `dataset.presetId`
252
- 在切换会话时不一定更新(酒馆自己 `bundle.js:1655` 用 `savedPid || 旧值`,`savedPid`
253
- 为空时**保留旧值**),`localStorage` 那份更是切换预设时才写。**切换会话根本不会更新它们。**
254
-
255
- 改为**优先用当前 DSH 会话 id 请求**:服务端按 `session-bindings.json` 查出该会话绑定的预设,
256
- 切换会话必然跟着变,不会粘。实测 4/5 会话正确解析(第 5 个 `minimal-gitbash` 本身无卡)。
257
-
258
- 注意只传一个定位参数且会话 id 优先——服务端判定顺序是 `if (presetId) … else if (sessionId) …`,
259
- 两个都传时**可能粘住的那个会盖掉可靠的**。
260
- ## v0.2.8 (2026-09-20)
285
+ ## v0.2.9 (2026-09-20)
286
+ ### 🐛 修复:面板「不管点哪个会话都显示同一张卡」
287
+
288
+ 服务端无问题(按 `presetId` 请求时每个预设都返回各自的卡)。根因在客户端:它一直发同一个
289
+ 预设——因为它依赖酒馆 DOM,而 `#tavern-session-preset-label` 的 `dataset.presetId`
290
+ 在切换会话时不一定更新(酒馆自己 `bundle.js:1655` 用 `savedPid || 旧值`,`savedPid`
291
+ 为空时**保留旧值**),`localStorage` 那份更是切换预设时才写。**切换会话根本不会更新它们。**
292
+
293
+ 改为**优先用当前 DSH 会话 id 请求**:服务端按 `session-bindings.json` 查出该会话绑定的预设,
294
+ 切换会话必然跟着变,不会粘。实测 4/5 会话正确解析(第 5 个 `minimal-gitbash` 本身无卡)。
295
+
296
+ 注意只传一个定位参数且会话 id 优先——服务端判定顺序是 `if (presetId) … else if (sessionId) …`,
297
+ 两个都传时**可能粘住的那个会盖掉可靠的**。
298
+ ## v0.2.8 (2026-09-20)
261
299
  ### 🐛 修复:Zod「变量结构」整类读不到(覆盖面最大的一个)
262
300
 
263
301
  parseMuvCard() 取 Zod schema 时用的是**精确等值** `s.name === 'zod'`,而真机上的卡
package/lib/index.js CHANGED
@@ -440,7 +440,8 @@ function extractMuvFromCharactersJson(presetDir) {
440
440
  schemas: inferSchemas(clean),
441
441
  initvarData: clean,
442
442
  initvarBlocks: [body],
443
- zodSource: ''
443
+ zodSource: '',
444
+ schemaSource: 'variable-insert'
444
445
  }
445
446
  } catch (_) {
446
447
  // Fall through: the block may still be YAML-ish.
@@ -456,7 +457,8 @@ function extractMuvFromCharactersJson(presetDir) {
456
457
  schemas: inferSchemas(parsed),
457
458
  initvarData: parsed,
458
459
  initvarBlocks: [iv[1]],
459
- zodSource: ''
460
+ zodSource: '',
461
+ schemaSource: 'initvar'
460
462
  }
461
463
  }
462
464
  return null
@@ -484,10 +486,10 @@ function extractMuvFromYml(yml) {
484
486
  const blockText = m[1]
485
487
  const parsed = parseInitvar(blockText)
486
488
  const schemas = inferSchemas(parsed)
487
- return { name, schemas, initvarData: parsed, initvarBlocks: [blockText], zodSource: '' }
489
+ return { name, schemas, initvarData: parsed, initvarBlocks: [blockText], zodSource: '', schemaSource: 'initvar' }
488
490
  }
489
491
  // No initvar default — serve empty-but-valid so the panel opens empty
490
- return { name, schemas: [], initvarData: {}, initvarBlocks: [], zodSource: '' }
492
+ return { name, schemas: [], initvarData: {}, initvarBlocks: [], zodSource: '', schemaSource: 'none' }
491
493
  } catch (_) {
492
494
  return null
493
495
  }
package/lib/muv-parser.js CHANGED
@@ -63,6 +63,70 @@ function textsInGroups(candidates, ...groups) {
63
63
  return candidates.filter(c => groups.includes(c.group)).map(c => c.text)
64
64
  }
65
65
 
66
+ /**
67
+ * World-book entries that hold a **bare** initvar block — no `<initvar>` tag at all.
68
+ *
69
+ * `异世界农场` keeps its entire variable tree in an entry whose comment is
70
+ * `[initvar]变量初始化勿开` and whose content is plain YAML:
71
+ *
72
+ * 时间:
73
+ * 日期: '05-20'
74
+ * 星期: 周日
75
+ * …
76
+ *
77
+ * Because there is no tag, the tag-based scan can never reach it and the card
78
+ * reported `schemas: 0` even though the whole tree was sitting in the world book.
79
+ *
80
+ * The prefix must be exactly `[initvar]`. The sibling conventions are **documents,
81
+ * not data** and reading them as YAML would build a junk table:
82
+ *
83
+ * - `[mvu_update]变量输出格式` / `[mvu_update]变量更新规则` — how to emit updates
84
+ * (苍玄界 and 异世界农场 both carry these);
85
+ * - `[mvu_plot]…` — illustration prompts.
86
+ *
87
+ * @param {object} data - the card's `data` object
88
+ * @returns {Array<{comment: string, content: string}>}
89
+ */
90
+ function worldbookInitvarEntries(data) {
91
+ const out = []
92
+ for (const entry of asArray(data?.character_book?.entries)) {
93
+ if (!/^\s*\[initvar\]/i.test(String(entry?.comment || ''))) continue
94
+ if (typeof entry?.content === 'string') out.push({ comment: String(entry.comment), content: entry.content })
95
+ }
96
+ return out
97
+ }
98
+
99
+ /**
100
+ * Does this text read as a YAML **mapping of variables**, rather than prose or a
101
+ * document that happens to mention keys?
102
+ *
103
+ * `parseInitvar` never fails — it drops what it cannot use and returns whatever is
104
+ * left, so "it parsed" is not evidence. This is the actual shape test used to decide
105
+ * whether a `[initvar]`-marked entry is data:
106
+ *
107
+ * - blanks and `# comment` lines are ignored;
108
+ * - `键:` / `键: 值` lines count as keys;
109
+ * - `- item` lines count as list content belonging to the nearest key;
110
+ * - **any other line means this is not a variable mapping** → reject.
111
+ *
112
+ * That last rule is what keeps a prose document out: Chinese prose uses the full-width
113
+ * `:`, so a sentence like `变量初始化格式说明:…` produces no key line at all, and a
114
+ * paragraph next to a stray `键:` line is still rejected (one foreign line is enough).
115
+ * @param {string} text
116
+ * @returns {boolean}
117
+ */
118
+ function looksLikeInitvarMapping(text) {
119
+ let keys = 0
120
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
121
+ const line = rawLine.trim()
122
+ if (!line || line.startsWith('#')) continue
123
+ if (/^-(?:\s|$)/.test(line)) continue
124
+ if (/^[^:\s#][^:]*:(\s|$)/.test(line)) { keys++; continue }
125
+ return false
126
+ }
127
+ return keys > 0
128
+ }
129
+
66
130
  /** The first `<initvar>` block in a list of texts, or null. */
67
131
  function firstInitvarBlock(texts) {
68
132
  for (const text of texts) {
@@ -140,7 +204,10 @@ export function extractVariableInsert(data) {
140
204
  /**
141
205
  * Parse a MUV character card JSON and extract schema + variable data.
142
206
  * @param {object} cardJson - The full character card JSON
143
- * @returns {{ name: string, schemas: object[], initvarData: object, zodSource: string }}
207
+ * @returns {{ name: string, schemas: object[], initvarData: object, zodSource: string,
208
+ * schemaSource: 'initvar'|'variable-insert'|'worldbook-initvar'|'none' }}
209
+ * `schemaSource` names the source the variable tree was read from ('none' when the
210
+ * card carries none), so a reader can tell a real tree from the heuristic fallback.
144
211
  */
145
212
  export function parseMuvCard(cardJson) {
146
213
  const data = cardData(cardJson)
@@ -187,6 +254,8 @@ export function parseMuvCard(cardJson) {
187
254
  // Use the first initvar block as default data
188
255
  let initvarData = initvarBlocks.length > 0 ? initvarBlocks[0].parsed : {}
189
256
  let initvarRaw = initvarBlocks.map(b => b.raw)
257
+ /** Where the variable tree came from — see `schemaSource` in the return value. */
258
+ let schemaSource = initvarRaw.length > 0 ? 'initvar' : 'none'
190
259
 
191
260
  // No <initvar> anywhere → fall back to the community <VariableInsert> shape.
192
261
  if (initvarRaw.length === 0) {
@@ -194,6 +263,7 @@ export function parseMuvCard(cardJson) {
194
263
  if (inserted) {
195
264
  initvarData = inserted.data
196
265
  initvarRaw = [inserted.raw]
266
+ schemaSource = 'variable-insert'
197
267
  }
198
268
  }
199
269
 
@@ -207,6 +277,38 @@ export function parseMuvCard(cardJson) {
207
277
  if (hit) {
208
278
  initvarData = hit.parsed
209
279
  initvarRaw = [hit.raw]
280
+ schemaSource = 'initvar'
281
+ }
282
+ }
283
+
284
+ // Last of the last resorts: a world-book entry marked `[initvar]…` whose content is
285
+ // bare YAML with no tag at all (see worldbookInitvarEntries). Four gates:
286
+ //
287
+ // ① only after every source above came up empty — a card that already parses from
288
+ // a greeting must not change where it reads from (native `<initvar>` wins);
289
+ // ② the content must actually read as a YAML mapping (looksLikeInitvarMapping),
290
+ // not as prose/documentation;
291
+ // ③ the caller is told where the tree came from via `schemaSource`, so this
292
+ // heuristic is visible in the API response instead of being silent;
293
+ // ④ the parsed mapping must be non-empty.
294
+ //
295
+ // **`enabled` is deliberately not part of the filter.** The entry this fix exists for
296
+ // — 异世界农场's `[initvar]变量初始化勿开` — is itself `enabled: false`, and its
297
+ // comment literally says 「勿开」(do not enable). That is the convention, not an
298
+ // accident: authors keep the variable tree in a *disabled* entry so it is never
299
+ // injected into the prompt, while tooling can still read it. Skipping disabled
300
+ // entries would make this whole fallback a no-op on the only card that needs it
301
+ // (measured: repro-wb-initvar.mjs). What keeps junk out is the exact `[initvar]`
302
+ // marker plus gate ② — a disabled entry *without* that marker is never read.
303
+ if (initvarRaw.length === 0) {
304
+ for (const entry of worldbookInitvarEntries(data)) {
305
+ if (!looksLikeInitvarMapping(entry.content)) continue
306
+ const parsed = parseInitvar(entry.content)
307
+ if (!parsed || typeof parsed !== 'object' || Object.keys(parsed).length === 0) continue
308
+ initvarData = parsed
309
+ initvarRaw = [String(entry.content).trim()]
310
+ schemaSource = 'worldbook-initvar'
311
+ break
210
312
  }
211
313
  }
212
314
 
@@ -218,7 +320,8 @@ export function parseMuvCard(cardJson) {
218
320
  schemas,
219
321
  initvarData,
220
322
  initvarBlocks: initvarRaw,
221
- zodSource
323
+ zodSource,
324
+ schemaSource
222
325
  }
223
326
  }
224
327
 
package/lib/panel.html CHANGED
@@ -46,6 +46,15 @@ body { font-family: 'Segoe UI', 'Noto Sans SC', system-ui, sans-serif; backgroun
46
46
  </div>
47
47
  <script>
48
48
  let state = { cardName: '', schemas: [], initvarData: {}, edits: {}, generatedBlock: '' }
49
+ // Where the variable tree was read from, shown next to the card name. `worldbook-initvar`
50
+ // is the only *heuristic* source (a world-book entry marked `[initvar]` with bare YAML
51
+ // and no tag), so it has to be visible — otherwise a wrong guess looks exactly like a
52
+ // card that genuinely declares its variables.
53
+ const SOURCE_LABEL = { 'initvar': '<initvar> 块', 'variable-insert': '<VariableInsert>', 'worldbook-initvar': '世界书 [initvar] 条目', 'none': '' }
54
+ function badgeText(d) {
55
+ const label = SOURCE_LABEL[d.schemaSource]
56
+ return d.name + (label ? ' · ' + label : '')
57
+ }
49
58
  function handleDrop(e) { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) loadFile(f) }
50
59
  function handleFile(e) { const f = e.target.files[0]; if (f) loadFile(f) }
51
60
  function loadFile(f) { const r = new FileReader(); r.onload = () => { try { parseCard(JSON.parse(r.result)) } catch(err) { alert('JSON error: '+err.message) } }; r.readAsText(f) }
@@ -55,7 +64,7 @@ async function parseCard(json) {
55
64
  const r = await fetch('/api/muv-table/parse-card', { method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify(json) })
56
65
  const d = await r.json(); if (!d.ok) throw new Error(d.error)
57
66
  state = { cardName: d.name, schemas: d.schemas, initvarData: d.initvarData, edits: {}, generatedBlock: '' }
58
- document.getElementById('badge').style.display = 'inline'; document.getElementById('badge').textContent = d.name
67
+ document.getElementById('badge').style.display = 'inline'; document.getElementById('badge').textContent = badgeText(d)
59
68
  document.getElementById('btnGenerate').style.display = 'inline-block'
60
69
  renderTable()
61
70
  } catch(e) { document.getElementById('main').innerHTML = '<div style="padding:12px;color:var(--danger)">⚠ '+e.message+'</div>' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-muv-table",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "MUV 变量表格编辑器 —— 树形变量面板、宏测试浮窗(random/pick/roll)、骰子快捷栏、pick 缓存管理;支持直接读取 SillyTavern PNG 角色卡(含正则脚本、世界书、变量表)。dsh-muv-engine 的伴生插件",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -8,6 +8,8 @@
8
8
  // ④ alternate_greetings(或 entries / scripts)是对象时 for...of 抛 TypeError → 端点 500
9
9
  // ⑤ 扁平 V1 卡(没有 data 包装)能被 findJsonMatch 找到,却被 parseMuvCard 读成 0 字段
10
10
  // ⑥ `键: ""` 空字符串被序列化成裸的 `键: `,再解析回来变成 `{}`(类型静默翻转)
11
+ // ⑦ 世界书里只有注释前缀 `[initvar]`、没有 `<initvar>` 标签的裸 YAML 条目读不到
12
+ // → 该卡 schemas 永远是 0(`异世界农场`);[11] 节连四道闸一起钉住
11
13
  //
12
14
  // 真实卡只在存在时才测(缺失 → SKIP),合成卡负责精确覆盖每条分支。
13
15
 
@@ -343,13 +345,11 @@ if (!fk) {
343
345
  }
344
346
 
345
347
  // ─────────────────────────────────────────────────────────
346
- // 这条是**钉住现状**,不是本次要修的东西:世界书里还有一种「没有 <initvar> 标签、
347
- // 只有注释前缀 [initvar] 的裸 YAML」投递方式(`异世界农场` 的
348
- // `[initvar]变量初始化勿开` 就是它,content 里只有 `时间:`/`种族好感度:` 这样的裸文本)。
349
- // 按标签扫描(③ 要求的那套)确实扫不到它,所以那张卡依然是 schemas: 0。
350
- // 要接住它需要一条**新的**判定规则(例如「comment 以 [initvar] 开头的世界书条目,
351
- // 在彻底找不到变量块时把 content 当 initvar 块解析」),那是另一个决定 —— 见 CHANGELOG。
352
- console.log('\n[10] 真实卡:异世界农场 —— 裸 [initvar] 世界书条目(现状钉住)')
348
+ // 世界书里还有第三种投递方式:**没有 <initvar> 标签**、只有注释前缀 `[initvar]`
349
+ // 的裸 YAML(`异世界农场` 的 `[initvar]变量初始化勿开`,content 里就是
350
+ // `时间:` / `种族好感度:` 这样的裸文本)。按标签扫描永远扫不到它,所以这张卡
351
+ // 以前是 schemas: 0。
352
+ console.log('\n[10] 真实卡:异世界农场 —— 裸 [initvar] 世界书条目(本轮修复)')
353
353
  const farm = findCard('异世界农场')
354
354
  if (!farm) {
355
355
  console.log(' SKIP 找不到 异世界农场')
@@ -358,15 +358,131 @@ if (!farm) {
358
358
  const entry = (card.data.character_book?.entries || [])
359
359
  .find(e => /^\[initvar\]/i.test(String(e?.comment || '')))
360
360
  check('确实存在注释为 [initvar]… 的世界书条目', !!entry, entry?.comment)
361
- check('★那份 content 里没有 <initvar> 标签(所以按标签扫不到)',
361
+ check('那份 content 里没有 <initvar> 标签(所以按标签扫不到,这就是本轮要接的洞)',
362
362
  !!entry && !/<initvar>/i.test(String(entry.content || '')),
363
363
  'content 前 40 字: ' + String(entry?.content || '').slice(0, 40))
364
364
  const parsed = parseMuvCard(card)
365
- check('现状:schemas 仍是 0(这条不在本轮范围内)', parsed.schemas.length === 0, 'schemas=' + parsed.schemas.length)
366
- check('但 Zod 变量结构读得到(上一轮的修复仍然有效)', String(parsed.zodSource || '').length > 100,
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,
367
376
  'zodSource=' + String(parsed.zodSource || '').length)
368
- check('没有产生垃圾 schema', parsed.schemas.length === 0 && Object.keys(parsed.initvarData || {}).length === 0)
369
377
  }
370
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
+
371
487
  console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`)
372
488
  process.exit(fail ? 1 : 0)