dsh-muv-table 0.3.0 → 0.3.1

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,43 @@
1
1
  # Changelog
2
2
 
3
- ## 未发布
3
+ ## v0.3.1 (2026-10-12)
4
+
5
+ ### 🐛 修复:世界书 `[initvar]` 条目里装的是 **JSON**(不是 YAML)时,整棵变量树读不出来
6
+
7
+ 用户口径:**「这个黄金庭院的卡的状态栏老是这样基本上是空白」**(状态栏三项恒显示「未知」,角色网格一个角色都没有)
8
+
9
+ - 现象离病因很远。那张卡自带的状态栏取值是 `_.get(sys, '日期', ['未知'])[0]`,「未知」是模板的
10
+ 兜底值 —— 说明脚本**跑到了**,只是喂给它的变量树是空的。
11
+ - 真因:`黄金庭院-昔涟` 把整棵树(`系统.日期/时间/地点` + 全体角色档案)按 MUV 的 `$meta` 约定
12
+ 写在一个 `[InitVar]` 世界书条目里,**内容是从 `{` 开始的 JSON**。这条路此前只认三种形态
13
+ (`<initvar>` 标签 / `<VariableInsert>` / 裸 **YAML**),JSON 在最后那条兜底上被
14
+ `looksLikeInitvarMapping` 判「不像变量映射」否决(`{` / `},` / `}` 不匹配 `键: 值`),
15
+ 于是 `schemaSource = 'none'`、`initvarData = {}`。(同样不能靠喂给 YAML 解析器救:
16
+ 那会儿只得到 `"系统": "{"` 这种垃圾键。)
17
+ - 修法:新增 `parseJsonInitvarMapping()`,在 YAML 闸门**之前**先试 JSON —— 顺序必须如此,
18
+ 反过来 JSON 永远进不来(两种形态互斥,不存在抢数据的问题)。认可是**结构性**的:
19
+ `JSON.parse` 成功且结果是纯对象才采用,散文 / 半截 JSON / 顶层数组统统退回 `none`,
20
+ 所以"带大括号的说明文档"混不进来。
21
+ - **`$meta` 被剔除**:它是 MVU 对变量树的控制元数据、不是变量 —— 留着会在面板里多出两个假字段
22
+ (`extensible` 读成字符串 `"false"`),还会跟着写进生成的块。
23
+ - 叶子保留 `[值, 说明]` 的**数组**形态(状态栏取 `[0]`,这是 MVU 的写法)。
24
+ - 新来源标 `schemaSource: 'worldbook-initvar-json'`,`panel.html` 的徽标同步加了标签 —— 它和
25
+ `worldbook-initvar` 一样是**启发式**来源,必须可见,否则"猜错"和"这卡确实没变量"长得一样。
26
+
27
+ ### ✅ 测试:`test-muv-parser.mjs` [12] 节
28
+
29
+ 先量它有多能咬:**换上修复前的解析器跑同一份测试 → 10 条红**,含真实卡那条
30
+ (「黄金庭院:不再读成 none」→ `source=none`);修好后全绿(`80 通过 / 1 失败`,那 1 条是本机
31
+ REAL 卡样本不足导致的**既有红**,改动前后读数一致)。除了分支覆盖,还钉了三件容易空转的事:
32
+
33
+ - **`$meta` 断言先钉反例**:先断言源 JSON 里**确实有** `$meta`,再看它没进结果 —— 否则在树本来就空时这条恒真。
34
+ - **卡片是不可信输入**:JSON 里的 `__proto__` 会被当普通键解析出来,若写成 `vars[key] =` 赋值会真的改写对象原型。突变回朴素赋值后该断言实测变红(`polluted=1`);把剔 `$meta` 那行删掉则 4 条红,现场可见 `$meta.extensible` 混进 schema。
35
+ - **UI 契约**:断言 `panel.html` 的 `SOURCE_LABEL` 覆盖 `schemaSource` 的每个取值 —— 漏一个是界面上渲染出 `undefined`,而不是报错。
36
+
37
+ ## v0.3.0 (2026-09-25) —— 补记
38
+
39
+ > 本节正文的代码实际已随 v0.3.0 发布(已核对 npm 上 0.3.0 的 tarball,`lib/client.js` 含 `card-ops-greeting-start`),
40
+ > 但当时 CHANGELOG 一直挂在「未发布」标题下没定型,现在移出并补记到本节。
4
41
 
5
42
  ### ✨ 新增:面板内「🧰 卡运维」区块(开场白注入 + 卡与脚本状态)
6
43
 
package/lib/muv-parser.js CHANGED
@@ -127,6 +127,58 @@ function looksLikeInitvarMapping(text) {
127
127
  return keys > 0
128
128
  }
129
129
 
130
+ /**
131
+ * A `[initvar]` world-book entry whose content is **JSON** rather than bare YAML.
132
+ *
133
+ * `黄金庭院-昔涟` keeps its entire tree that way, and it is not exotic — it is what
134
+ * MUV's own `$meta` convention looks like when written out:
135
+ *
136
+ * {
137
+ * "$meta": { "extensible": false, "strictSet": true },
138
+ * "系统": { "日期": ["2025年04月10日", "格式为YYYY年MM月DD日"], … },
139
+ * "昔涟": { "好感度": ["50", "0-100"], … }
140
+ * }
141
+ *
142
+ * This cannot go through `parseInitvar`: that is a YAML parser, so JSON comes back
143
+ * as junk keys (`"系统"` reads as the string `"{"`), and "it parsed" was never
144
+ * evidence to begin with. JSON is instead recognised **structurally** — `JSON.parse`
145
+ * must succeed *and* yield a plain object. Prose, a broken brace, or a top-level
146
+ * array are all rejected, so a `[initvar]`-marked documentation entry that merely
147
+ * quotes some braces cannot sneak in as a variable tree.
148
+ *
149
+ * `$meta` is MVU's control metadata for the tree, not a variable: left in, it would
150
+ * surface in the panel as two bogus fields (`extensible` reading as the string
151
+ * "false") and round-trip into any block generated from it. Dropped here — nothing
152
+ * reads it today. List values are kept **as lists**, because a leaf is written
153
+ * `[值, 说明]` and the renderer takes `[0]`.
154
+ *
155
+ * @param {string} text - the raw world-book entry content
156
+ * @returns {object|null} the variable tree, or null when this is not JSON data
157
+ */
158
+ function parseJsonInitvarMapping(text) {
159
+ const trimmed = String(text ?? '').trim()
160
+ if (!trimmed.startsWith('{')) return null
161
+
162
+ let parsed
163
+ try { parsed = JSON.parse(trimmed) } catch (_) { return null }
164
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
165
+
166
+ const vars = {}
167
+ let kept = 0
168
+ for (const key of Object.keys(parsed)) {
169
+ if (key === '$meta') continue
170
+ // defineProperty rather than `vars[key] =`: JSON.parse accepts `__proto__` as
171
+ // an ordinary key, and assigning it would genuinely rewrite this object's
172
+ // prototype. Card content is untrusted input.
173
+ Object.defineProperty(vars, key, {
174
+ value: parsed[key], enumerable: true, writable: true, configurable: true
175
+ })
176
+ kept++
177
+ }
178
+ // A tree made only of `$meta` declares no variables; that stays a rejection.
179
+ return kept > 0 ? vars : null
180
+ }
181
+
130
182
  /** The first `<initvar>` block in a list of texts, or null. */
131
183
  function firstInitvarBlock(texts) {
132
184
  for (const text of texts) {
@@ -205,9 +257,10 @@ export function extractVariableInsert(data) {
205
257
  * Parse a MUV character card JSON and extract schema + variable data.
206
258
  * @param {object} cardJson - The full character card JSON
207
259
  * @returns {{ name: string, schemas: object[], initvarData: object, zodSource: string,
208
- * schemaSource: 'initvar'|'variable-insert'|'worldbook-initvar'|'none' }}
260
+ * schemaSource: 'initvar'|'variable-insert'|'worldbook-initvar'|'worldbook-initvar-json'|'none' }}
209
261
  * `schemaSource` names the source the variable tree was read from ('none' when the
210
262
  * card carries none), so a reader can tell a real tree from the heuristic fallback.
263
+ * '-json' means the `[initvar]` world-book entry held JSON instead of bare YAML.
211
264
  */
212
265
  export function parseMuvCard(cardJson) {
213
266
  const data = cardData(cardJson)
@@ -281,8 +334,11 @@ export function parseMuvCard(cardJson) {
281
334
  }
282
335
  }
283
336
 
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:
337
+ // Last of the last resorts: a world-book entry marked `[initvar]…` whose content has
338
+ // no tag at all (see worldbookInitvarEntries). Two content shapes are recognised here:
339
+ // bare **YAML** — every card this fallback was built for — and **JSON** written with
340
+ // MUV's `$meta` convention (`黄金庭院-昔涟`; see parseJsonInitvarMapping). Both answer
341
+ // to the same four gates:
286
342
  //
287
343
  // ① only after every source above came up empty — a card that already parses from
288
344
  // a greeting must not change where it reads from (native `<initvar>` wins);
@@ -302,6 +358,17 @@ export function parseMuvCard(cardJson) {
302
358
  // marker plus gate ② — a disabled entry *without* that marker is never read.
303
359
  if (initvarRaw.length === 0) {
304
360
  for (const entry of worldbookInitvarEntries(data)) {
361
+ // JSON first — order matters. Seen through the YAML gate below, a JSON tree is
362
+ // just rows that do not read as `键: 值` (`{`, `},`, `}` …), so
363
+ // looksLikeInitvarMapping rejects it outright and the JSON branch would never
364
+ // run. The two shapes are mutually exclusive, never in competition for data.
365
+ const asJson = parseJsonInitvarMapping(entry.content)
366
+ if (asJson) {
367
+ initvarData = asJson
368
+ initvarRaw = [String(entry.content).trim()]
369
+ schemaSource = 'worldbook-initvar-json'
370
+ break
371
+ }
305
372
  if (!looksLikeInitvarMapping(entry.content)) continue
306
373
  const parsed = parseInitvar(entry.content)
307
374
  if (!parsed || typeof parsed !== 'object' || Object.keys(parsed).length === 0) continue
package/lib/panel.html CHANGED
@@ -46,11 +46,13 @@ 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': '' }
49
+ // Where the variable tree was read from, shown next to the card name. The two
50
+ // `worldbook-initvar*` entries are *heuristic* sources (a world-book entry marked
51
+ // `[initvar]`, no tag, holding bare YAML or JSON), so they have to be visible —
52
+ // otherwise a wrong guess looks exactly like a card that genuinely declares its
53
+ // variables. Every value `parseMuvCard` can emit must have a label here; a missing
54
+ // one would render "undefined" instead of failing loudly (checked in test-muv-parser).
55
+ const SOURCE_LABEL = { 'initvar': '<initvar> 块', 'variable-insert': '<VariableInsert>', 'worldbook-initvar': '世界书 [initvar] 条目', 'worldbook-initvar-json': '世界书 [initvar] 条目 (JSON)', 'none': '' }
54
56
  function badgeText(d) {
55
57
  const label = SOURCE_LABEL[d.schemaSource]
56
58
  return d.name + (label ? ' · ' + label : '')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-muv-table",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "MUV 变量表格编辑器 —— 树形变量面板、宏测试浮窗(random/pick/roll)、骰子快捷栏、pick 缓存管理;支持直接读取 SillyTavern PNG 角色卡(含正则脚本、世界书、变量表)。dsh-muv-engine 的伴生插件",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -10,6 +10,8 @@
10
10
  // ⑥ `键: ""` 空字符串被序列化成裸的 `键: `,再解析回来变成 `{}`(类型静默翻转)
11
11
  // ⑦ 世界书里只有注释前缀 `[initvar]`、没有 `<initvar>` 标签的裸 YAML 条目读不到
12
12
  // → 该卡 schemas 永远是 0(`异世界农场`);[11] 节连四道闸一起钉住
13
+ // ⑧ 同一个 `[initvar]` 条目装的是 **JSON** 而不是 YAML(`黄金庭院-昔涟`)→ 整棵变量树
14
+ // 读不出来,状态栏 `_.get(sys,'日期',['未知'])[0]` 恒走兜底;[12] 节
13
15
  //
14
16
  // 真实卡只在存在时才测(缺失 → SKIP),合成卡负责精确覆盖每条分支。
15
17
 
@@ -484,5 +486,124 @@ check('★ disabled 且没有 [initvar] 标记的条目永远不读',
484
486
  data: { name: 'dis2', first_mes: '没变量', character_book: { entries: [{ comment: '变量初始化', content: '甲: 1', enabled: false }] } },
485
487
  }).schemaSource === 'none')
486
488
 
489
+ // ─────────────────────────────────────────────────────────
490
+ // ⑧ 世界书 `[initvar]` 条目里装的是 **JSON** 而不是 YAML(`黄金庭院-昔涟` 就是这种)。
491
+ //
492
+ // 病灶现象在 UI 上很远:卡自带的状态栏恒为「未知」、角色网格一个角色都没有。真因是
493
+ // 那张卡把整棵变量树(`系统.日期/时间/地点` + 全体角色档案)以 MUV 的 `$meta` 约定
494
+ // 写在一个 `[InitVar]` 世界书条目里,内容是从 `{` 开始的 JSON;走到 YAML 闸门时被判
495
+ // 「不像变量映射」否决(`{` / `},` / `}` 不是 `键: 值`),于是 schemaSource 落到
496
+ // 'none'、`initvarData` 是空对象 —— 状态栏的 `_.get(sys,'日期',['未知'])[0]` 永远只能
497
+ // 拿到兜底值,跟运行时有没有变量无关。
498
+ console.log('\n[12] 世界书 [initvar] 条目装的是 JSON(黄金庭院-昔涟)')
499
+
500
+ // 形状照真实卡:$meta 是 MVU 的控制元数据;叶子写成 [值, 说明] 的数组。
501
+ const JSON_TREE = JSON.stringify({
502
+ '$meta': { extensible: false, strictSet: true },
503
+ '系统': {
504
+ '日期': ['2025年04月10日', '格式为YYYY年MM月DD日'],
505
+ '时间': ['09:00', '格式为hh:mm'],
506
+ '地点': ['黄金庭院-客厅', '当前主要场景地点'],
507
+ },
508
+ '昔涟': { '好感度': ['50', '0-100'] },
509
+ }, null, 2)
510
+
511
+ /** 一张世界书装了给定内容的合成卡;first_mes 留给「更高优先级来源」的用例用。 */
512
+ function cardWithWbJson(content, firstMes = '没变量') {
513
+ return parseMuvCard({
514
+ data: {
515
+ name: 'wbjson',
516
+ first_mes: firstMes,
517
+ // enabled:false 是作者的约定(把变量树关掉免得注入上下文),必须照样能读。
518
+ character_book: { entries: [{ comment: '[InitVar]', content, enabled: false }] },
519
+ },
520
+ })
521
+ }
522
+
523
+ const j1 = cardWithWbJson(JSON_TREE)
524
+ check('★ JSON 变量树被读出来源 = worldbook-initvar-json',
525
+ j1.schemaSource === 'worldbook-initvar-json', 'source=' + j1.schemaSource)
526
+ check('系统.日期/时间/地点 都在,且保留 [值, 说明] 数组形态',
527
+ at(j1.initvarData, '系统.日期')?.[0] === '2025年04月10日' &&
528
+ at(j1.initvarData, '系统.时间')?.[0] === '09:00' &&
529
+ at(j1.initvarData, '系统.地点')?.[0] === '黄金庭院-客厅',
530
+ JSON.stringify(at(j1.initvarData, '系统')))
531
+ check('★ 状态栏口径不再走兜底:`(_.get(sys,"日期")||["未知"])[0]`',
532
+ (at(j1.initvarData, '系统.日期') || ['未知'])[0] === '2025年04月10日',
533
+ '取到=' + JSON.stringify((at(j1.initvarData, '系统.日期') || ['未知'])[0]))
534
+ // 「$meta 不在结果里」这种断言空转时会恒真(树本来就是空的也说没有 $meta),所以
535
+ // 先把反例钉住:源 JSON **确实带** $meta(这里也兜住 JSON_TREE 本身被改坏的情况),
536
+ // 再断言它没进结果 —— 这样它量的是「被筛掉」,而不是「压根没喂进来」。
537
+ check('★ 源 JSON 里确实有 $meta,且没进变量树 / schema',
538
+ Object.keys(JSON.parse(JSON_TREE)).includes('$meta') &&
539
+ !Object.keys(j1.initvarData).includes('$meta') && !j1.schemas.some(s => s.name === '$meta'),
540
+ '源 keys=' + Object.keys(JSON.parse(JSON_TREE)).join(',') + ' | 结果 keys=' + Object.keys(j1.initvarData).join(','))
541
+ check('schema 建起来了(系统 + 昔涟)',
542
+ j1.schemas.length === 2 && fieldAt(j1.schemas, '系统.日期') !== undefined,
543
+ 'schemas=' + j1.schemas.length + ' leaves=' + leafPaths(j1.schemas).join(','))
544
+
545
+ // 闸① 这条兜底只在其它来源全空时启用 —— 能解析的卡不许换数据源
546
+ check('闸① 问候语里的 <initvar> 优先于 JSON 世界书条目',
547
+ cardWithWbJson(JSON_TREE, '<initvar>\n名字: 问候语里的\n</initvar>').schemaSource === 'initvar')
548
+ check('闸① <VariableInsert> 也优先于 JSON 世界书条目',
549
+ cardWithWbJson(JSON_TREE, '<VariableInsert>{"问候语里的":1}</VariableInsert>').schemaSource === 'variable-insert')
550
+
551
+ // 闸② 形状判定:必须**真的读到 JSON 数据**才采用。凡是进不了 JSON.parse 或不是对象的,
552
+ // 一律退回 none —— 带着半句话就说 { 的文档条目不能混进来。
553
+ const yamlCase = 'YAML 照旧走 YAML 分支'
554
+ for (const [label, content] of [
555
+ ['破碎的 JSON(括号没闭合)', '{\n "系统": {\n "日期": ["x"]\n'],
556
+ ['顶层是数组的 JSON', '[{"系统":{"日期":["x"]}}]'],
557
+ ['只有 $meta 的 JSON(等于没变量)', '{"$meta":{"extensible":false}}'],
558
+ ['以 { 开头但不是 JSON 的散文', '{ 变量初始化格式说明:请在回复结尾输出 JSONPatch }'],
559
+ ['null / 标量 JSON', '123'],
560
+ [yamlCase, '甲: 1\n乙:\n 丙: 2'],
561
+ ]) {
562
+ const r = cardWithWbJson(content)
563
+ const ok = label === yamlCase
564
+ ? (r.schemaSource === 'worldbook-initvar' && at(r.initvarData, '乙.丙') === 2)
565
+ : (r.schemaSource === 'none' && Object.keys(r.initvarData).length === 0)
566
+ check('闸② ' + label + ' -> ' + (label === yamlCase ? 'worldbook-initvar' : '不采用'),
567
+ ok, 'source=' + r.schemaSource + ' data=' + JSON.stringify(r.initvarData))
568
+ }
569
+ check('前后有 BOM/空白的 JSON 仍读得出(判定用 trim 后的内容)',
570
+ cardWithWbJson('\n\n ' + JSON_TREE + '\n\n').schemaSource === 'worldbook-initvar-json')
571
+
572
+ // ★ 卡片内容是**不可信输入**:JSON.parse 允许 `__proto__` 当普通键,若解析用
573
+ // `vars[key] = value` 赋值,这条就会真的改写对象原型。这条断言在赋值写法下必红。
574
+ const j2 = cardWithWbJson('{"__proto__":{"polluted":1},"系统":{"日期":["2025年04月10日"]}}')
575
+ check('★ JSON 里的 __proto__ 只是普通键,不改写原型',
576
+ Object.getPrototypeOf(j2.initvarData) === Object.prototype && j2.initvarData.polluted === undefined &&
577
+ at(j2.initvarData, '系统.日期')?.[0] === '2025年04月10日',
578
+ 'proto改了吗=' + (Object.getPrototypeOf(j2.initvarData) !== Object.prototype) +
579
+ ' polluted=' + j2.initvarData.polluted + ' data=' + JSON.stringify(j2.initvarData))
580
+
581
+ // ★ UI 契约:schemaSource 的每个取值都要在 panel.html 的 SOURCE_LABEL 里有对应标签,
582
+ // 漏一个就是界面上渲染出 "undefined"(顺便:这也让「返回值的取值域」有了权威清单)。
583
+ const panelHtml = fs.readFileSync(new URL('./lib/panel.html', import.meta.url), 'utf8')
584
+ const labelBody = /const SOURCE_LABEL = \{([^}]*)\}/.exec(panelHtml)?.[1] ?? ''
585
+ const labelled = new Set([...labelBody.matchAll(/'([^']*)'\s*:/g)].map(m => m[1]))
586
+ const EMITTED = ['initvar', 'variable-insert', 'worldbook-initvar', 'worldbook-initvar-json', 'none']
587
+ const missingLabel = EMITTED.filter(v => !labelled.has(v))
588
+ check('★ panel.html 的 SOURCE_LABEL 覆盖所有 schemaSource 取值',
589
+ missingLabel.length === 0 && labelled.size > 0,
590
+ '漏: ' + missingLabel.join(',') + ' | 现有标签=' + [...labelled].join(','))
591
+
592
+ // 真实卡:这条断言能在修复前变红 —— 它量的是「病因」本身,不是中间过程。
593
+ const golden = findCard('黄金庭院')
594
+ if (!golden) {
595
+ console.log(' SKIP 找不到 黄金庭院(期望 ~/Downloads/黄金庭院.png)')
596
+ } else {
597
+ const g = parseMuvCard(readPngCard(golden))
598
+ check('★ 真实卡黄金庭院:不再读成 none',
599
+ g.schemaSource === 'worldbook-initvar-json', 'source=' + g.schemaSource)
600
+ check('状态栏三个字段都拿到真实值',
601
+ ['日期', '时间', '地点'].every(k => String(at(g.initvarData, '系统.' + k)?.[0] || '').length > 0),
602
+ JSON.stringify(at(g.initvarData, '系统')))
603
+ check('角色档案进了变量树(昔涟)',
604
+ at(g.initvarData, '昔涟.好感度') !== undefined, 'keys=' + Object.keys(g.initvarData).slice(0, 8).join(','))
605
+ check('$meta 没混进 schema', !g.schemas.some(s => s.name === '$meta'))
606
+ }
607
+
487
608
  console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`)
488
609
  process.exit(fail ? 1 : 0)