dsh-pet-live2d 2.3.2 → 2.3.3

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,5 +1,72 @@
1
1
  # 更新日志
2
2
 
3
+ ## 2.3.3
4
+
5
+ ### 修:互动反应补上**内置默认值** —— 宠物没声明那三组也演得出来
6
+
7
+ 「摸头 / 摸尾巴 / 转圈」演什么,之前**只**认宠物 `pet.json` 里的 `patReactions` /
8
+ `tailReactions` / `spinReactions`,插件代码里一个默认值都没有。于是**自己做的宠物**
9
+ (最小 `pet.json` 里根本没有这三个键)上:
10
+
11
+ | 互动 | 以前 | 现在(没声明时) |
12
+ |---|---|---|
13
+ | 摸头 | 只有台词,**不演重锤出击** | 重锤出击 / 问号 / 星星眼 随机一个 |
14
+ | 摸尾巴 | 只有台词 | 吐魂 / 问号 |
15
+ | 转圈转晕 | 演不出来 | 晕晕 |
16
+
17
+ 兜底顺序和台词、摸鱼池是同一套:**用户改过的 <- 宠物声明的 <- 内置默认**。
18
+ 宠物自己声明了就一直用宠物那份(这只鲸鱼娘一字未变),只是"没写"不再等于"没反应"。
19
+
20
+ 设置页「互动」卡里那三组候选现在显示的就是**有效那一组**(和运行时会演的完全一致),
21
+ 行尾会标明来历:`(宠物默认)` / `(内置默认)` / `(已改过)` —— 看到的是内置值时
22
+ 你就知道"改宠物也能改这一组"。
23
+
24
+ ### 修:随包宠物的默认值**终于能到老用户手里**(原来装过一次就再也不会更新)
25
+
26
+ `installBundledPets()` 以前是"目标目录存在就跳过"。插件目录随 `dsh plugin add` / npm
27
+ 更新,用户的宠物目录 `%DSH_HOME%\pets\<id>\` **不会** —— 于是宠物**自己的默认值永远停在
28
+ 装它的那一天**:2.3.0 往 `pet.json` 里加的那批(台词 / 互动反应候选 / 摸鱼槽位 /
29
+ `asking`·`helper`·`queued` 三个相位、自拍等三个槽位)一个都没到过桌面。
30
+
31
+ 症状和你这次报的一模一样,而且**看起来完全像插件的 bug**:互动只有台词不演反应、
32
+ 相位台词一句不弹、装扮里少三个槽位、`会话相位` 列表里少三个状态。
33
+
34
+ 现在按**内容**判定,三条:
35
+
36
+ | 宠物目录里那份 | 行为 |
37
+ |---|---|
38
+ | 不存在 | 装一份,并把版本与内容指纹记进 `pets/.synced.json` |
39
+ | 就是我们装下去的那一份(指纹逐字节相同) | 整份更新到随包版本 |
40
+ | 你自己改过(指纹对不上) | **一个字都不碰**(日志里会说一句"跳过更新") |
41
+
42
+ 冷启动(同步记录出现之前装的副本,也就是所有老装机)靠一张**历史内容指纹表**认人:
43
+ 与历史上任何一次随包分发逐字节相同才升级 —— 改过名字、槽位、任何一个字,哈希就不同,
44
+ 所以"用户自己改过的宠物永远优先"这条没有被削弱。
45
+
46
+ 升级会在宿主日志里说清楚:
47
+
48
+ ```
49
+ [live2d-pet] ds-whale-girl:宠物默认值更新到 1.1.0(原先 1.0.1)
50
+ ```
51
+
52
+ > 顺带把随包宠物的版本抬到 **1.1.0**。升级判定只看内容指纹、不看版本号 ——
53
+ > 同一个版本号下补默认值也能发出去(这次就是)。
54
+
55
+ ### 修:互动候选「改不动」的隐患
56
+
57
+ `PHASE_OVERRIDES`(设置覆盖的存放处)初始化时**漏了 `interactions` 这个键**,而
58
+ `applyOverride()` 是直接往这个对象上写的 —— 也就是说第一次点候选时覆盖会被静默丢掉。
59
+ 读的地方都写了 `?.`,所以它一直"读得出来、写不进去",只有正面写它的设置界面会踩到。
60
+
61
+ 现在五个键(`phases` / `fidget` / `relations` / `interactions` / `lines`)都在初始化时给出来,
62
+ 并加了一条断言盯着"点一下候选 → 覆盖真的落盘"。
63
+
64
+ ### 诊断:新增 `reactionDiagnostics()`
65
+
66
+ 三组候选各自的**来历**(`user` / `pet` / `builtin` / `none`)、内置默认值、以及最终生效值。
67
+ "点了她没反应"这类问题一眼就能分出是"用户覆盖成了空数组"、"宠物没声明"还是"连兜底都没了"
68
+ —— 只看 `effectiveReactions()` 是分不出来的。
69
+
3
70
  ## 2.3.2
4
71
 
5
72
  ### 修:摸尾巴又摸不到了(这次是真的)
package/README.md CHANGED
@@ -3,7 +3,12 @@
3
3
  给 **DSH**(DeepSeek Harness)Web GUI 用的 **Live2D 桌宠插件**:一只可以拖动、跟着鼠标看、
4
4
  点她会害羞、还会跟着会话状态换动作的桌宠。
5
5
 
6
- 自带 **DS鲸鱼娘**(8 组动作 + 44 个表情/道具),开箱即用。更新日志见 [CHANGELOG.md](CHANGELOG.md)。
6
+ 自带 **DS鲸鱼娘**(8 组动作 + 44 个表情/道具),开箱即用。
7
+
8
+ > **宠物会跟着插件一起更新**:随包的那只(以及它的默认设置:台词、互动反应、
9
+ > 会话相位、摸鱼槽位)在插件升级时**自动同步**到 `%DSH_HOME%\pets\ds-whale-girl\`。
10
+ > 你自己改过的那份**不会被覆盖**(判定是内容指纹,改过就一个字都不碰),
11
+ > 想拿回随包版本就把目录删掉、重启 `dsh web`,它会重新装一份。更新日志见 [CHANGELOG.md](CHANGELOG.md)。
7
12
 
8
13
  ![桌宠](docs/preview.png)
9
14
 
@@ -131,6 +136,11 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
131
136
  - 转圈看的是**累计转角**(不是位置绕了几圈),所以贴着角色转小圈、远远转大圈都算。
132
137
  - 三个互动各自可开关,反应也能改(见下)。共用的三句台词在「气泡」设置里。
133
138
 
139
+ > 上表那三组是**默认反应**,来源分两层:宠物 `pet.json` 里声明了就用它的;**没声明就用
140
+ > 插件内置的这几个**(重锤出击 / 问号 / 星星眼、吐魂 / 问号、晕晕)—— 自己做的宠物不写
141
+ > 这三个键也有反应。设置页「互动」卡的行尾会标明当前用的是哪一层(宠物默认 / 内置默认 /
142
+ > 已改过),候选勾选本来就是最高优先级。
143
+
134
144
  ### 气泡:说什么、在哪、要不要
135
145
 
136
146
  她说话时会弹一个小气泡(问候、点击、摸头、摸尾巴、转晕、归位,以及**每个会话相位一句**:
@@ -237,6 +247,11 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
237
247
  "looksByPhase": { "done": { "whale": "头顶鲸", "mood": "开心兴奋" } },
238
248
  // 摸鱼默认盯哪些槽位(用户还能再加;不写就是"手/情绪/脸红/嘴/眼"那五个兜底)
239
249
  "fidgetSlots": ["rhand", "lhand", "mood", "cheek", "mouth", "eyes", "selfie"],
250
+ // 互动反应候选(标签 = 动作的中文名或表情名)。**三个键都可以不写** ——
251
+ // 不写就用插件内置的:摸头 重锤出击/问号/星星眼、摸尾巴 吐魂/问号、转晕 晕晕
252
+ "patReactions": ["重锤出击", "问号", "星星眼"],
253
+ "tailReactions": ["吐魂", "问号"],
254
+ "spinReactions": ["晕晕"],
240
255
  // 可选;模型自己表达不了的「作者意图」
241
256
  "motionOptions": {
242
257
  "OpenCase": { "hold": true },
package/lib/client.js CHANGED
@@ -2841,8 +2841,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2841
2841
  * fidgetNone 与各选项的 fidgetWeight);这里只放用户改过的部分,
2842
2842
  * 键都按名字存,换宠物时对不上的覆盖会被忽略(和装扮存档同一套思路)。
2843
2843
  *
2844
- * phases: { <相位>: { motion: 组名|null, expression: 表情名|null } }
2845
- * fidget: { <槽位>: { none: 权重, options: { <选项标签>: 权重 } } }
2844
+ * phases: { <相位>: { pools: { <槽位>: 条目表 } } }
2845
+ * fidget: { <槽位>: { entries: 条目表 } }
2846
+ * relations: { "<槽位>:<标签>": { pairs, requires } }
2847
+ * interactions: { <patReactions|tailReactions|spinReactions>: [标签] }
2848
+ * lines: { <键>: [台词] , phase: { <相位>: 台词 } }
2846
2849
  */
2847
2850
  /** 当前宠物的清单,给设置界面用(DSH 设置页拿不到组件里的 pet)。 */
2848
2851
  const MANIFEST = { current: null };
@@ -2857,7 +2860,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2857
2860
  * 姿势本身的性质 —— 在摸鱼表里改它,右键面板点同一个姿势、相位池里抽到它,
2858
2861
  * 行为必须一致。所以它单独存一层,而不是挂在条目上。
2859
2862
  */
2860
- const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, lines: {} };
2863
+ // 五个键都要在这里给出来(哪怕只是空壳):`applyOverride()` 是**原地往这个对象上写**的,
2864
+ // 少一个键就等于那条路径第一次改设置时静默丢掉 —— 互动反应候选原来就是这么漏的
2865
+ // (读的地方全写着 `?.`,所以"能读、写不进去",只有正面写它的界面会踩到)。
2866
+ const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, interactions: {}, lines: {} };
2861
2867
 
2862
2868
  /** 槽位 id -> 中文标签(关系行显示「贴纸」而不是 `sticker`)。 */
2863
2869
  const slotLabelOf = (slotId) =>
@@ -3575,28 +3581,60 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3575
3581
 
3576
3582
 
3577
3583
  /**
3578
- * What a head pat may answer with (requirement #5).
3584
+ * 三个互动**内置的**反应候选(用户覆盖 <- 宠物声明 <- 这里)。
3579
3585
  *
3580
- * One of these at random, and no blush — the blush is what a tap used to add
3581
- * unconditionally, which made every pat look identical.
3586
+ * 这三组原来只在 pet.json 里声明,代码侧没有任何默认值(`HEAD_PAT_REACTIONS`
3587
+ * 是上一版实现留下的死常量,谁都没读它)—— 于是**没声明这三组的宠物,互动看着
3588
+ * 是好的、其实什么都不演**:摸头/摸尾巴只有台词,转圈连台词都不弹。而这恰恰是
3589
+ * 「做一只自己的宠物」最常见的状态(最小 pet.json 里根本没有这几个键)。
3590
+ *
3591
+ * 标签按同一个顺序解读:先当**动作组的中文名**找(catalog.json 里作者起的名),
3592
+ * 找不到就当**表达式**闪一下。所以这里写的是用户看得懂的名字,不是 group / file id。
3582
3593
  */
3583
- const HEAD_PAT_REACTIONS = [
3584
- { motion: "Hammer" },
3585
- { expression: "问号" },
3586
- { expression: "星星眼" },
3587
- ];
3594
+ const DEFAULT_REACTIONS = {
3595
+ /** 摸头:随机一个,并且**故意不脸红** —— 脸红是以前每次摸头都加的东西,让每一下都一样。 */
3596
+ patReactions: ["重锤出击", "问号", "星星眼"],
3597
+ /** 摸尾巴:吐魂是表情,问号是符号表情。 */
3598
+ tailReactions: ["吐魂", "问号"],
3599
+ /** 转圈转晕:演「晕晕」。 */
3600
+ spinReactions: ["晕晕"],
3601
+ };
3602
+
3603
+ /** 一组反应候选的来历(诊断用,见 reactionSource)。 */
3604
+ const REACTION_KEYS = Object.keys(DEFAULT_REACTIONS);
3605
+
3606
+ /** 洗一遍候选:只留非空字符串(那份清单可能来自 localStorage,不可信)。 */
3607
+ const cleanReactionList = (list) =>
3608
+ (Array.isArray(list) ? list : []).filter((name) => typeof name === "string" && name !== "");
3588
3609
 
3589
3610
  /**
3590
- * 互动的反应候选:宠物默认(pet.json 的 `patReactions` / `tailReactions` /
3591
- * `spinReactions`)+ 用户覆盖(`PHASE_OVERRIDES.interactions`)。
3611
+ * 互动的反应候选:**用户覆盖 <- 宠物声明 <- 内置默认**。
3592
3612
  *
3593
3613
  * 条目是**标签**(「重锤出击」「星星眼」这种):先当动作组找(宠物会给动作起中文名),
3594
3614
  * 找不到就当表达式名。这样设置界面里可以拿宠物自己的清单当候选,用户不用记 id。
3615
+ *
3616
+ * 这里的兜底顺序和台词(`linesNow`)、摸鱼池(`fidgetEntriesFor`)是同一套:
3617
+ * 用户没改过就用宠物声明的,宠物没声明就用内置的 —— **任何一层为空都不能变成
3618
+ * "这个互动没反应"**。
3595
3619
  */
3596
3620
  const interactionReactions = (key) => {
3597
- const mine = PHASE_OVERRIDES.interactions?.[key];
3598
- const list = Array.isArray(mine) && mine.length > 0 ? mine : (MANIFEST.current?.[key] ?? []);
3599
- return Array.isArray(list) ? list.filter((name) => typeof name === "string" && name !== "") : [];
3621
+ const mine = cleanReactionList(PHASE_OVERRIDES.interactions?.[key]);
3622
+ if (mine.length > 0) return mine;
3623
+ const declared = cleanReactionList(MANIFEST.current?.[key]);
3624
+ if (declared.length > 0) return declared;
3625
+ return cleanReactionList(DEFAULT_REACTIONS[key]);
3626
+ };
3627
+
3628
+ /**
3629
+ * 这一组候选是从哪一层来的("没反应"这类问题要一眼看出是哪一层空了)。
3630
+ *
3631
+ * 诊断读口,不参与行为:`user` / `pet` / `builtin` / `none`。
3632
+ */
3633
+ const reactionSource = (key) => {
3634
+ if (cleanReactionList(PHASE_OVERRIDES.interactions?.[key]).length > 0) return "user";
3635
+ if (cleanReactionList(MANIFEST.current?.[key]).length > 0) return "pet";
3636
+ if (cleanReactionList(DEFAULT_REACTIONS[key]).length > 0) return "builtin";
3637
+ return "none";
3600
3638
  };
3601
3639
 
3602
3640
  /** 跑一条反应(由点击/转圈触发)。 */
@@ -3988,6 +4026,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3988
4026
  api.effectiveLines = () => linesNow();
3989
4027
  /** 某个互动的**有效**反应候选(同样是与宠物默认合并后的结果)。 */
3990
4028
  api.effectiveReactions = (key) => interactionReactions(key);
4029
+ /**
4030
+ * 诊断:三组候选各自的**来历**(user / pet / builtin / none)与内置默认值。
4031
+ *
4032
+ * "点了她没反应"要先分清是哪一层空了:用户覆盖成了空数组、宠物没声明、
4033
+ * 还是连内置默认都没有。只看 `effectiveReactions()` 是分不出来的。
4034
+ */
4035
+ api.reactionDiagnostics = () => ({
4036
+ source: Object.fromEntries(REACTION_KEYS.map((key) => [key, reactionSource(key)])),
4037
+ builtin: Object.fromEntries(REACTION_KEYS.map((key) => [key, DEFAULT_REACTIONS[key].slice()])),
4038
+ effective: Object.fromEntries(REACTION_KEYS.map((key) => [key, interactionReactions(key)])),
4039
+ });
3991
4040
  /** 台词的字段清单(测试用它确认"每一个字段都有输入框")。 */
3992
4041
  api.lineFields = () => ({
3993
4042
  plain: LINE_FIELDS.map((field) => field.key),
@@ -6418,12 +6467,9 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6418
6467
  */
6419
6468
  function InteractControls() {
6420
6469
  useSettings();
6421
- const listFor = (key) => {
6422
- const mine = PHASE_OVERRIDES.interactions?.[key];
6423
- if (Array.isArray(mine) && mine.length > 0) return mine;
6424
- const base = MANIFEST.current?.[key];
6425
- return Array.isArray(base) ? base : [];
6426
- };
6470
+ // 候选显示的是**有效那一组**(用户覆盖 <- 宠物声明 <- 内置默认),和运行时
6471
+ // `interactionReactions()` 同一个函数:界面上没勾的选项就不该演,勾着的必须真演。
6472
+ const listFor = (key) => interactionReactions(key);
6427
6473
  const toggle = (key, label) => {
6428
6474
  const next = listFor(key).slice();
6429
6475
  const at = next.indexOf(label);
@@ -6446,8 +6492,16 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6446
6492
  h("span", { "data-row-label": "" }, label),
6447
6493
  note === undefined ? null : h("span", { "data-note-inline": "" }, note),
6448
6494
  );
6449
- const reactionRow = (key, title, note) => h("div", { "data-reaction-set": key },
6450
- h("div", { "data-row-label": "" }, title, note === undefined ? null : h("span", { "data-note-inline": "" }, note)),
6495
+ // 从哪里来的写在行尾:**这只宠物没声明这一组**时,用户看到的默认值其实是插件
6496
+ // 内置的那几个 —— 不说清楚他会以为"这是宠物自己的设定,改宠物就能改默认"。
6497
+ const sourceNote = (key) => {
6498
+ const source = reactionSource(key);
6499
+ if (source === "user") return "(已改过)";
6500
+ return source === "pet" ? "(宠物默认)" : "(内置默认)";
6501
+ };
6502
+ const reactionRow = (key, title) => h("div", { "data-reaction-set": key, ...(reactionSource(key) === "builtin" ? { "data-reaction-builtin": "" } : {}) },
6503
+ h("div", { "data-row-label": "" }, title,
6504
+ h("span", { "data-note-inline": "" }, sourceNote(key), "(随机一个)")),
6451
6505
  h("div", { "data-chips": "" }, candidates.length === 0
6452
6506
  ? h("span", { "data-note-inline": "" }, "这只宠物没有可选的动作/表情")
6453
6507
  : candidates.map((label) => {
@@ -6466,9 +6520,9 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6466
6520
  switchRow("tailEnabled", "摸尾巴有反应"),
6467
6521
  switchRow("spinEnabled", "鼠标绕着转圈会晕"),
6468
6522
  h(TuningControls, { key: "spin", group: "interact" }),
6469
- reactionRow("patReactions", "摸头时演什么", "(随机一个)"),
6470
- reactionRow("tailReactions", "摸尾巴时演什么", "(随机一个)"),
6471
- reactionRow("spinReactions", "转晕时演什么", "(随机一个)"),
6523
+ reactionRow("patReactions", "摸头时演什么"),
6524
+ reactionRow("tailReactions", "摸尾巴时演什么"),
6525
+ reactionRow("spinReactions", "转晕时演什么"),
6472
6526
  );
6473
6527
  }
6474
6528
 
package/lib/index.js CHANGED
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync } from 'node:fs'
24
+ import { createHash } from 'node:crypto'
24
25
  import { readFile } from 'node:fs/promises'
25
26
  import { homedir } from 'node:os'
26
27
  import { dirname, extname, join, sep } from 'node:path'
@@ -567,9 +568,89 @@ export function scanPet(dir, id) {
567
568
  }
568
569
  }
569
570
 
570
- /** Build the live catalog from disk (fresh per request, so installs are picked up). */
571
571
  /**
572
- * 把**随包分发**的宠物装进用户的宠物目录。
572
+ * 随包宠物 pet.json 的**历史内容指纹**(SHA-256),一份一行。
573
+ *
574
+ * 只为一件事存在:认出"用户宠物目录里那份是我们**某一次发出去的**原样副本"。
575
+ * 认出来才敢升级(`syncBundledPet`),认不出来就一个字都不碰。
576
+ *
577
+ * 为什么必须从 git 历史里现算、不能手抄:抄错一位的两种后果都是静默的 ——
578
+ * 要么永远不升级(老用户拿不到新的默认值),要么把用户自己改过的副本覆盖掉。
579
+ * 生成命令:`node tools/print-pet-hashes.mjs dsh-live2d-pet/pets/ds-whale-girl/pet.json --at <提交> --code`
580
+ * (每份内容取它**最后**出现的那个提交,否则第一次安装与仓库当时状态对不上)。
581
+ *
582
+ * 这是**冷启动**用的表:同步记录(`pets/.synced.json`)建立之后,判定就只看记录,
583
+ * 这个表只在"目标没有记录"时才参与 —— 也就是所有老装机,以及第一次装新版本的人。
584
+ * 因此每改一次随包宠物,都要把**改动之前那一版**的哈希补进来(改动之后那一版是目标,
585
+ * 不需要进表)。
586
+ */
587
+ const BUNDLED_PET_HASHES = {
588
+ 'ds-whale-girl': [
589
+ // ef7fb1a:模型第一次随包分发(1.0.x,17 个槽位、5 个相位)
590
+ '5421dee9d13ee60b91c341ab515bbef3fb8702a9791279c9a2f61124d4478b34',
591
+ // 3f18b195:面板跟随宿主主题 + 自拍可配置
592
+ '707049e3b8a05f3de5ddd49299bcb47fdd2ac1494209b7c710c1371be718bb27',
593
+ // 9b62d727:自拍独立成槽、氛围拆三个、摸鱼不再擦掉手选
594
+ '071a7bfe18b4882703a43f8a14dc7eded8d5aa08671dc3ecc4db220258e5be23',
595
+ // 6c6b61e1(2.0.0):把作者调好的值烘成宠物默认
596
+ 'e931e44a6589932688d7507b0d5e62ba4c0f9618492ec7fe644885117e61f12a',
597
+ // 4b594ed3(2.2.0):多接三个会话状态
598
+ '88b86f32833882893e8ec10a9320bb4d9899a0211811d5a96544e4b97b804234',
599
+ // 25c4d285(2.3.0 前):台词 / 反应候选 / 摸鱼槽位
600
+ '18840cd90fe70aa68632c45f77b4af1254595b5f963d261d2cbf34f6fbcaf579',
601
+ // 1.0.1(2.3.0 ~ 2.3.2 随包的那份):内容与上面那条相同,只是换了插件版本号
602
+ '7c6cdb9c9f3d92636c388bffb3229c439cf01a65fe6a8a88499a8e4071f7884a',
603
+ ],
604
+ }
605
+
606
+ /** 同步记录:哪个宠物是我们装的、装的是哪一版、装下去那份长什么样。 */
607
+ const SYNC_RECORD = '.synced.json'
608
+
609
+ /** 文件和 buffer 的 SHA-256(判定"这是不是我们发出去的原样")。 */
610
+ function sha256(bytes) {
611
+ return createHash('sha256').update(bytes).digest('hex')
612
+ }
613
+
614
+ /** 读同步记录;读不出来就当"没有"(老装机本来就没有)。 */
615
+ function readSyncRecord() {
616
+ const parsed = readJson(join(petsRoot(), SYNC_RECORD))
617
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
618
+ }
619
+
620
+ function writeSyncRecord(record) {
621
+ mkdirSync(petsRoot(), { recursive: true })
622
+ writeFileSync(join(petsRoot(), SYNC_RECORD), JSON.stringify(record, null, 2) + '\n')
623
+ }
624
+
625
+ /** 点分数字比较:'1.1.0' > '1.0.1'。非数字段(rc / beta)只取数字部分。 */
626
+ export function compareVersions(a, b) {
627
+ const parts = (raw) => String(raw ?? '').split('.').map((piece) => {
628
+ const n = Number.parseInt(piece, 10)
629
+ return Number.isFinite(n) ? n : 0
630
+ })
631
+ const left = parts(a)
632
+ const right = parts(b)
633
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
634
+ const one = left[i] ?? 0
635
+ const two = right[i] ?? 0
636
+ if (one !== two) return one > two ? 1 : -1
637
+ }
638
+ return 0
639
+ }
640
+
641
+ /** 这份宠物目录里的 pet.json 是不是"某一次随包发出去的原样"。 */
642
+ function isPristineBundledManifest(petId, manifestPath, fingerprints) {
643
+ let hash
644
+ try {
645
+ hash = sha256(readFileSync(manifestPath))
646
+ } catch {
647
+ return false
648
+ }
649
+ return (fingerprints[petId] ?? []).includes(hash)
650
+ }
651
+
652
+ /**
653
+ * 把随包宠物装进用户的宠物目录,并在**确实是我们装的那份**过期时更新它。
573
654
  *
574
655
  * 以前宠物是仓库里单独一份,装插件的人还得自己再拷一次 —— 从插件市场装完
575
656
  * 「看不见宠物」就是这么来的(插件包里有代码,没有模型)。
@@ -578,31 +659,99 @@ export function scanPet(dir, id) {
578
659
  * 已明确授权本项目转载与开源(见 pets/ds-whale-girl/LICENSE);Core 是 Live2D Inc.
579
660
  * 的专有软件,只允许从官方渠道取,所以它走 CDN 兜底那条路。
580
661
  *
581
- * 只在目标**不存在**时复制:用户自己改过的宠物永远优先,绝不覆盖。
662
+ * **升级这一半是补上的**(原来只有"目标不存在才拷"):插件目录会随
663
+ * `dsh plugin add` / npm 更新,用户的宠物目录不会 —— 于是宠物**自己的默认值
664
+ * 永远停在装它的那一天**。25c4d285 往 pet.json 里加的那批(台词 / 互动反应候选 /
665
+ * 摸鱼槽位 / 新增的三个相位)就这么一直没到过老用户的桌面,症状是"互动只有台词
666
+ * 不演反应、相位不弹台词、装扮少三个槽位",而且**看起来完全像插件的 bug**。
667
+ *
668
+ * 三条判定,缺一条都会变成"静默覆盖用户数据":
669
+ * 1. 目标不存在 → 装一份(并把版本与内容指纹记进 `pets/.synced.json`);
670
+ * 2. 目标就是我们上次装下去的那一份(**内容指纹**对得上)→ 整份更新;
671
+ * 3. 用户动过(指纹对不上)→ **一个字都不碰**。
672
+ *
673
+ * 第 2 条在冷启动时(记录出现之前装的副本)靠 `BUNDLED_PET_HASHES` 认人:
674
+ * 内容与历史上任何一次随包分发**逐字节相同**才升级。改过名字/槽位/任何东西的副本
675
+ * 哈希必然不同,所以"用户自己改过的宠物永远优先,绝不覆盖"这条没有被削弱。
582
676
  */
583
- export function installBundledPets() {
677
+ export function installBundledPets(options = {}) {
584
678
  const bundled = join(pluginRoot(), 'pets')
585
679
  if (!existsSync(bundled)) return
680
+ // 冷启动指纹表可以被注入:测试要造一份"我们以前发过的旧副本",但测试**不能**
681
+ // 依赖生产表里的具体哈希(那样改一次表就得改测试,而且改错了也没人发现)。
682
+ const fingerprints = options.fingerprints ?? BUNDLED_PET_HASHES
586
683
  let names = []
587
684
  try {
588
685
  names = readdirSync(bundled).filter((entry) => !entry.startsWith('.'))
589
686
  } catch {
590
687
  return
591
688
  }
689
+ const record = readSyncRecord()
690
+ const notes = []
691
+ let dirty = false
592
692
  for (const name of names) {
593
693
  const source = join(bundled, name)
594
694
  const target = join(petsRoot(), name)
595
695
  try {
596
696
  if (!statSync(source).isDirectory()) continue
597
- if (existsSync(target)) continue
598
- mkdirSync(dirname(target), { recursive: true })
697
+ const manifestPath = join(source, 'pet.json')
698
+ if (!existsSync(manifestPath)) continue
699
+ const version = String(readJson(manifestPath)?.version ?? '')
700
+ const targetManifest = join(target, 'pet.json')
701
+
702
+ if (!existsSync(target)) {
703
+ mkdirSync(dirname(target), { recursive: true })
704
+ cpSync(source, target, { recursive: true })
705
+ record[name] = { version, hash: sha256(readFileSync(targetManifest)) }
706
+ dirty = true
707
+ continue
708
+ }
709
+
710
+ const synced = record[name]
711
+ const same = existsSync(targetManifest) ? sha256(readFileSync(targetManifest)) : undefined
712
+ const ours = (synced !== undefined && synced.hash === same)
713
+ || isPristineBundledManifest(name, targetManifest, fingerprints)
714
+ if (!ours) {
715
+ // 用户自己改过:不动它。**要说出来** —— 沉默地不升级,用户看到的是
716
+ // "插件更新了但没有任何变化",比报错更难查。
717
+ if (compareVersions(version, readJson(targetManifest)?.version) > 0) {
718
+ notes.push(name + ':宠物目录里这份被改过,跳过更新(随包版本 ' + version
719
+ + ',本地 ' + String(readJson(targetManifest)?.version ?? '?') + ')')
720
+ }
721
+ continue
722
+ }
723
+ // **升级与否只看内容**,不看版本号:版本号是宠物自己声明的,随包那份在同一个
724
+ // 版本号下改过(补默认值就是这么发生的),只比版本号会让这次修订永远发不出去。
725
+ // 反过来,"版本相同 + 内容相同"在这里就短路了,不会做无谓的整份重写。
726
+ const bundledHash = sha256(readFileSync(manifestPath))
727
+ if (same === bundledHash) continue
728
+
729
+ // cpSync 是逐文件覆盖 —— 用户额外放进去的文件(自己的贴图、备注)不在随包
730
+ // 那份里,所以留得住。
731
+ // 「原先」那个版本号要**在覆盖之前**读:覆盖之后再读只会读到新的,日志就成了
732
+ // "更新到 1.1.0(原先 1.1.0)"(第一次跑就写成了这样)。
733
+ const was = String(readJson(targetManifest)?.version ?? '?')
599
734
  cpSync(source, target, { recursive: true })
735
+ record[name] = { version, hash: bundledHash }
736
+ dirty = true
737
+ notes.push(name + ':宠物默认值更新到 ' + version + '(原先 ' + was + ')')
600
738
  } catch {
601
739
  /* 只读文件系统之类:让用户自己拷,别让整个目录扫描失败 */
602
740
  }
603
741
  }
742
+ if (dirty) {
743
+ try {
744
+ writeSyncRecord(record)
745
+ } catch {
746
+ /* 记录写不进去只影响"下次少一次判定",不影响这次已经装好的宠物 */
747
+ }
748
+ }
749
+ if (notes.length > 0 && options.quiet !== true) {
750
+ for (const note of notes) console.log('[live2d-pet] ' + note)
751
+ }
604
752
  }
605
753
 
754
+ /** Build the live catalog from disk (fresh per request, so installs are picked up). */
606
755
  export function buildCatalog() {
607
756
  installBundledPets()
608
757
  const root = petsRoot()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pet-live2d",
3
- "version": "2.3.2",
3
+ "version": "2.3.3",
4
4
  "description": "Live2D 桌宠插件:为 DSH Web GUI 挂一只可拖动、会跟随鼠标、能换动作与表情的 Live2D 宠物。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -3,7 +3,7 @@
3
3
  "id": "ds-whale-girl",
4
4
  "displayName": "DS鲸鱼娘",
5
5
  "description": "氵六青的 DS 鲸鱼娘 Live2D 桌宠:8 组动作 + 44 个表情/道具,点她会有反应。",
6
- "version": "1.0.1",
6
+ "version": "1.1.0",
7
7
  "author": "模型:上善无形 / ZipZipPipe / 氵六青 · 插件:A8Chann",
8
8
  "license": "CC BY-NC-SA 4.0 (attribution, NON-COMMERCIAL, share-alike). Character: original OC by 上善无形 (https://www.pixiv.net/users/62155430); DeepSeek maid redesign by ZipZipPipe (https://www.pixiv.net/users/18604994); this Live2D model by 氵六青 (https://space.bilibili.com/11272072), who authorised redistribution here — which does NOT lift the base NC/SA terms. Commercial use requires permission from each rights holder. See NOTICE.md.",
9
9
  "homepage": "https://space.bilibili.com/11272072",