dsh-muv-table 0.2.11 → 0.3.0
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 +65 -1
- package/lib/client.js +784 -60
- package/lib/index.js +54 -8
- package/package.json +6 -3
- package/test-card-ops.mjs +152 -0
- package/test-cards.mjs +58 -0
- package/test-preset-resolve.mjs +89 -4
- package/test-var-section.mjs +272 -0
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:
|
|
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
|
-
|
|
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
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-muv-table",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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-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)
|
package/test-cards.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Locate real character cards on this machine for the tests.
|
|
2
|
+
//
|
|
3
|
+
// The search roots mirror `MUV_CARD_DIRS` in lib/index.js so a test finds the
|
|
4
|
+
// same files the plugin would. Nothing here writes to disk; a missing card is a
|
|
5
|
+
// normal outcome (`null`) and callers turn that into a SKIP, never a failure —
|
|
6
|
+
// the tests must stay runnable on a machine without the sample library.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
|
|
12
|
+
/** Directories worth searching for a sample card. */
|
|
13
|
+
export function cardDirs() {
|
|
14
|
+
const roots = [
|
|
15
|
+
'C:/MySpecialFolder/SillyTavern',
|
|
16
|
+
path.join(os.homedir(), 'SillyTavern'),
|
|
17
|
+
path.join(os.homedir(), 'Documents', 'SillyTavern'),
|
|
18
|
+
]
|
|
19
|
+
// Same order index.js uses: explicit download dir, env override, ST, then the
|
|
20
|
+
// usual download/desktop drop zones.
|
|
21
|
+
const out = ['E:/BaiduNetdiskDownload/EdgeDownload']
|
|
22
|
+
for (const d of String(process.env.DSH_MUV_CARD_DIRS || '').split(path.delimiter)) {
|
|
23
|
+
if (d.trim()) out.push(d.trim())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// SillyTavern keeps cards under data/<user>/characters.
|
|
27
|
+
for (const root of roots) {
|
|
28
|
+
const dataDir = path.join(root, 'data')
|
|
29
|
+
if (!fs.existsSync(dataDir)) continue
|
|
30
|
+
try {
|
|
31
|
+
for (const user of fs.readdirSync(dataDir)) {
|
|
32
|
+
const chars = path.join(dataDir, user, 'characters')
|
|
33
|
+
if (fs.existsSync(chars)) out.push(chars)
|
|
34
|
+
}
|
|
35
|
+
} catch (_) {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
out.push(path.join(os.homedir(), 'Downloads'), path.join(os.homedir(), 'Desktop'))
|
|
39
|
+
|
|
40
|
+
return out.filter((d, i, all) => all.indexOf(d) === i)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Find a card file by base name (without extension).
|
|
45
|
+
* @param {string} name - e.g. '苍玄界', '_足控天堂2'
|
|
46
|
+
* @returns {string} absolute path, or '' when not on this machine
|
|
47
|
+
*/
|
|
48
|
+
export function findCard(name) {
|
|
49
|
+
const explicit = process.env.MUV_TEST_CARD_DIR
|
|
50
|
+
const dirs = explicit ? [explicit, ...cardDirs()] : cardDirs()
|
|
51
|
+
for (const dir of dirs) {
|
|
52
|
+
for (const ext of ['.png', '.json']) {
|
|
53
|
+
const p = path.join(dir, name + ext)
|
|
54
|
+
if (fs.existsSync(p)) return p
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return ''
|
|
58
|
+
}
|
package/test-preset-resolve.mjs
CHANGED
|
@@ -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
|
-
|
|
185
|
-
|
|
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('
|
|
188
|
-
r8.body?.presetDir
|
|
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 是对象)走端点')
|