dsh-wisp 1.28.0 → 1.29.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,40 @@ node tools/audit-log.mjs --confirm <manifest> # 生成后:并入代理登
448
448
 
449
449
  ## 变更
450
450
 
451
+ ### 1.29.0
452
+
453
+ 三件和"她真的陪着你"有关的事。都不需要新素材,全是行为 + 台词。
454
+
455
+ - **夜深了她会劝你去睡。** 过了 `bedtimeHour`(默认 **23**)她换一种口气,过了入睡**两小时**
456
+ 换成"嘴硬心软"那一档 —— 后者是社区设定里她的底色(*"我不管你,但……真的该睡了。"*、
457
+ *"才不是担心你,只是我不想半夜还看着屏幕。"*、*"你要是倒下,我可没有备份。"*)。
458
+
459
+ - **作息是可配的**(`bedtimeHour` / `wakeHour`,默认 23 → 5,跨零点)——
460
+ 这既是真功能(各人作息不同),也让测试**不必伪造时钟**:把窗口套在当前那个小时上,
461
+ 该响就得响,套偏一小时就该安静;
462
+ - 深度档按**入睡后经过的小时数**算,不是按"小时落在某个绕圈区间里"——
463
+ 后者在窄窗口下会把窗外一大段时间也算成深夜(写测试时当场撞上:13 点睡、14 点起,
464
+ 结果 13 点自己被判成"深度深夜");
465
+ - 菜单「行为」组里多了「**深夜劝睡**」开关,另有 `nightMs`(默认 30 分钟一次;0 = 关)。
466
+
467
+ - **你离开又回来,她记得。** 挂 `visibilitychange` 记住离开的时刻,回来按时长分两档:
468
+ 短走一句(*"回来了。"*、*"位置我没动。"*)、长走(15 分钟以上)另一句
469
+ (*"你走了好久。"*、*"我还以为你把我关了。"*)。阈值 `backAfterMs`,默认 2 分钟,0 = 关。
470
+
471
+ - **连续天数。** 从今天往回数,记忆里一天不落有多长 —— 第 2 天起她会提
472
+ (*"我们已经连着见了 4 天。"*),到 **7 / 30 / 100 / 365** 天换一句里程碑。
473
+ 断了就回到 1,而且**一句都不说**("连续第 1 天"很怪)。`doctor().streak` 可查。
474
+
475
+ - `doctor()` 新增 `night`(窗口 / 深度档起点 / 当前小时 / 上次提醒时间)、`streak`、`awaySince`
476
+ —— 不用猜"她为什么突然劝我睡觉"。
477
+
478
+ - 预检 435 → **451 项**。
479
+
480
+ - 顺手记一个测试教训:气泡只活几秒,**"推进完再看"必然看不到** ——
481
+ 久坐提醒那节早就踩过,这次又踩了一遍。现在统一用 `watch(h, ms)` 边推进边收集。
482
+
483
+ ### 1.28.0
484
+
451
485
  ### 1.28.0
452
486
 
453
487
  - **菜单不再是 19 个同级条目。** 原来 6 个动作 + 4 个开关 + 4 个角落 + 5 套皮肤全平铺,
package/lib/client.js CHANGED
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
51
51
  var exports = module.exports
52
52
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
53
53
 
54
- const VERSION = '1.28.0'
54
+ const VERSION = '1.29.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -69,6 +69,7 @@ const WHATS_NEW = {
69
69
  '1.26.0': '检查更新同时读 npm 和 GitHub,取更高的那个版本。',
70
70
  '1.27.0': '她饿了会捧出一碗白米饭,还有第八个表情。',
71
71
  '1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
72
+ '1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
72
73
  }
73
74
 
74
75
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -215,6 +216,11 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
215
216
  careAfterMs: 5400000, // 连续活动这么久之后提醒你起来动动(90 分钟;0 = 关)
216
217
  hungry: true, // 干饭:她饿了会说
217
218
  hungerMs: 2700000, // 连续活动这么久就该吃一碗了(45 分钟;0 = 关)
219
+ night: true, // 深夜劝睡
220
+ bedtimeHour: 23, // 几点算深夜(0-23)
221
+ wakeHour: 5, // 几点算天亮(0-23);bedtime > wake 时窗口跨零点
222
+ nightMs: 1800000, // 深夜每隔这么久说一句(30 分钟;0 = 关)
223
+ backAfterMs: 120000, // 离开超过这么久,回来时她会说一句(2 分钟;0 = 关)
218
224
  milestones: true, // 今日轮次到达里程碑时得意一下
219
225
  memory: true, // 她记着你:一起多少天 / 常放哪个角 / 常穿哪套
220
226
  happyMs: 1100,
@@ -318,6 +324,31 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
318
324
  '算力不够了,得补点碳水。', '电量告急,切待机。', '你要不要也来一口?',
319
325
  '这碗不算加餐,算基础设施。', '吃饱了才有力气摸鱼。', '饭碗见底了,该续命了。', '我数着米粒呢。',
320
326
  ],
327
+ /* 深夜劝睡:23 点后一种语气,凌晨 1 点后另一种 —— 后者是社区设定里"嘴硬心软"那一档。
328
+ 分档按 bedtimeHour + 2 推导,所以改了作息这套分档也跟着走。 */
329
+ nightLate: [
330
+ '十一点了。', '该收的东西收一收吧。', '我不催你,时钟在催。', '这个点还在跑,明天会还债的。',
331
+ '夜里的屏幕最亮,眼睛最累。', '再干一会儿就收工?', '白天的你会感谢现在的你。', '我给你留着灯。',
332
+ '要不要把剩下的留到明天?', '深夜的算力也是算力,但人也得睡。', '我先打个盹,你随意。', '这时候的安静挺值钱的。',
333
+ ],
334
+ nightDeep: [
335
+ '一点了。', '我不管你,但……真的该睡了。', '明天再跑也一样,代码不会跑。', '再撑下去,明天会很难受。',
336
+ '才不是担心你,只是我不想半夜还看着屏幕。', '你要是倒下,我可没有备份。', '这个点做的决定,白天多半会后悔。',
337
+ '我数到十你就去睡。……一。', '熬夜是拿明天的算力换今晚的。', '天快亮了,你还在这儿。',
338
+ '我要是能关灯就好了。', '睡吧。我留在这儿。',
339
+ ],
340
+ /* 离开又回来:按走了多久分两档(阈值见 backAfterMs / BACK_LONG_MS)。 */
341
+ backSoon: [
342
+ '回来了。', '刚才去哪了。', '我在这儿等着呢。', '一眼没看见你。', '这么快就回来,水都没喝吧。', '位置我没动。',
343
+ ],
344
+ backLong: [
345
+ '你走了好久。', '差点以为今天见不到了。', '回来了就好。', '我一直在这儿。', '这么久,是去吃饭了吗?',
346
+ '屏幕都凉了。', '我还以为你把我关了。', '下次说一声,我就不数时间了。',
347
+ ],
348
+ /* 连续天数({n} 是天数) */
349
+ streak: [
350
+ '连续第 {n} 天了。', '第 {n} 天了,你还在。', '我们已经连着见了 {n} 天。', '第 {n} 天。我记着呢。', '{n} 天没断,挺厉害的。',
351
+ ],
321
352
  updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
322
353
  updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
323
354
  updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
@@ -334,6 +365,37 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
334
365
  hint: ['右键我一下试试。', '右键我有菜单。', '拖我,或者右键我。'],
335
366
  }
336
367
 
368
+ /* 深夜窗口。bedtime > wake 时它跨零点(默认 23→5);不跨也能用(比如 1→5)。 */
369
+ const isNightHour = (hour, from, to) => {
370
+ if (from === to) return false
371
+ return from > to ? (hour >= from || hour < to) : (hour >= from && hour < to)
372
+ }
373
+ /* "深度深夜"= 已经过了入睡时间两小时以上。按**经过的小时数**算,而不是按绕圈区间:
374
+ 后者在窄窗口(比如 13 点睡、14 点起)会把窗外的一大段时间也算进来。 */
375
+ const DEEP_AFTER_HOURS = 2
376
+ const hoursSince = (hour, from) => (((hour - from) % 24) + 24) % 24
377
+ const deepHourOf = (bedtime) => (Math.round(bedtime) + DEEP_AFTER_HOURS) % 24
378
+
379
+ /* 连续天数:从今天往回数,memory.days 里一天不落有多长。
380
+ 用毫秒回推而不是解析日期字符串 —— dayKey() 产出的 2026-9-30 不是 ISO 形式,
381
+ 各引擎对它的解析并不一致。 */
382
+ const dayKeyOf = (ms) => {
383
+ try {
384
+ const d = new Date(ms)
385
+ return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
386
+ } catch (e) { return 'unknown' }
387
+ }
388
+ const streakOf = (days) => {
389
+ if (!Array.isArray(days) || days.length === 0) return 0
390
+ const set = new Set(days)
391
+ let n = 0
392
+ let t = Date.now()
393
+ while (n < 400 && set.has(dayKeyOf(t))) { n++; t -= 86400000 }
394
+ return n
395
+ }
396
+ /* 连续天数的里程碑;到了才说一次。 */
397
+ const STREAK_MILESTONES = [7, 30, 100, 365]
398
+
337
399
  /* 时段划分:凌晨与白天说话的口气不一样。深夜跨了两天,所以按"< 5 点"算夜里。 */
338
400
  const greetPoolName = (hour) => {
339
401
  if (hour < 5 || hour >= 22) return 'night'
@@ -388,6 +450,11 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
388
450
  careAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
389
451
  hungry: { kind: 'bool' },
390
452
  hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
453
+ night: { kind: 'bool' },
454
+ bedtimeHour: { kind: 'num', lo: 0, hi: 23 },
455
+ wakeHour: { kind: 'num', lo: 0, hi: 23 },
456
+ nightMs: { kind: 'num', lo: 0, hi: 86400000 },
457
+ backAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
391
458
  milestones: { kind: 'bool' },
392
459
  memory: { kind: 'bool' },
393
460
  happyMs: { kind: 'num', lo: 200, hi: 10000 },
@@ -1391,6 +1458,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1391
1458
  let lastCareAt = 0
1392
1459
  /* 上一顿饭的时间。起手是 0,所以计时以 activeSince 为准 —— 见下面 mealBase。 */
1393
1460
  let lastMealAt = 0
1461
+ /* 上一次深夜劝睡;离开又回来的时间点。 */
1462
+ let lastNightAt = 0
1463
+ let awayAt = 0
1394
1464
  let happyHandle = null
1395
1465
  let lastErrorCount = 0
1396
1466
 
@@ -1430,6 +1500,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1430
1500
  pokedHandle = clock.after(POKED_MS, () => { if (mood === 'poked') setMood('idle') })
1431
1501
  }
1432
1502
 
1503
+ /* 打盹:深夜劝睡用它(一个哈欠比一条通知更像她)。睡着是瞬时状态,
1504
+ 自己到期就回稳态。 */
1505
+ const NAP_MS = 5200
1506
+ let napHandle = null
1507
+ const flashSleepy = (text) => {
1508
+ if (mood === 'sleep') wake(true)
1509
+ setMood('sleep')
1510
+ say(text)
1511
+ if (napHandle) clock.cancel(napHandle)
1512
+ napHandle = clock.after(NAP_MS, () => { if (mood === 'sleep') setMood('idle') })
1513
+ }
1514
+
1433
1515
  /* 干饭用的也是瞬时状态(借 eat 的姿态)。比"提醒你活动"长一点 ——
1434
1516
  吃饭本来就慢。 */
1435
1517
  const EAT_MS = 5200
@@ -1531,6 +1613,21 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1531
1613
  flashEat(pick(LINES.eat))
1532
1614
  }
1533
1615
 
1616
+ /* ---- 深夜劝睡 ---------------------------------------------------------
1617
+ 作息**是可配的**(bedtimeHour / wakeHour),所以它既是一项真功能,
1618
+ 也让测试不必伪造时钟 —— 把窗口套在当前那个小时上就能测。
1619
+ 深度深夜从 bedtime + 2 起算,语气换一档:从"该收了"变成"嘴硬心软"。 */
1620
+ if (cfg.night && cfg.nightMs > 0) {
1621
+ const hour = hourNow()
1622
+ const nightBase = Math.max(lastNightAt, activeSince)
1623
+ if (isNightHour(hour, cfg.bedtimeHour, cfg.wakeHour)
1624
+ && nowTick - nightBase >= cfg.nightMs) {
1625
+ lastNightAt = nowTick
1626
+ const deep = hoursSince(hour, cfg.bedtimeHour) >= DEEP_AFTER_HOURS
1627
+ flashSleepy(pick(deep ? LINES.nightDeep : LINES.nightLate))
1628
+ }
1629
+ }
1630
+
1534
1631
  // 有东西在等她处理时,优先级高于一切:这是唯一"你不理它就卡住"的状态。
1535
1632
  const pending = cfg.reactions ? readPending() : null
1536
1633
  if (pending === null) {
@@ -1849,6 +1946,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1849
1946
  { key: 'reactions', label: '跟随状态' },
1850
1947
  { key: 'celebrate', label: '跑完撒花' },
1851
1948
  { key: 'hungry', label: '饿了会说话' },
1949
+ { key: 'night', label: '深夜劝睡' },
1852
1950
  ]
1853
1951
 
1854
1952
  /* ---- 弹窗 ----------------------------------------------------------------
@@ -2223,6 +2321,26 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2223
2321
  }
2224
2322
  window.addEventListener('resize', onResize)
2225
2323
 
2324
+ /* ---- 你离开又回来 --------------------------------------------------------
2325
+ visibilitychange 是唯一能知道"你去忙别的了"的信号。离开时长按两个阈值分档:
2326
+ 短走一句、长走一句;backAfterMs: 0 就整个关掉。
2327
+ 注意监听要能摘掉(destroy 里 remove)—— 文档级监听泄漏过一次,记住这个教训。 */
2328
+ const BACK_LONG_MS = 900000
2329
+ const onVisibility = () => {
2330
+ let state = 'visible'
2331
+ try { state = document.visibilityState === 'hidden' ? 'hidden' : 'visible' } catch (e) { state = 'visible' }
2332
+ if (state === 'hidden') { awayAt = nowMs(); return }
2333
+ if (awayAt === 0) return
2334
+ const away = nowMs() - awayAt
2335
+ awayAt = 0
2336
+ if (!(cfg.backAfterMs > 0) || away < cfg.backAfterMs) return
2337
+ wake(true)
2338
+ say(pick(away >= BACK_LONG_MS ? LINES.backLong : LINES.backSoon))
2339
+ }
2340
+ if (typeof document.addEventListener === 'function') {
2341
+ document.addEventListener('visibilitychange', onVisibility)
2342
+ }
2343
+
2226
2344
  attach()
2227
2345
  /* 打招呼按时段取:凌晨三点和上午十点不该说同一句话。时段在**挂载时**取一次 ——
2228
2346
  一个挂了一整天的页面不该在过零点后突然换口气,那反而像 bug。 */
@@ -2230,6 +2348,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2230
2348
  const greetName = greetPoolName(greetHour)
2231
2349
  clock.after(800, () => say(pick(greetPool(greetName))))
2232
2350
 
2351
+ /* 连续天数:第 2 天起才提(第一天说"连续第 1 天"很怪);到里程碑单独说一句。 */
2352
+ const streak = streakOf(memory.days)
2353
+ if (streak >= 2 && cfg.memory) {
2354
+ const hitMilestone = STREAK_MILESTONES.indexOf(streak) >= 0
2355
+ clock.after(2600, () => {
2356
+ if (destroyed) return
2357
+ const text = pick(hitMilestone ? LINES.milestone : LINES.streak).split('{n}').join(String(streak))
2358
+ flashHappy(text)
2359
+ })
2360
+ }
2361
+
2233
2362
  /* ---- 她更新了吗 --------------------------------------------------------
2234
2363
  插件不能联网,所以"检查更新"它做不了。但**它知道自己现在是哪一版** —— 于是能做到
2235
2364
  一件真事:上次见到的是旧版本,这次不是了,那就是更新过了,说一句。
@@ -2552,6 +2681,16 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2552
2681
  },
2553
2682
  /* 干饭:不用猜"她为什么突然捧碗" */
2554
2683
  hunger: { enabled: cfg.hungry === true, everyMs: cfg.hungerMs, lastMealAt: Math.round(lastMealAt) },
2684
+ /* 深夜:窗口是可配的,所以把算出来的窗口一起报出来 */
2685
+ night: {
2686
+ enabled: cfg.night === true,
2687
+ window: [cfg.bedtimeHour, cfg.wakeHour],
2688
+ deepFrom: deepHourOf(cfg.bedtimeHour),
2689
+ hour: hourNow(),
2690
+ lastAt: Math.round(lastNightAt),
2691
+ },
2692
+ streak: streakOf(memory.days),
2693
+ awaySince: Math.round(awayAt),
2555
2694
  /* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
2556
2695
  runsToday: { day: runsToday.day, count: runsToday.count, said: runsToday.said.slice() },
2557
2696
  /* 她记着你:一起多少天、习惯放在哪、常穿哪套、已经说过哪些 */
@@ -2592,6 +2731,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2592
2731
  clock.dispose()
2593
2732
  for (const ev of WAKE_EVENTS) window.removeEventListener(ev, wake)
2594
2733
  window.removeEventListener('resize', onResize)
2734
+ if (typeof document.removeEventListener === 'function') {
2735
+ document.removeEventListener('visibilitychange', onVisibility)
2736
+ }
2595
2737
  window.removeEventListener('pointermove', onMove)
2596
2738
  window.removeEventListener('pointerup', onUp)
2597
2739
  bodyEl.removeEventListener('pointerdown', onDown)
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
51
51
  var exports = module.exports
52
52
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
53
53
 
54
- const VERSION = '1.28.0'
54
+ const VERSION = '1.29.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -69,6 +69,7 @@ const WHATS_NEW = {
69
69
  '1.26.0': '检查更新同时读 npm 和 GitHub,取更高的那个版本。',
70
70
  '1.27.0': '她饿了会捧出一碗白米饭,还有第八个表情。',
71
71
  '1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
72
+ '1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
72
73
  }
73
74
 
74
75
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -164,6 +165,11 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
164
165
  careAfterMs: 5400000, // 连续活动这么久之后提醒你起来动动(90 分钟;0 = 关)
165
166
  hungry: true, // 干饭:她饿了会说
166
167
  hungerMs: 2700000, // 连续活动这么久就该吃一碗了(45 分钟;0 = 关)
168
+ night: true, // 深夜劝睡
169
+ bedtimeHour: 23, // 几点算深夜(0-23)
170
+ wakeHour: 5, // 几点算天亮(0-23);bedtime > wake 时窗口跨零点
171
+ nightMs: 1800000, // 深夜每隔这么久说一句(30 分钟;0 = 关)
172
+ backAfterMs: 120000, // 离开超过这么久,回来时她会说一句(2 分钟;0 = 关)
167
173
  milestones: true, // 今日轮次到达里程碑时得意一下
168
174
  memory: true, // 她记着你:一起多少天 / 常放哪个角 / 常穿哪套
169
175
  happyMs: 1100,
@@ -267,6 +273,31 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
267
273
  '算力不够了,得补点碳水。', '电量告急,切待机。', '你要不要也来一口?',
268
274
  '这碗不算加餐,算基础设施。', '吃饱了才有力气摸鱼。', '饭碗见底了,该续命了。', '我数着米粒呢。',
269
275
  ],
276
+ /* 深夜劝睡:23 点后一种语气,凌晨 1 点后另一种 —— 后者是社区设定里"嘴硬心软"那一档。
277
+ 分档按 bedtimeHour + 2 推导,所以改了作息这套分档也跟着走。 */
278
+ nightLate: [
279
+ '十一点了。', '该收的东西收一收吧。', '我不催你,时钟在催。', '这个点还在跑,明天会还债的。',
280
+ '夜里的屏幕最亮,眼睛最累。', '再干一会儿就收工?', '白天的你会感谢现在的你。', '我给你留着灯。',
281
+ '要不要把剩下的留到明天?', '深夜的算力也是算力,但人也得睡。', '我先打个盹,你随意。', '这时候的安静挺值钱的。',
282
+ ],
283
+ nightDeep: [
284
+ '一点了。', '我不管你,但……真的该睡了。', '明天再跑也一样,代码不会跑。', '再撑下去,明天会很难受。',
285
+ '才不是担心你,只是我不想半夜还看着屏幕。', '你要是倒下,我可没有备份。', '这个点做的决定,白天多半会后悔。',
286
+ '我数到十你就去睡。……一。', '熬夜是拿明天的算力换今晚的。', '天快亮了,你还在这儿。',
287
+ '我要是能关灯就好了。', '睡吧。我留在这儿。',
288
+ ],
289
+ /* 离开又回来:按走了多久分两档(阈值见 backAfterMs / BACK_LONG_MS)。 */
290
+ backSoon: [
291
+ '回来了。', '刚才去哪了。', '我在这儿等着呢。', '一眼没看见你。', '这么快就回来,水都没喝吧。', '位置我没动。',
292
+ ],
293
+ backLong: [
294
+ '你走了好久。', '差点以为今天见不到了。', '回来了就好。', '我一直在这儿。', '这么久,是去吃饭了吗?',
295
+ '屏幕都凉了。', '我还以为你把我关了。', '下次说一声,我就不数时间了。',
296
+ ],
297
+ /* 连续天数({n} 是天数) */
298
+ streak: [
299
+ '连续第 {n} 天了。', '第 {n} 天了,你还在。', '我们已经连着见了 {n} 天。', '第 {n} 天。我记着呢。', '{n} 天没断,挺厉害的。',
300
+ ],
270
301
  updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
271
302
  updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
272
303
  updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
@@ -283,6 +314,37 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
283
314
  hint: ['右键我一下试试。', '右键我有菜单。', '拖我,或者右键我。'],
284
315
  }
285
316
 
317
+ /* 深夜窗口。bedtime > wake 时它跨零点(默认 23→5);不跨也能用(比如 1→5)。 */
318
+ const isNightHour = (hour, from, to) => {
319
+ if (from === to) return false
320
+ return from > to ? (hour >= from || hour < to) : (hour >= from && hour < to)
321
+ }
322
+ /* "深度深夜"= 已经过了入睡时间两小时以上。按**经过的小时数**算,而不是按绕圈区间:
323
+ 后者在窄窗口(比如 13 点睡、14 点起)会把窗外的一大段时间也算进来。 */
324
+ const DEEP_AFTER_HOURS = 2
325
+ const hoursSince = (hour, from) => (((hour - from) % 24) + 24) % 24
326
+ const deepHourOf = (bedtime) => (Math.round(bedtime) + DEEP_AFTER_HOURS) % 24
327
+
328
+ /* 连续天数:从今天往回数,memory.days 里一天不落有多长。
329
+ 用毫秒回推而不是解析日期字符串 —— dayKey() 产出的 2026-9-30 不是 ISO 形式,
330
+ 各引擎对它的解析并不一致。 */
331
+ const dayKeyOf = (ms) => {
332
+ try {
333
+ const d = new Date(ms)
334
+ return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
335
+ } catch (e) { return 'unknown' }
336
+ }
337
+ const streakOf = (days) => {
338
+ if (!Array.isArray(days) || days.length === 0) return 0
339
+ const set = new Set(days)
340
+ let n = 0
341
+ let t = Date.now()
342
+ while (n < 400 && set.has(dayKeyOf(t))) { n++; t -= 86400000 }
343
+ return n
344
+ }
345
+ /* 连续天数的里程碑;到了才说一次。 */
346
+ const STREAK_MILESTONES = [7, 30, 100, 365]
347
+
286
348
  /* 时段划分:凌晨与白天说话的口气不一样。深夜跨了两天,所以按"< 5 点"算夜里。 */
287
349
  const greetPoolName = (hour) => {
288
350
  if (hour < 5 || hour >= 22) return 'night'
@@ -337,6 +399,11 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
337
399
  careAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
338
400
  hungry: { kind: 'bool' },
339
401
  hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
402
+ night: { kind: 'bool' },
403
+ bedtimeHour: { kind: 'num', lo: 0, hi: 23 },
404
+ wakeHour: { kind: 'num', lo: 0, hi: 23 },
405
+ nightMs: { kind: 'num', lo: 0, hi: 86400000 },
406
+ backAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
340
407
  milestones: { kind: 'bool' },
341
408
  memory: { kind: 'bool' },
342
409
  happyMs: { kind: 'num', lo: 200, hi: 10000 },
@@ -1340,6 +1407,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1340
1407
  let lastCareAt = 0
1341
1408
  /* 上一顿饭的时间。起手是 0,所以计时以 activeSince 为准 —— 见下面 mealBase。 */
1342
1409
  let lastMealAt = 0
1410
+ /* 上一次深夜劝睡;离开又回来的时间点。 */
1411
+ let lastNightAt = 0
1412
+ let awayAt = 0
1343
1413
  let happyHandle = null
1344
1414
  let lastErrorCount = 0
1345
1415
 
@@ -1379,6 +1449,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1379
1449
  pokedHandle = clock.after(POKED_MS, () => { if (mood === 'poked') setMood('idle') })
1380
1450
  }
1381
1451
 
1452
+ /* 打盹:深夜劝睡用它(一个哈欠比一条通知更像她)。睡着是瞬时状态,
1453
+ 自己到期就回稳态。 */
1454
+ const NAP_MS = 5200
1455
+ let napHandle = null
1456
+ const flashSleepy = (text) => {
1457
+ if (mood === 'sleep') wake(true)
1458
+ setMood('sleep')
1459
+ say(text)
1460
+ if (napHandle) clock.cancel(napHandle)
1461
+ napHandle = clock.after(NAP_MS, () => { if (mood === 'sleep') setMood('idle') })
1462
+ }
1463
+
1382
1464
  /* 干饭用的也是瞬时状态(借 eat 的姿态)。比"提醒你活动"长一点 ——
1383
1465
  吃饭本来就慢。 */
1384
1466
  const EAT_MS = 5200
@@ -1480,6 +1562,21 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1480
1562
  flashEat(pick(LINES.eat))
1481
1563
  }
1482
1564
 
1565
+ /* ---- 深夜劝睡 ---------------------------------------------------------
1566
+ 作息**是可配的**(bedtimeHour / wakeHour),所以它既是一项真功能,
1567
+ 也让测试不必伪造时钟 —— 把窗口套在当前那个小时上就能测。
1568
+ 深度深夜从 bedtime + 2 起算,语气换一档:从"该收了"变成"嘴硬心软"。 */
1569
+ if (cfg.night && cfg.nightMs > 0) {
1570
+ const hour = hourNow()
1571
+ const nightBase = Math.max(lastNightAt, activeSince)
1572
+ if (isNightHour(hour, cfg.bedtimeHour, cfg.wakeHour)
1573
+ && nowTick - nightBase >= cfg.nightMs) {
1574
+ lastNightAt = nowTick
1575
+ const deep = hoursSince(hour, cfg.bedtimeHour) >= DEEP_AFTER_HOURS
1576
+ flashSleepy(pick(deep ? LINES.nightDeep : LINES.nightLate))
1577
+ }
1578
+ }
1579
+
1483
1580
  // 有东西在等她处理时,优先级高于一切:这是唯一"你不理它就卡住"的状态。
1484
1581
  const pending = cfg.reactions ? readPending() : null
1485
1582
  if (pending === null) {
@@ -1798,6 +1895,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1798
1895
  { key: 'reactions', label: '跟随状态' },
1799
1896
  { key: 'celebrate', label: '跑完撒花' },
1800
1897
  { key: 'hungry', label: '饿了会说话' },
1898
+ { key: 'night', label: '深夜劝睡' },
1801
1899
  ]
1802
1900
 
1803
1901
  /* ---- 弹窗 ----------------------------------------------------------------
@@ -2172,6 +2270,26 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2172
2270
  }
2173
2271
  window.addEventListener('resize', onResize)
2174
2272
 
2273
+ /* ---- 你离开又回来 --------------------------------------------------------
2274
+ visibilitychange 是唯一能知道"你去忙别的了"的信号。离开时长按两个阈值分档:
2275
+ 短走一句、长走一句;backAfterMs: 0 就整个关掉。
2276
+ 注意监听要能摘掉(destroy 里 remove)—— 文档级监听泄漏过一次,记住这个教训。 */
2277
+ const BACK_LONG_MS = 900000
2278
+ const onVisibility = () => {
2279
+ let state = 'visible'
2280
+ try { state = document.visibilityState === 'hidden' ? 'hidden' : 'visible' } catch (e) { state = 'visible' }
2281
+ if (state === 'hidden') { awayAt = nowMs(); return }
2282
+ if (awayAt === 0) return
2283
+ const away = nowMs() - awayAt
2284
+ awayAt = 0
2285
+ if (!(cfg.backAfterMs > 0) || away < cfg.backAfterMs) return
2286
+ wake(true)
2287
+ say(pick(away >= BACK_LONG_MS ? LINES.backLong : LINES.backSoon))
2288
+ }
2289
+ if (typeof document.addEventListener === 'function') {
2290
+ document.addEventListener('visibilitychange', onVisibility)
2291
+ }
2292
+
2175
2293
  attach()
2176
2294
  /* 打招呼按时段取:凌晨三点和上午十点不该说同一句话。时段在**挂载时**取一次 ——
2177
2295
  一个挂了一整天的页面不该在过零点后突然换口气,那反而像 bug。 */
@@ -2179,6 +2297,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2179
2297
  const greetName = greetPoolName(greetHour)
2180
2298
  clock.after(800, () => say(pick(greetPool(greetName))))
2181
2299
 
2300
+ /* 连续天数:第 2 天起才提(第一天说"连续第 1 天"很怪);到里程碑单独说一句。 */
2301
+ const streak = streakOf(memory.days)
2302
+ if (streak >= 2 && cfg.memory) {
2303
+ const hitMilestone = STREAK_MILESTONES.indexOf(streak) >= 0
2304
+ clock.after(2600, () => {
2305
+ if (destroyed) return
2306
+ const text = pick(hitMilestone ? LINES.milestone : LINES.streak).split('{n}').join(String(streak))
2307
+ flashHappy(text)
2308
+ })
2309
+ }
2310
+
2182
2311
  /* ---- 她更新了吗 --------------------------------------------------------
2183
2312
  插件不能联网,所以"检查更新"它做不了。但**它知道自己现在是哪一版** —— 于是能做到
2184
2313
  一件真事:上次见到的是旧版本,这次不是了,那就是更新过了,说一句。
@@ -2501,6 +2630,16 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2501
2630
  },
2502
2631
  /* 干饭:不用猜"她为什么突然捧碗" */
2503
2632
  hunger: { enabled: cfg.hungry === true, everyMs: cfg.hungerMs, lastMealAt: Math.round(lastMealAt) },
2633
+ /* 深夜:窗口是可配的,所以把算出来的窗口一起报出来 */
2634
+ night: {
2635
+ enabled: cfg.night === true,
2636
+ window: [cfg.bedtimeHour, cfg.wakeHour],
2637
+ deepFrom: deepHourOf(cfg.bedtimeHour),
2638
+ hour: hourNow(),
2639
+ lastAt: Math.round(lastNightAt),
2640
+ },
2641
+ streak: streakOf(memory.days),
2642
+ awaySince: Math.round(awayAt),
2504
2643
  /* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
2505
2644
  runsToday: { day: runsToday.day, count: runsToday.count, said: runsToday.said.slice() },
2506
2645
  /* 她记着你:一起多少天、习惯放在哪、常穿哪套、已经说过哪些 */
@@ -2541,6 +2680,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2541
2680
  clock.dispose()
2542
2681
  for (const ev of WAKE_EVENTS) window.removeEventListener(ev, wake)
2543
2682
  window.removeEventListener('resize', onResize)
2683
+ if (typeof document.removeEventListener === 'function') {
2684
+ document.removeEventListener('visibilitychange', onVisibility)
2685
+ }
2544
2686
  window.removeEventListener('pointermove', onMove)
2545
2687
  window.removeEventListener('pointerup', onUp)
2546
2688
  bodyEl.removeEventListener('pointerdown', onDown)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/verify-wisp.mjs CHANGED
@@ -320,6 +320,7 @@ function createHarness(options = {}) {
320
320
  },
321
321
  dispatch(type, event) { for (const fn of [...(documentListeners[type] ?? [])]) fn(event ?? {}) },
322
322
  get documentListenerCount() { return Object.values(documentListeners).reduce((n, l) => n + l.length, 0) },
323
+ documentListenerCountOf(type) { return (documentListeners[type] ?? []).length },
323
324
  createElement: (t) => makeEl(t),
324
325
  getElementById: (id) => created.find((e) => e.id === id && !e.removed) ?? null,
325
326
  /* 采样点上的元素栈:按"最上面优先"返回。窗口背景那一段要靠它判断
@@ -2828,8 +2829,11 @@ head('3y. dialogs: about her, and the action preview')
2828
2829
  dlg.document.dispatch('keydown', { key: 'Escape', preventDefault() {} })
2829
2830
  check(dlgApi.dialog === null && dialogGone(), 'Escape closes it',
2830
2831
  `dialog=${String(dlgApi.dialog)} 监听数=${dlg.document.documentListenerCount}`)
2831
- check(dlg.document.documentListenerCount === 1,
2832
- 'exactly one document-level key listener exists', String(dlg.document.documentListenerCount))
2832
+ check(dlg.document.documentListenerCountOf('keydown') === 1,
2833
+ 'exactly one document-level key listener exists', String(dlg.document.documentListenerCountOf('keydown')))
2834
+ check(dlg.document.documentListenerCountOf('visibilitychange') === 1,
2835
+ 'and exactly one visibility listener (added for the come-back greeting)',
2836
+ String(dlg.document.documentListenerCountOf('visibilitychange')))
2833
2837
 
2834
2838
  dlgApi.openActions()
2835
2839
  check(dlgApi.dialog === 'actions', 'the action preview opens', String(dlgApi.dialog))
@@ -2860,8 +2864,9 @@ head('3y. dialogs: about her, and the action preview')
2860
2864
  check(dlgApi.dialog === null, 'clicking the backdrop closes it too')
2861
2865
 
2862
2866
  for (let i = 0; i < 3; i++) { dlgApi.openAbout(); dlgApi.closeDialog() }
2863
- check(dlg.document.documentListenerCount === 1,
2864
- 'opening and closing repeatedly does not stack listeners', String(dlg.document.documentListenerCount))
2867
+ check(dlg.document.documentListenerCountOf('keydown') === 1,
2868
+ 'opening and closing repeatedly does not stack listeners',
2869
+ String(dlg.document.documentListenerCountOf('keydown')))
2865
2870
 
2866
2871
  dlgApi.openAbout()
2867
2872
  dlgApi.destroy()
@@ -2869,6 +2874,196 @@ head('3y. dialogs: about her, and the action preview')
2869
2874
  active = keepDlg
2870
2875
  }
2871
2876
 
2877
+ /* ============================== 3y-2. night / come back / streak =========== */
2878
+ head('3y-2. night nagging, coming back, and the streak')
2879
+
2880
+ /* 这三件事都靠**可配置的时间**,所以不需要伪造时钟:
2881
+ 把深夜窗口套在当前那个小时上,行为就该发生;套偏一个小时,就不该发生。 */
2882
+ {
2883
+ const hourNowReal = new Date().getHours()
2884
+ const mk = (options) => {
2885
+ const h = createHarness(Object.assign({ timer: true, composerText: '在打字' }, options))
2886
+ h.evaluate(clientSrc)
2887
+ return h
2888
+ }
2889
+ const liveSaid = (h) => h.all('wisp-say').filter((el) => el.removed !== true).map((el) => el.textContent)
2890
+ const saidLines = (h) => liveSaid(h)
2891
+ /* 推进 ms,把过程中出现过的每一句话都收集起来。 */
2892
+ const watch = (h, ms, step = 250) => {
2893
+ const seen = new Set(liveSaid(h))
2894
+ for (let left = ms; left > 0; left -= step) {
2895
+ h.advance(Math.min(step, left), Math.min(step, 200))
2896
+ for (const t of liveSaid(h)) seen.add(t)
2897
+ }
2898
+ return [...seen]
2899
+ }
2900
+ const inPool = (pool, text) => Array.isArray(pool) && pool.includes(text)
2901
+
2902
+ /* ---- 深夜劝睡 ---------------------------------------------------------- */
2903
+ const night = mk()
2904
+ const keepNight = active
2905
+ active = night
2906
+ night.module().default.apply(night.ctx, {
2907
+ reactions: true, wander: false, celebrate: false,
2908
+ careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
2909
+ /* 让"现在"落在深夜窗口里:从当前小时开始,到下一个小时结束 */
2910
+ bedtimeHour: hourNowReal, wakeHour: (hourNowReal + 1) % 24, nightMs: 60000,
2911
+ })
2912
+ night.advance(1300, 100)
2913
+ const nightApi = night.win.__wisp
2914
+ check(nightApi.doctor().night.enabled === true
2915
+ && nightApi.doctor().night.deepFrom === (hourNowReal + 2) % 24,
2916
+ 'doctor reports the night window and where the deeper register starts',
2917
+ JSON.stringify(nightApi.doctor().night))
2918
+ const nightSaid = watch(night, 70000)
2919
+ check(nightSaid.some((t) => inPool(linesInBundle()?.nightLate, t)),
2920
+ 'inside the window she speaks up, from the late pool',
2921
+ nightSaid.slice(-2).join(' / ') || '(没说)')
2922
+ check(nightApi.doctor().night.lastAt > 0, 'and the check time is recorded',
2923
+ String(nightApi.doctor().night.lastAt))
2924
+
2925
+ /* 窗口外不该说话 */
2926
+ const day = mk()
2927
+ const keepDay = active
2928
+ active = day
2929
+ day.module().default.apply(day.ctx, {
2930
+ reactions: true, wander: false, celebrate: false,
2931
+ careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
2932
+ bedtimeHour: (hourNowReal + 3) % 24, wakeHour: (hourNowReal + 4) % 24, nightMs: 60000,
2933
+ })
2934
+ day.advance(1300, 100)
2935
+ const daySaid = watch(day, 200000)
2936
+ check(!daySaid.some((t) => inPool(linesInBundle()?.nightLate, t) || inPool(linesInBundle()?.nightDeep, t)),
2937
+ 'outside the window she says nothing about bedtime', saidLines(day).slice(-2).join(' / ') || '(没说)')
2938
+ day.win.__wisp.destroy()
2939
+ active = keepDay
2940
+
2941
+ /* 深度深夜:把 bedtime 往前挪两小时,当前小时就落进"嘴硬心软"那一档 */
2942
+ const deep = mk()
2943
+ const keepDeep = active
2944
+ active = deep
2945
+ deep.module().default.apply(deep.ctx, {
2946
+ reactions: true, wander: false, celebrate: false,
2947
+ careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
2948
+ bedtimeHour: (hourNowReal + 22) % 24, wakeHour: (hourNowReal + 1) % 24, nightMs: 60000,
2949
+ })
2950
+ deep.advance(1300, 100)
2951
+ const deepSaid = watch(deep, 70000)
2952
+ check(deepSaid.some((t) => inPool(linesInBundle()?.nightDeep, t)),
2953
+ 'two hours past bedtime the register changes', deepSaid.slice(-2).join(' / ') || '(没说)')
2954
+
2955
+ /* 两种关法 */
2956
+ const nightLines = () => [].concat(linesInBundle()?.nightLate ?? [], linesInBundle()?.nightDeep ?? [])
2957
+ deep.win.__wisp.configure({ night: false })
2958
+ check(!watch(deep, 300000).some((t) => nightLines().includes(t)),
2959
+ 'night: false turns the whole thing off')
2960
+ deep.win.__wisp.configure({ night: true, nightMs: 0 })
2961
+ check(!watch(deep, 300000).some((t) => nightLines().includes(t)), 'so does nightMs: 0')
2962
+ deep.win.__wisp.destroy()
2963
+ active = keepDeep
2964
+
2965
+ /* ---- 离开又回来 -------------------------------------------------------- */
2966
+ const back = mk()
2967
+ const keepBack = active
2968
+ active = back
2969
+ back.module().default.apply(back.ctx, {
2970
+ reactions: true, wander: false, celebrate: false, careAfterMs: 0, hungerMs: 0,
2971
+ sleepAfterMs: 3600000, backAfterMs: 60000,
2972
+ })
2973
+ back.advance(1300, 100)
2974
+ const backApi = back.win.__wisp
2975
+ /* 替身没有 visibilityState,直接摆上再派发事件 —— 这正是真浏览器里发生的事。 */
2976
+ back.document.visibilityState = 'hidden'
2977
+ back.document.dispatch('visibilitychange', {})
2978
+ back.advance(120000, 1000)
2979
+ back.document.visibilityState = 'visible'
2980
+ back.document.dispatch('visibilitychange', {})
2981
+ const shortBack = watch(back, 400)
2982
+ check(shortBack.some((t) => inPool(linesInBundle()?.backSoon, t)),
2983
+ 'coming back after a short while gets the short line', shortBack.slice(-2).join(' / ') || '(没说)')
2984
+
2985
+ back.document.visibilityState = 'hidden'
2986
+ back.document.dispatch('visibilitychange', {})
2987
+ back.advance(30 * 60000, 5000)
2988
+ back.document.visibilityState = 'visible'
2989
+ back.document.dispatch('visibilitychange', {})
2990
+ const longBack = watch(back, 400)
2991
+ check(longBack.some((t) => inPool(linesInBundle()?.backLong, t)),
2992
+ 'a long absence gets the other line', longBack.slice(-2).join(' / ') || '(没说)')
2993
+
2994
+ /* 走开一小会儿不该说话(只看"回来池",别把别的行为的话算进来) */
2995
+ const backLines = () => [].concat(linesInBundle()?.backSoon ?? [], linesInBundle()?.backLong ?? [])
2996
+ back.document.visibilityState = 'hidden'
2997
+ back.document.dispatch('visibilitychange', {})
2998
+ back.advance(5000, 1000)
2999
+ back.document.visibilityState = 'visible'
3000
+ back.document.dispatch('visibilitychange', {})
3001
+ check(!watch(back, 600).some((t) => backLines().includes(t)),
3002
+ 'a blink of absence is not worth a line')
3003
+
3004
+ back.win.__wisp.configure({ backAfterMs: 0 })
3005
+ back.document.visibilityState = 'hidden'
3006
+ back.document.dispatch('visibilitychange', {})
3007
+ back.advance(30 * 60000, 5000)
3008
+ back.document.visibilityState = 'visible'
3009
+ back.document.dispatch('visibilitychange', {})
3010
+ check(!watch(back, 600).some((t) => backLines().includes(t)),
3011
+ 'backAfterMs: 0 turns the greeting off')
3012
+ back.win.__wisp.destroy()
3013
+ active = keepBack
3014
+
3015
+ /* ---- 连续天数 ---------------------------------------------------------- */
3016
+ /* 用 localStorage 直接种一段连续的记录:今天 + 往前三天 = 连续 4 天。 */
3017
+ const keyFor = (offset) => {
3018
+ const d = new Date(Date.now() - offset * 86400000)
3019
+ return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
3020
+ }
3021
+ const memorySeed = {
3022
+ 'dsh-wisp:memory:v1': JSON.stringify({
3023
+ days: [keyFor(3), keyFor(2), keyFor(1), keyFor(0)],
3024
+ said: [], corners: {}, skins: {}, remarkDay: keyFor(0),
3025
+ }),
3026
+ }
3027
+ const streak = mk({ storageSeed: memorySeed })
3028
+ const keepStreak = active
3029
+ active = streak
3030
+ streak.module().default.apply(streak.ctx, {
3031
+ reactions: true, wander: false, celebrate: false, careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
3032
+ })
3033
+ const streakSaid = watch(streak, 5000)
3034
+ const streakApi = streak.win.__wisp
3035
+ check(streakApi.doctor().streak === 4, 'doctor reports a four-day streak', String(streakApi.doctor().streak))
3036
+ const streakPool = [].concat(linesInBundle()?.streak ?? [], linesInBundle()?.milestone ?? [])
3037
+ .map((t) => t.split('{n}').join('4'))
3038
+ check(streakSaid.some((t) => streakPool.includes(t)),
3039
+ 'and she mentions it the next time you meet', streakSaid.slice(-3).join(' / ') || '(没说)')
3040
+ streak.win.__wisp.destroy()
3041
+ active = keepStreak
3042
+
3043
+ /* 断了就从头数:今天之前缺一天 */
3044
+ const brokenSeed = {
3045
+ 'dsh-wisp:memory:v1': JSON.stringify({
3046
+ days: [keyFor(5), keyFor(4), keyFor(0)], said: [], corners: {}, skins: {}, remarkDay: keyFor(0),
3047
+ }),
3048
+ }
3049
+ const broken = mk({ storageSeed: brokenSeed })
3050
+ const keepBroken = active
3051
+ active = broken
3052
+ broken.module().default.apply(broken.ctx, {
3053
+ reactions: true, wander: false, celebrate: false, careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
3054
+ })
3055
+ const brokenSaid = watch(broken, 5000)
3056
+ check(broken.win.__wisp.doctor().streak === 1,
3057
+ 'a gap resets it to one (and one is not worth mentioning)',
3058
+ String(broken.win.__wisp.doctor().streak))
3059
+ const streakPool2 = [].concat(linesInBundle()?.streak ?? [], linesInBundle()?.milestone ?? [])
3060
+ .map((t) => t.split('{n}').join('1'))
3061
+ check(!brokenSaid.some((t) => streakPool2.includes(t)),
3062
+ 'so she stays quiet about it', brokenSaid.slice(-2).join(' / ') || '(没说)')
3063
+ broken.win.__wisp.destroy()
3064
+ active = keepBroken
3065
+ }
3066
+
2872
3067
  /* ================================================== 3z. generation audit ==== */
2873
3068
  head('3z. every shipped sprite traces back to a text-only call')
2874
3069