dsh-wisp 1.35.0 → 1.36.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/README.md CHANGED
@@ -448,6 +448,59 @@ node tools/audit-log.mjs --confirm <manifest> # 生成后:并入代理登
448
448
 
449
449
  ## 变更
450
450
 
451
+ ### 1.36.0
452
+
453
+ 一次性做完 ③④⑤ 三件事。其中 **⑤ 完整交付并验证**;③④ 的**代码路径全部做完并接好**,
454
+ 但素材生成这次没成功(原因见最后一节)。
455
+
456
+ #### ⑤ 英文台词
457
+
458
+ 她会说英文了。**只翻译她说的话**(菜单按钮文案仍由 DSH 界面语言决定,不在这一层)。
459
+
460
+ 实现上是**覆盖层**而不是替换:中文词池原样保留,英文按 key 覆盖上去,
461
+ 缺哪条就自动退回中文 —— 所以可以慢慢补齐,永远不会出现半句话。
462
+
463
+ 语言来源按优先级:① 本地存的强制值 `localStorage['dsh-wisp-lang']`
464
+ ② 页面 `<html lang>` ③ `navigator.language`。
465
+
466
+ **② 排在 ③ 前面是刻意的**:界面是中文、系统是英文的用户,不该听到英文台词 ——
467
+ 页面的 lang 比系统语言更能代表"我现在在看什么语言"。
468
+
469
+ 三条检查:英文环境下说英文 ✓、句子里**一个汉字都没有** ✓、强制中文时仍是中文 ✓(对照组,
470
+ 否则第一条可能只是"恰好没触发")。
471
+
472
+ #### ③ 音效(默认关闭)
473
+
474
+ 四个音效位:`poke`(戳一下)/ `done`(跑完)/ `focusEnd`(专注结束)/ `notice`(提醒你起来)。
475
+
476
+ - **默认关闭** —— 无故出声是最招人烦的交互之一,必须是用户主动打开的。
477
+ - **没有音频文件时整条路径静默**,而且**菜单里连那个开关都不显示** ——
478
+ 摆一个点了没反应的开关,比没有开关更糟。
479
+ - 素材落地方式:把 `poke.mp3` 等放进 `assets/audio/`,构建自动内联成 data URL,重启即生效。
480
+
481
+ #### ④ 第九个情绪:`report`(抱着记录本汇报)
482
+
483
+ 给**值班汇报 / 今日小结 / 专注结束**用 —— 正好接上 1.35.0 那三件事。
484
+
485
+ 它和别的格子一样走 `SPRITE_FALLBACK`:**素材还没生成时自动退回 `idle`**,
486
+ 不会出现空白或坏图。构建也认这一点:`report` 是**可选心情**,缺素材只警告不中断。
487
+
488
+ #### 这一节说清楚"没做完的部分"
489
+
490
+ **五张 `report` 母版与四个音效这次没生成出来。** 两次 Maker 生成调用都**请求超时**,
491
+ 第二次挂了约一小时,事后检查三处可能的落地目录,**一个新文件都没有** —— 这是服务侧的挂起,
492
+ 不是提示词或参数的问题(同一个工具此前生成过 40 张素材)。
493
+
494
+ 所以这一版交付的是**可以立刻用上的代码路径**:素材一放进目录、重跑构建就生效,
495
+ 不需要再改一行代码。素材缺失时的行为是**静默降级**而不是报错。
496
+
497
+ 另外顺手修了构建里两处**只认必选心情**的守卫(它们会把"缺 report"当成致命错误直接退出)——
498
+ 这正是"素材可后补"这件事在构建侧的对应改动。
499
+
500
+ 预检 539 → **542 项**。
501
+
502
+ ### 1.35.0
503
+
451
504
  ### 1.35.0
452
505
 
453
506
  **她开始替你"值班"。** 在这之前,你走开时她只是**安静下来**;回来只说一句"你走了好久"。
package/build.mjs CHANGED
@@ -45,7 +45,10 @@ const assetsDir = resolve(here, flagValue('--from', 'assets'))
45
45
  const templatePath = join(here, 'lib', 'client.template.js')
46
46
  const outPath = join(here, 'lib', 'client.js')
47
47
 
48
- const MOODS = ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked', 'proud', 'eat']
48
+ /* report = 抱着记录本汇报(值班汇报 / 今日小结 / 专注结束)。它和其它格子一样,
49
+ 但**允许缺素材**:缺了就跳过,客户端由 SPRITE_FALLBACK 退回 idle。 */
50
+ const MOODS = ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked', 'proud', 'eat', 'report']
51
+ const OPTIONAL_MOODS = ['report']
49
52
 
50
53
  /* 皮肤 = assets/ 下的一个子目录,里面是这套皮肤的 <mood>[_tier].webp。
51
54
  如果没有子目录含精灵图,就把 assets/ 本身当作一个名为 default 的皮肤 ——
@@ -102,6 +105,20 @@ if (skins.length === 0) {
102
105
  process.exit(1)
103
106
  }
104
107
 
108
+ /* 音效:assets/audio/<名字>.mp3 -> { <名字>: "data:audio/mpeg;base64,…" }。
109
+ 没有文件就是空对象 —— 客户端据此连菜单开关都不显示(点了没反应的开关更糟)。 */
110
+ const audioDir = join(assetsDir, 'audio')
111
+ const sounds = {}
112
+ if (existsSync(audioDir)) {
113
+ const MIME = { '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.wav': 'audio/wav' }
114
+ for (const name of readdirSync(audioDir).sort()) {
115
+ const ext = name.slice(name.lastIndexOf('.')).toLowerCase()
116
+ if (MIME[ext] === undefined) continue
117
+ sounds[name.slice(0, name.lastIndexOf('.'))] = `data:${MIME[ext]};base64,${readFileSync(join(audioDir, name)).toString('base64')}`
118
+ }
119
+ }
120
+ if (Object.keys(sounds).length > 0) console.log(` audio: ${Object.keys(sounds).join(', ')}`)
121
+
105
122
  const sprites = {}
106
123
  let total = 0
107
124
  for (const skin of skins) {
@@ -111,6 +128,11 @@ for (const skin of skins) {
111
128
  for (const mood of MOODS) {
112
129
  const file = pickFile(dir, mood)
113
130
  if (!file) {
131
+ /* 可选心情(report)缺素材只警告不中断:客户端有回退表,缺它不会变成坏行为。 */
132
+ if (OPTIONAL_MOODS.includes(mood)) {
133
+ console.warn(` ! ${skin}: 跳过可选心情 "${mood}"(还没有素材)`)
134
+ continue
135
+ }
114
136
  console.error(`build: skin "${skin}" has no sprite for mood "${mood}"`)
115
137
  process.exit(1)
116
138
  }
@@ -140,7 +162,11 @@ const literal = '{\n' + skins.map((skin) => (
140
162
  + MOODS.map((m) => ` ${m}: '${sprites[skin][m]}',`).join('\n')
141
163
  + '\n },'
142
164
  )).join('\n') + '\n }'
143
- const out = template.replace(PLACEHOLDER, literal)
165
+ const soundsInlined = template.replace(
166
+ "const SOUNDS = (typeof __WISP_SOUNDS__",
167
+ `const __WISP_SOUNDS__ = ${JSON.stringify(sounds)}\nconst SOUNDS = (typeof __WISP_SOUNDS__`,
168
+ )
169
+ const out = soundsInlined.replace(PLACEHOLDER, literal)
144
170
 
145
171
  if (out.includes(PLACEHOLDER)) {
146
172
  console.error('build: placeholder survived the substitution; refusing to write a broken bundle')
@@ -174,8 +200,10 @@ if (stamped[1] !== pkg.version) {
174
200
  }
175
201
 
176
202
  // Prove the injected table survives parsing before writing anything.
203
+ // 可选心情(report)允许缺失:客户端有 SPRITE_FALLBACK 兜底,缺它不会变成坏行为。
177
204
  const table = out.match(/const SPRITES = \{([\s\S]*?)\n {4}\}/)
178
- if (!table || MOODS.some((m) => !new RegExp(`\\b${m}:\\s*'data:image/`).test(table[1]))) {
205
+ const requiredMoods = MOODS.filter((m) => !OPTIONAL_MOODS.includes(m))
206
+ if (!table || requiredMoods.some((m) => !new RegExp(`\\b${m}:\\s*'data:image/`).test(table[1]))) {
179
207
  console.error('build: the injected SPRITES table is missing sprites; aborting')
180
208
  process.exit(1)
181
209
  }
@@ -187,6 +215,7 @@ try {
187
215
  const broken = []
188
216
  for (const skin of skins) {
189
217
  for (const mood of MOODS) {
218
+ if (OPTIONAL_MOODS.includes(mood)) continue // 可缺:客户端由 SPRITE_FALLBACK 兜底
190
219
  const uri = value?.[skin]?.[mood]
191
220
  if (typeof uri !== 'string' || uri.indexOf('data:image/') !== 0) broken.push(`${skin}/${mood}`)
192
221
  }