dsh-muv-table 0.2.12 → 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/lib/index.js CHANGED
@@ -664,10 +664,22 @@ export function apply(ctx) {
664
664
  const url = new URL(req.url, 'http://localhost')
665
665
  const presetId = url.searchParams.get('presetId') || ''
666
666
  const sessionId = url.searchParams.get('sessionId') || ''
667
+ // 需要「我就是要这个 preset,别管会话」的调用方显式声明。
668
+ const preferPreset = url.searchParams.get('preferPreset') === '1'
667
669
 
668
- // Resolve the preset: explicit id > the session's bound preset >
670
+ // Resolve the preset: **the session's bound preset > explicit id >**
669
671
  // whichever preset the user is actually in > the tavern's own default.
670
672
  //
673
+ // ★ 顺序为什么会话在前(实测的事故):客户端曾经两个 id 都送、且 presetId 排在前面,
674
+ // 于是"带 presetId 就按 presetId 走"让 sessionId 形同虚设。而 presetId 恰恰是
675
+ // **会粘住**的那个 —— 它来自酒馆面板的 `dataset.presetId` / localStorage,切换会话时
676
+ // 那个值可能仍是上一个会话的预设。用户实测:在「瑟瑟提瓦特」的会话里看到了
677
+ // 「足控天堂」的状态栏界面(串台)。
678
+ //
679
+ // 会话 id 是唯一随会话切换必然变化的依据(服务端按 session-bindings.json 查),
680
+ // 所以它必须优先。确实需要"按 id 强制取某个预设"的调用方(诊断脚本、卡库检查)
681
+ // 显式加 `preferPreset=1` —— 不把它们的语义悄悄改掉。
682
+ //
671
683
  // Every step that missed used to end in `presetDir = TAVERN_PRESET_DIR`
672
684
  // *silently*, so an unknown id (a renamed preset, a stale id cached in
673
685
  // the client, a typo) served that preset's card while the panel said
@@ -681,12 +693,7 @@ export function apply(ctx) {
681
693
  let presetSource = ''
682
694
  let missing = ''
683
695
  let requested = ''
684
- if (presetId) {
685
- requested = String(presetId)
686
- presetDir = resolvePresetDirById(presetId)
687
- if (presetDir) presetSource = 'explicit'
688
- else missing = 'preset not found: ' + requested
689
- } else if (sessionId) {
696
+ const fromSession = () => {
690
697
  const bound = presetIdForSession(sessionId)
691
698
  requested = bound
692
699
  if (!bound || bound === 'default') {
@@ -700,7 +707,32 @@ export function apply(ctx) {
700
707
  else missing = 'preset bound to session ' + sessionId + ' not found: ' + bound
701
708
  }
702
709
  }
703
- if (!presetDir && !missing) {
710
+ const fromExplicit = () => {
711
+ requested = String(presetId)
712
+ presetDir = resolvePresetDirById(presetId)
713
+ if (presetDir) presetSource = 'explicit'
714
+ else missing = 'preset not found: ' + requested
715
+ }
716
+ if (preferPreset && presetId) {
717
+ fromExplicit()
718
+ } else {
719
+ if (sessionId) fromSession()
720
+ if (!presetDir && !missing && presetId) fromExplicit()
721
+ }
722
+ // ★ 这里原来会无条件回落到 `activePresetId()`("最近写入的会话"的绑定)。已收窄。
723
+ //
724
+ // 为什么不能当默认兜底(实测):它的实现是按**会话文件的 mtime** 倒序,取第一个
725
+ // 有绑定的预设。而"最近写入"根本不是"用户正在看的会话" —— 实测当时它返回的是
726
+ // 标题为「安装 dsh-tavern-v2 及其附属插件」的**开发会话**的绑定,因为那类会话在
727
+ // 持续写文件(跑 agent、被试跑的门禁)。于是**每个没带 sessionId 的请求都拿到
728
+ // 同一张卡**,用户看到的就是"基本上每个会话都变成看 X 的界面"(串台)。
729
+ // 它还**不稳定**:同一请求在不同时刻会解析到不同预设。
730
+ //
731
+ // 现在没有会话依据就落到**稳定默认**(tavern-lite),`presetSource` 记 `default`。
732
+ // 面板据此能如实提示"这个会话还没绑卡",而不是悄悄给用户看别人的卡。
733
+ // 确实需要"最近活跃预设"的调用方(诊断脚本)显式加 `preferActive=1` ——
734
+ // 不把"猜"当成静默兜底。
735
+ if (!presetDir && !missing && url.searchParams.get('preferActive') === '1') {
704
736
  const active = activePresetId()
705
737
  if (active) {
706
738
  requested = active
@@ -742,6 +774,16 @@ export function apply(ctx) {
742
774
  cardName,
743
775
  presetDir: path.basename(presetDir),
744
776
  presetSource,
777
+ // ★ 诚实上报(交叉复核发现):调用方**显式**给了 presetId,但被"会话优先"规则
778
+ // 忽略了 —— 会话存在但未绑定 ⇒ `fromSession()` 把 `defaultPresetDir()` 写进了
779
+ // `presetDir`,于是上面那句 `if (!presetDir && !missing && presetId) fromExplicit()`
780
+ // 永远进不去。实测 `?sessionId=<已存在但未绑定>&presetId=A-preset` 会返回
781
+ // tavern-lite,显式 presetId 被**静默吞掉** —— 与被修掉的串台是同一症状类
782
+ // (拿到不是你要的卡,而且不告诉你)。行为保持不变(要强制用它请带
783
+ // `preferPreset=1`),但至少不再沉默。
784
+ ...(presetId && !preferPreset && presetSource === 'session-default'
785
+ ? { note: 'presetId 被忽略:该会话未绑定预设(会话优先)。要强制用它请带 preferPreset=1', ignoredPresetId: presetId }
786
+ : {}),
745
787
  ...parseMuvCard(raw.json),
746
788
  // Consumers read the scripts from either shape; keep both so an
747
789
  // existing reader keeps working while the engine migrates.
@@ -765,6 +807,10 @@ export function apply(ctx) {
765
807
  cardName: fromStore.cardName || cardName,
766
808
  presetDir: path.basename(presetDir),
767
809
  presetSource,
810
+ // 同上:显式 presetId 被会话优先规则忽略时如实说明
811
+ ...(presetId && !preferPreset && presetSource === 'session-default'
812
+ ? { note: 'presetId 被忽略:该会话未绑定预设(会话优先)。要强制用它请带 preferPreset=1', ignoredPresetId: presetId }
813
+ : {}),
768
814
  name: fromStore.name,
769
815
  schemas: fromStore.schemas,
770
816
  initvarData: fromStore.initvarData,
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.2.12",
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,7 +10,7 @@
10
10
  "./lib/*": "./lib/*",
11
11
  "./package.json": "./package.json"
12
12
  },
13
- "files": ["lib", "cordis.patch.yml", "CHANGELOG.md", "README.md", "test-cards.mjs", "test-png-card.mjs", "test-muv-parser.mjs", "test-preset-resolve.mjs"],
13
+ "files": ["lib", "cordis.patch.yml", "CHANGELOG.md", "README.md", "test-cards.mjs", "test-png-card.mjs", "test-muv-parser.mjs", "test-preset-resolve.mjs", "test-card-ops.mjs", "test-var-section.mjs"],
14
14
  "keywords": ["dsh", "dsh-plugin", "deepseek-harness", "muv", "magvarupdate", "tavern", "variable-table"],
15
15
  "license": "PolyForm-Noncommercial-Copyleft-1.0.0",
16
16
  "dsh": {
@@ -18,5 +18,8 @@
18
18
  "client": { "platform": "web" }
19
19
  },
20
20
  "repository": {"type": "git", "url": "git+https://github.com/chen731215-dev/dsh-muv-table.git"},
21
- "engines": { "node": ">=20" }
21
+ "engines": { "node": ">=20" },
22
+ "scripts": {
23
+ "test": "node test-var-section.mjs"
24
+ }
22
25
  }
@@ -0,0 +1,152 @@
1
+ // 卡运维回归:「点『➕ 把当前卡的开场白注入会话末尾』→ 发出去的请求体是什么」。
2
+ //
3
+ // Run: node test-card-ops.mjs
4
+ //
5
+ // 为什么测这个:这条链路唯一的"协议"就是那个 HTTP 请求。面板上一切(成功/已存在/降级)
6
+ // 都是从响应里读出来的,所以**请求体形状**错了,用户看到的现象会非常难查(服务端按
7
+ // sessionId 权威解析预设;带错 presetId 会静默串台到别的卡,见 docs/04 排错手册)。
8
+ //
9
+ // 本用例**逐字提取** lib/client.js 里两端标记之间的那段函数执行 —— 不是复制一份到测试里,
10
+ // 所以被测代码改了它就会跟着变(对照组会因此变红,见 §D)。
11
+ // 因此那段代码有硬约束:不许引用 React / DOM / 外层闭包变量,只用入参 + 全局 fetch。
12
+
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ let pass = 0, fail = 0
18
+ function check(name, cond, detail) {
19
+ if (cond) { pass++; console.log(' OK ' + name) }
20
+ else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
21
+ }
22
+
23
+ const here = path.dirname(fileURLToPath(import.meta.url))
24
+ const CLIENT = path.join(here, 'lib', 'client.js')
25
+ const src = fs.readFileSync(CLIENT, 'utf8')
26
+
27
+ // ── 逐字提取被标记的那段 ─────────────────────────────────────────────
28
+ const BEGIN = '// [[card-ops-greeting-start]]'
29
+ const END = '// [[card-ops-greeting-end]]'
30
+ const i0 = src.indexOf(BEGIN)
31
+ const i1 = src.indexOf(END)
32
+ if (i0 < 0 || i1 < 0 || i1 < i0) {
33
+ console.log('FAIL 在 lib/client.js 里找不到 [[card-ops-greeting-start/end]] 标记')
34
+ process.exit(1)
35
+ }
36
+ const extracted = src.slice(i0 + BEGIN.length, i1).trim()
37
+
38
+ console.log('=== 卡运维 · 开场白注入(逐字提取执行)===\n')
39
+
40
+ // 把提取到的函数体编译出来(声明语句 → 取出函数引用)
41
+ function buildFn(body) {
42
+ // eslint-disable-next-line no-new-func
43
+ return new Function(body + '\nreturn cardOpsInsertGreeting')()
44
+ }
45
+
46
+ // 假的 fetch:记录请求,按脚本回响应
47
+ function mockFetch(responder) {
48
+ const calls = []
49
+ const f = async (url, init) => {
50
+ calls.push({ url, init })
51
+ return responder(url, init)
52
+ }
53
+ f.calls = calls
54
+ return f
55
+ }
56
+ function jsonRes(status, obj) {
57
+ return { status, ok: status >= 200 && status < 300, json: async () => obj }
58
+ }
59
+ // 路由不存在时的真实形态:非 JSON 响应 ⇒ json() 抛错
60
+ function htmlRes(status) {
61
+ return { status, ok: false, json: async () => { throw new SyntaxError('Unexpected token <') } }
62
+ }
63
+
64
+ console.log('[A] 结构不变量(提取段必须自洽,否则"逐字提取"就成了假的)')
65
+ check('提取段含 cardOpsInsertGreeting 声明', /async function cardOpsInsertGreeting\s*\(/.test(extracted))
66
+ check('提取段不引用 React / document / window(否则无法独立执行)',
67
+ !/\bReact\b|\bdocument\b|\bwindow\.__/.test(extracted),
68
+ (extracted.match(/\bReact\b|\bdocument\b|\bwindow\.__/g) || []).join(','))
69
+
70
+ const insetGreeting = buildFn(extracted)
71
+
72
+ console.log('\n[B] 手势 → 请求:有会话 id 时')
73
+ {
74
+ const f = mockFetch(() => jsonRes(200, { ok: true, inserted: true, cardName: '足控天堂', greetingLen: 321, turn: 1 }))
75
+ const r = await insetGreeting({ sessionId: 'session-0123456789abcdef0123456789abcdef', fetch: f })
76
+ check('只发一次请求', f.calls.length === 1, 'calls=' + f.calls.length)
77
+ const c = f.calls[0] || {}
78
+ check('打的是 /api/tavern/greeting/insert', c.url === '/api/tavern/greeting/insert', String(c.url))
79
+ check('方法 POST', c.init && c.init.method === 'POST', String(c.init && c.init.method))
80
+ check('content-type: application/json',
81
+ c.init && c.init.headers && c.init.headers['content-type'] === 'application/json',
82
+ JSON.stringify(c.init && c.init.headers))
83
+ check('请求体**只**含 sessionId(presetId 由服务端按会话权威解析,不许发猜的)',
84
+ c.init && c.init.body === '{"sessionId":"session-0123456789abcdef0123456789abcdef"}',
85
+ String(c.init && c.init.body))
86
+ check('成功 ⇒ kind=ok 且带回卡名与字数',
87
+ r.kind === 'ok' && r.message.includes('足控天堂') && r.message.includes('321'),
88
+ JSON.stringify(r))
89
+ }
90
+
91
+ console.log('\n[C] 手势 → 请求:认不出会话(不带 sessionId)')
92
+ {
93
+ const f = mockFetch(() => jsonRes(200, { ok: true, cardName: 'X', greetingLen: 1, turn: 1 }))
94
+ const r = await insetGreeting({ sessionId: '', fetch: f })
95
+ const c = f.calls[0] || {}
96
+ check('请求体为空对象 {}(undefined 键被 JSON.stringify 丢掉,交给服务端兜底 lastSessionId)',
97
+ c.init && c.init.body === '{}', String(c.init && c.init.body))
98
+ check('仍然 kind=ok(不是本地拦下)', r.kind === 'ok', JSON.stringify(r))
99
+ }
100
+
101
+ console.log('\n[D] 对照臂(必须真变红):把请求体改回"带上猜出来的 presetId"')
102
+ {
103
+ // 函数式 replace(铁律):大段文本替换一律用函数式替换串
104
+ const mutated = extracted.replace(
105
+ 'body: JSON.stringify({ sessionId: sessionId || undefined })',
106
+ () => "body: JSON.stringify({ sessionId: sessionId || undefined, presetId: 'stuck-preset-from-dom' })"
107
+ )
108
+ check('对照臂的替换确实生效(没生效的话下面的断言就是空跑)', mutated !== extracted)
109
+ const bad = buildFn(mutated)
110
+ const f = mockFetch(() => jsonRes(200, { ok: true, cardName: 'X', greetingLen: 1, turn: 1 }))
111
+ await bad({ sessionId: 'session-0123456789abcdef0123456789abcdef', fetch: f })
112
+ const c = f.calls[0] || {}
113
+ const wouldPass = c.init && c.init.body === '{"sessionId":"session-0123456789abcdef0123456789abcdef"}'
114
+ check('带 presetId 的请求体**过不了** [B] 的断言(断言不是永真)',
115
+ !wouldPass, 'body=' + String(c.init && c.init.body))
116
+ }
117
+
118
+ console.log('\n[E] 响应分支:已存在 / 服务端明确原因 / 接口不存在 / 网络不可达')
119
+ {
120
+ const okSpy = mockFetch(() => jsonRes(200, { ok: false, error: 'greeting-already-present' }))
121
+ const r1 = await insetGreeting({ sessionId: 'session-0123456789abcdef0123456789abcdef', fetch: okSpy })
122
+ check('greeting-already-present ⇒ kind=present(蓝色提示,不是报错)',
123
+ r1.kind === 'present' && r1.message.includes('已有开场白'), JSON.stringify(r1))
124
+
125
+ const liveSpy = mockFetch(() => jsonRes(404, { ok: false, error: 'session-not-live:会话 x 当前没有活跃的 Session 对象' }))
126
+ const r2 = await insetGreeting({ sessionId: 'session-0123456789abcdef0123456789abcdef', fetch: liveSpy })
127
+ check('404 + JSON(会话不活跃)⇒ kind=error 且照原样显示服务端原因(不当成"接口不存在")',
128
+ r2.kind === 'error' && r2.message.includes('session-not-live'), JSON.stringify(r2))
129
+
130
+ const missSpy = mockFetch(() => htmlRes(404))
131
+ const r3 = await insetGreeting({ sessionId: 'session-0123456789abcdef0123456789abcdef', fetch: missSpy })
132
+ check('404 + 非 JSON(路由没实现)⇒ kind=degrade,提示更新/重启酒馆插件',
133
+ r3.kind === 'degrade' && r3.message.includes('dsh-tavern-v2'), JSON.stringify(r3))
134
+
135
+ const badSpy = mockFetch(() => { throw new TypeError('Failed to fetch') })
136
+ const r4 = await insetGreeting({ sessionId: 'session-0123456789abcdef0123456789abcdef', fetch: badSpy })
137
+ check('fetch 直接抛(酒馆插件没起)⇒ kind=degrade,不向上抛异常',
138
+ r4.kind === 'degrade' && r4.message.includes('Failed to fetch'), JSON.stringify(r4))
139
+ }
140
+
141
+ console.log('\n[F] 提取段确实来自 lib/client.js 的交付版本(不是测试自带副本)')
142
+ check('标记在文件里各出现一次',
143
+ src.split(BEGIN).length - 1 === 1 && src.split(END).length - 1 === 1)
144
+ check('client.js 里 UI 侧确实调用它(按钮 → 纯函数)',
145
+ src.includes('setGreet(await cardOpsInsertGreeting('), '缺少调用点')
146
+ check('client.js 里有卡运维区块的挂载点与元素标记',
147
+ src.includes("'data-dsh-muv-card-ops'") &&
148
+ src.includes("'data-dsh-card-ops-greeting-insert'") &&
149
+ src.includes("'data-dsh-card-ops-refresh'"))
150
+
151
+ console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`)
152
+ process.exit(fail ? 1 : 0)
@@ -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)
@@ -181,11 +181,96 @@ check('结果可解释:要么 found:false,要么标成 session-default',
181
181
  'found=' + r7.body?.found + ' source=' + r7.body?.presetSource)
182
182
  check('绝不谎称 explicit', r7.body?.presetSource !== 'explicit', r7.body?.presetSource)
183
183
 
184
- console.log('\n[8] 两个定位参数同时给:presetId 优先(客户端只该给一个)')
185
- const r8 = await call('?presetId=real-preset&sessionId=session-bound-default')
184
+ // ── 8) ★ 串台钉子:会话绑定优先于显式 presetId ───────────────
185
+ //
186
+ // 实测事故:客户端两个 id 都送、且 presetId 排在前面,而 presetId 来自酒馆面板的
187
+ // `dataset.presetId` / localStorage —— **切换会话后它可能仍是上一个会话的预设**。
188
+ // 于是用户切到「瑟瑟提瓦特」后,服务端还按「足控天堂」的 presetId 出卡,**串台**。
189
+ //
190
+ // 会话 id 是唯一随会话切换必然变化的依据(服务端按 session-bindings.json 查),必须优先。
191
+ console.log('\n[8] ?sessionId=session-bound-real&presetId=tavern-lite(会话绑定 A + 过期的 presetId B)')
192
+ const r8 = await call('?sessionId=session-bound-real&presetId=tavern-lite')
186
193
  show('返回', r8)
187
- check('用 presetId 那个', r8.body?.presetDir === 'real-preset' && r8.body?.presetSource === 'explicit',
188
- r8.body?.presetDir + '/' + r8.body?.presetSource)
194
+ check('★ 解析出会话绑定的那个(real-preset),不是过期的 presetId',
195
+ r8.body?.presetDir === 'real-preset', r8.body?.presetDir)
196
+ check('★ presetSource=session(一眼能看出这个结果是按会话来的)',
197
+ r8.body?.presetSource === 'session', r8.body?.presetSource)
198
+ check('★ 绝不能是 explicit(那就说明 presetId 又盖掉了会话)',
199
+ r8.body?.presetSource !== 'explicit', r8.body?.presetSource)
200
+
201
+ console.log('\n[8b] ?presetId=tavern-lite(没有 sessionId:原语义必须保持)')
202
+ const r8b = await call('?presetId=tavern-lite')
203
+ show('返回', r8b)
204
+ check('found:true', r8b.body?.found === true, 'found=' + r8b.body?.found)
205
+ check('按 presetId 取到 tavern-lite', r8b.body?.presetDir === 'tavern-lite', r8b.body?.presetDir)
206
+ check('presetSource=explicit(没有会话时它就是显式指定)',
207
+ r8b.body?.presetSource === 'explicit', r8b.body?.presetSource)
208
+
209
+ console.log('\n[8c] ?sessionId=session-bound-real&presetId=tavern-lite&preferPreset=1(显式开关)')
210
+ const r8c = await call('?sessionId=session-bound-real&presetId=tavern-lite&preferPreset=1')
211
+ show('返回', r8c)
212
+ check('★ preferPreset=1 时按 presetId 走(不把需要它的调用方语义悄悄改掉)',
213
+ r8c.body?.presetDir === 'tavern-lite' && r8c.body?.presetSource === 'explicit',
214
+ r8c.body?.presetDir + '/' + r8c.body?.presetSource)
215
+
216
+ console.log('\n[8d] ?sessionId=session-bound-gone&presetId=tavern-lite(会话绑定悬空 + 有 presetId)')
217
+ const r8d = await call('?sessionId=session-bound-gone&presetId=tavern-lite')
218
+ show('返回', r8d)
219
+ check('★ 仍报 found:false:会话绑定的预设不存在时,不许退回用 presetId 静默换一张卡',
220
+ r8d.body?.found === false, 'found=' + r8d.body?.found + ' dir=' + r8d.body?.presetDir)
221
+ check('错误里点名的是会话绑定的那个已消失预设',
222
+ typeof r8d.body?.error === 'string' && r8d.body.error.includes('renamed-away'),
223
+ JSON.stringify(r8d.body?.error))
224
+
225
+ // ── 8e) 串台回归:没有会话依据时**不许**回落到"最近写入的会话"的预设 ──────────
226
+ // 只断言"结果是 default"是**证明不了东西**的:若夹具里不存在"最近写入且有绑定的会话",
227
+ // 旧代码同样会给 default,这条用例就静默失效。所以先**造出**该条件,再**断言条件成立**。
228
+ const sessDir = path.join(tmp, 'storages', 'session_projcache', 'sessions')
229
+ fs.mkdirSync(sessDir, { recursive: true })
230
+ const recentSess = path.join(sessDir, 'session-bound-real.json')
231
+ fs.writeFileSync(recentSess, '{}', 'utf8')
232
+ const _t = Date.now() / 1000
233
+ fs.utimesSync(recentSess, _t, _t + 30) // 把它变成"最近写入"的那个会话文件
234
+ const _bindNow = JSON.parse(fs.readFileSync(path.join(presets, 'session-bindings.json'), 'utf8'))
235
+ check('前置条件:夹具里确实有「最近写入 + 绑定可解析」的会话(否则本用例没有鉴别力)',
236
+ _bindNow['session-bound-real'] === 'real-preset' &&
237
+ fs.existsSync(path.join(presets, 'real-preset')),
238
+ 'bind=' + _bindNow['session-bound-real'])
239
+
240
+ console.log('\n[8e] 无参数(没有会话依据,但存在"最近写入的会话"的绑定)')
241
+ const r8e = await call('')
242
+ show('返回', r8e)
243
+ check('★ 绝不许回落到 active —— 那正是"基本上每个会话都变成看同一张卡"的串台向量',
244
+ r8e.body?.presetSource !== 'active', 'presetSource=' + r8e.body?.presetSource)
245
+ check('落到稳定默认(presetSource=default,而不是别人会话的卡)',
246
+ r8e.body?.presetSource === 'default', r8e.body?.presetSource + ' / ' + r8e.body?.presetDir)
247
+
248
+ // ── 8f) ★ 交叉复核发现:显式 presetId 被"会话优先"规则忽略时**不许静默吞掉** ────────
249
+ //
250
+ // 机制:会话存在但未绑定 ⇒ `fromSession()` 把 `defaultPresetDir()` 写进了 `presetDir`,
251
+ // 于是后面那句 `if (!presetDir && !missing && presetId) fromExplicit()` **永远进不去**。
252
+ // 于是 `?sessionId=<未绑定>&presetId=A` 会返回 tavern-lite,**显式给的 A 被静默丢弃** ——
253
+ // 与被修掉的串台是同一症状类:拿到不是你要的卡,而且不告诉你。
254
+ //
255
+ // 行为保持不变(要强制用 A 请带 `preferPreset=1`,见 [8c]),但必须在响应里说明。
256
+ // 这里故意让 presetId 指向一个**真实存在且与结果不同**的预设(real-preset vs tavern-lite),
257
+ // 这样"它确实没生效"和"我们如实说了"两件事能同时被钉住。
258
+ console.log('\n[8f] ?sessionId=session-unbound&presetId=real-preset(会话未绑定 + 显式 presetId)')
259
+ const r8f = await call('?sessionId=session-unbound&presetId=real-preset')
260
+ show('返回', r8f)
261
+ check('会话优先:落在 tavern-lite,显式 presetId 未生效',
262
+ r8f.body?.presetDir === 'tavern-lite', 'presetDir=' + r8f.body?.presetDir)
263
+ check('来源如实标 session-default(不是谎称 explicit)',
264
+ r8f.body?.presetSource === 'session-default', r8f.body?.presetSource)
265
+ check('★ 不许静默吞掉:note 里说明 presetId 被忽略、并指出逃生门 preferPreset=1',
266
+ typeof r8f.body?.note === 'string' && r8f.body.note.includes('preferPreset'),
267
+ JSON.stringify(r8f.body?.note))
268
+ check('★ 并点名被忽略的是哪个 presetId(调用方能自查)',
269
+ r8f.body?.ignoredPresetId === 'real-preset', 'ignoredPresetId=' + r8f.body?.ignoredPresetId)
270
+ check('反向:带 preferPreset=1 时它**必须**生效(逃生门真的可用,不是空话)',
271
+ (await call('?sessionId=session-unbound&presetId=real-preset&preferPreset=1')).body?.presetDir === 'real-preset')
272
+ check('反向:没有显式 presetId 时不该出现 note(避免噪音字段)',
273
+ (await call('?sessionId=session-unbound')).body?.note === undefined)
189
274
 
190
275
  // ── 9) 畸形卡不再 500(④ 的端点面) ────────────────────────
191
276
  console.log('\n[9] 畸形卡(alternate_greetings 是对象)走端点')