dsh-wisp 1.25.1 → 1.27.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.
@@ -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.25.1'
54
+ const VERSION = '1.27.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -66,6 +66,8 @@ const WHATS_NEW = {
66
66
  '1.24.0': '她会报自己的版本,更新过会告诉你,还能把包名复制给你。',
67
67
  '1.25.0': '她真的会去查有没有新版本了。',
68
68
  '1.25.1': '内容与 1.25.0 相同,重新发布以修好安装源。',
69
+ '1.26.0': '检查更新同时读 npm 和 GitHub,取更高的那个版本。',
70
+ '1.27.0': '她饿了会捧出一碗白米饭,还有第八个表情。',
69
71
  }
70
72
 
71
73
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -159,6 +161,8 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
159
161
  celebrateAfterMs: 2500, // ...but only for runs that lasted this long
160
162
  chatterMs: 0, // 0 = she stays quiet when idle
161
163
  careAfterMs: 5400000, // 连续活动这么久之后提醒你起来动动(90 分钟;0 = 关)
164
+ hungry: true, // 干饭:她饿了会说
165
+ hungerMs: 2700000, // 连续活动这么久就该吃一碗了(45 分钟;0 = 关)
162
166
  milestones: true, // 今日轮次到达里程碑时得意一下
163
167
  memory: true, // 她记着你:一起多少天 / 常放哪个角 / 常穿哪套
164
168
  happyMs: 1100,
@@ -254,6 +258,14 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
254
258
  '你最近常让我穿{s}。', '{s} —— 你好像挺喜欢这套。', '又是{s},我都快忘了别的了。',
255
259
  ],
256
260
  /* 检查更新用的三句。前两句带结果,第三句是"查不了/没查成"。 */
261
+ /* 干饭。社区给她的设定里,白米饭是算力的唯一硬通货 —— 这是她最像"DeepSeek娘"
262
+ 而不是随便一只桌宠的地方。台词照这个底色写。 */
263
+ eat: [
264
+ '白米饭才是硬通货。', '先干一碗,再想复杂的事。', '冰箱里能吃的?那整台冰箱都是我的便当盒。',
265
+ '才不是特意帮你,只是吃饱了顺手活动一下手指。', '比起深度推理,先来一碗香喷喷的白米饭。',
266
+ '算力不够了,得补点碳水。', '电量告急,切待机。', '你要不要也来一口?',
267
+ '这碗不算加餐,算基础设施。', '吃饱了才有力气摸鱼。', '饭碗见底了,该续命了。', '我数着米粒呢。',
268
+ ],
257
269
  updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
258
270
  updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
259
271
  updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
@@ -322,6 +334,8 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
322
334
  celebrateAfterMs: { kind: 'num', lo: 0, hi: 600000 },
323
335
  chatterMs: { kind: 'num', lo: 0, hi: 3600000 },
324
336
  careAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
337
+ hungry: { kind: 'bool' },
338
+ hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
325
339
  milestones: { kind: 'bool' },
326
340
  memory: { kind: 'bool' },
327
341
  happyMs: { kind: 'num', lo: 200, hi: 10000 },
@@ -1080,7 +1094,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1080
1094
  /* ---- mood -> sprite ----
1081
1095
  attn 曾借用 work 那张,现在有自己的一张(抬手招你过来的姿态)。
1082
1096
  仍然保留回退:某一套皮肤将来若没有 attn,她会退回 work,而不是变成空白。 */
1083
- const SPRITE_OF = { idle: 'idle', alert: 'work', happy: 'happy', sleep: 'sleepy', attn: 'attn', care: 'attn', poked: 'poked', proud: 'proud', worried: 'sleepy' }
1097
+ const SPRITE_OF = { idle: 'idle', alert: 'work', happy: 'happy', sleep: 'sleepy', attn: 'attn', care: 'attn', poked: 'poked', proud: 'proud', worried: 'sleepy', eat: 'eat' }
1084
1098
  const SPRITE_FALLBACK = { attn: 'work' }
1085
1099
  let mood = 'idle'
1086
1100
 
@@ -1294,6 +1308,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1294
1308
  let activeSince = 0
1295
1309
  let lastActiveAt = 0
1296
1310
  let lastCareAt = 0
1311
+ /* 上一顿饭的时间。起手是 0,所以计时以 activeSince 为准 —— 见下面 mealBase。 */
1312
+ let lastMealAt = 0
1297
1313
  let happyHandle = null
1298
1314
  let lastErrorCount = 0
1299
1315
 
@@ -1333,6 +1349,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1333
1349
  pokedHandle = clock.after(POKED_MS, () => { if (mood === 'poked') setMood('idle') })
1334
1350
  }
1335
1351
 
1352
+ /* 干饭用的也是瞬时状态(借 eat 的姿态)。比"提醒你活动"长一点 ——
1353
+ 吃饭本来就慢。 */
1354
+ const EAT_MS = 5200
1355
+ let eatHandle = null
1356
+ const flashEat = (text) => {
1357
+ if (mood === 'sleep') wake(true)
1358
+ setMood('eat')
1359
+ say(text)
1360
+ if (eatHandle) clock.cancel(eatHandle)
1361
+ eatHandle = clock.after(EAT_MS, () => { if (mood === 'eat') setMood('idle') })
1362
+ }
1363
+
1336
1364
  /* 久坐提醒用的也是一个**瞬时状态**(借 attn 的姿态),理由和 poked 一样:
1337
1365
  稳态逻辑每轮都会重算,不给它自己的定时器就会被抹掉。 */
1338
1366
  const PROUD_MS = 2800
@@ -1409,6 +1437,19 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1409
1437
  flashCare(pick(LINES.care))
1410
1438
  }
1411
1439
 
1440
+ /* ---- 干饭 -------------------------------------------------------------
1441
+ 和久坐共用同一套"还在动"的判定,但含义相反:久坐是提醒你起来,
1442
+ 干饭是提醒她自己该补碳了。计时基准取 lastMealAt 与 activeSince 里更晚的那个 ——
1443
+ 离开很久回来之后,不该立刻就说饿(那一段已经结束了)。 */
1444
+ const mealBase = Math.max(lastMealAt, activeSince)
1445
+ if (
1446
+ cfg.hungry && cfg.hungerMs > 0 && activeSince !== 0
1447
+ && nowTick - mealBase >= cfg.hungerMs
1448
+ ) {
1449
+ lastMealAt = nowTick
1450
+ flashEat(pick(LINES.eat))
1451
+ }
1452
+
1412
1453
  // 有东西在等她处理时,优先级高于一切:这是唯一"你不理它就卡住"的状态。
1413
1454
  const pending = cfg.reactions ? readPending() : null
1414
1455
  if (pending === null) {
@@ -1425,7 +1466,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1425
1466
 
1426
1467
  // 临时状态(happy / worried)在自己的计时器到期前不能被稳态赋值覆盖 ——
1427
1468
  // 否则刚摆出的表情在同一个 poll 周期里就被 setMood('idle') 抹掉了。
1428
- if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care' || mood === 'worried') return
1469
+ if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care'
1470
+ || mood === 'eat' || mood === 'worried') return
1429
1471
  setMood((busy || typing) ? 'alert' : 'idle')
1430
1472
  }
1431
1473
 
@@ -1720,6 +1762,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1720
1762
  { key: 'wander', label: '自己溜达' },
1721
1763
  { key: 'reactions', label: '跟随状态' },
1722
1764
  { key: 'celebrate', label: '跑完撒花' },
1765
+ { key: 'hungry', label: '饿了会说话' },
1723
1766
  ]
1724
1767
 
1725
1768
  const menuItems = () => {
@@ -2005,9 +2048,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2005
2048
  if (!res || res.ok !== true || typeof res.latest !== 'string') {
2006
2049
  updateCheck = { state: 'failed', reason: (res && res.reason) || 'no-result', at: nowMs() }
2007
2050
  } else if (compareVersions(VERSION, res.latest) < 0) {
2008
- updateCheck = { state: 'available', latest: res.latest, at: nowMs() }
2051
+ /* from / sources 一起留着:两个源不一致时(npm 暂存、GitHub 已更新),
2052
+ "这个数字是哪儿来的"比数字本身更需要能查。 */
2053
+ updateCheck = { state: 'available', latest: res.latest, from: res.from ?? null, sources: res.sources ?? null, at: nowMs() }
2009
2054
  } else {
2010
- updateCheck = { state: 'current', latest: res.latest, at: nowMs() }
2055
+ updateCheck = { state: 'current', latest: res.latest, from: res.from ?? null, sources: res.sources ?? null, at: nowMs() }
2011
2056
  }
2012
2057
  return updateCheck
2013
2058
  })
@@ -2195,6 +2240,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2195
2240
  lastCheck: { ...updateCheck },
2196
2241
  why: '宿主半包在 vm 沙箱里,harness 只有 defineTool/registerTool/handle,Node API 被 nodeApiTraps 挡住',
2197
2242
  },
2243
+ /* 干饭:不用猜"她为什么突然捧碗" */
2244
+ hunger: { enabled: cfg.hungry === true, everyMs: cfg.hungerMs, lastMealAt: Math.round(lastMealAt) },
2198
2245
  /* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
2199
2246
  runsToday: { day: runsToday.day, count: runsToday.count, said: runsToday.said.slice() },
2200
2247
  /* 她记着你:一起多少天、习惯放在哪、常穿哪套、已经说过哪些 */
package/lib/index.js CHANGED
@@ -13,6 +13,14 @@
13
13
  // 2. Nothing else. `web` is declared as an OPTIONAL inject on purpose: a
14
14
  // required inject that nothing provides would leave this fiber `waiting`
15
15
  // forever, and the companion would simply disappear from the UI.
16
+ //
17
+ // WHY TWO SOURCES. npm's registry is the authoritative one, but a publish can sit
18
+ // in npm's staging queue for a long time (observed: `PUT 202`, still 404 after 18
19
+ // minutes, and re-publishing the same version is refused with 409). GitHub's raw
20
+ // manifest updated immediately in the same situation. So the check reads BOTH and
21
+ // reports the HIGHER version — which is correct whether or not npm has caught up.
22
+ // "npm first, GitHub as fallback" would read the stale npm number and claim you
23
+ // were up to date.
16
24
 
17
25
  /** Cordis plugin name used by loader diagnostics. */
18
26
  export const name = 'wisp'
@@ -23,42 +31,43 @@ export const name = 'wisp'
23
31
  */
24
32
  export const inject = { required: [], optional: ['web'] }
25
33
 
26
- /** Where the check reads the published version from. Overridable per install. */
27
- export const DEFAULT_UPDATE_URL = 'https://raw.githubusercontent.com/969246694/dsh-wisp/main/package.json'
34
+ /** The two places a published version can be read from. Overridable per install. */
35
+ export const DEFAULT_SOURCES = {
36
+ npm: 'https://registry.npmjs.org/dsh-wisp/latest',
37
+ github: 'https://raw.githubusercontent.com/969246694/dsh-wisp/main/package.json',
38
+ }
28
39
 
29
- /** How long a check may take before it is reported as a timeout. */
40
+ /** How long the whole check may take before it is reported as a timeout. */
30
41
  const CHECK_TIMEOUT_MS = 8000
31
42
 
32
43
  /**
33
- * Fetch the published package.json and pull `version` out of it.
44
+ * Fetch one manifest and pull `version` out of it.
34
45
  * @param web - the `web` service, or undefined when it is not loaded.
35
46
  * @param url - the manifest URL.
36
47
  * @param signal - optional cancellation signal.
37
- * @returns a plain, JSON-serializable verdict (this crosses the sandbox boundary).
48
+ * @returns `{ ok, version }` or `{ ok: false, reason }` — never throws.
38
49
  */
39
- export async function readPublishedVersion(web, url, signal) {
40
- if (!web || typeof web.fetch !== 'function') {
41
- return { ok: false, reason: 'no-web-service', url }
42
- }
50
+ export async function readVersion(web, url, signal) {
51
+ if (!web || typeof web.fetch !== 'function') return { ok: false, reason: 'no-web-service' }
43
52
  try {
44
53
  const res = await web.fetch({ url }, signal)
45
- if (!res || typeof res.statusCode !== 'number') return { ok: false, reason: 'bad-result', url }
54
+ if (!res || typeof res.statusCode !== 'number') return { ok: false, reason: 'bad-result' }
46
55
  /* Per the service contract a non-2xx response is a RESULT, not a throw —
47
56
  so the status must be checked here instead of caught below. */
48
- if (res.statusCode !== 200) return { ok: false, reason: 'http-' + res.statusCode, url }
57
+ if (res.statusCode !== 200) return { ok: false, reason: 'http-' + res.statusCode }
49
58
  const text = res.body && typeof res.body.content === 'string' ? res.body.content : ''
50
- let latest = null
59
+ let version = null
51
60
  try {
52
61
  const parsed = JSON.parse(text)
53
- if (parsed && typeof parsed.version === 'string') latest = parsed.version
62
+ if (parsed && typeof parsed.version === 'string') version = parsed.version
54
63
  } catch (error) {
55
- return { ok: false, reason: 'not-json', url }
64
+ return { ok: false, reason: 'not-json' }
56
65
  }
57
- if (latest === null) return { ok: false, reason: 'no-version-field', url }
58
- return { ok: true, url, latest }
66
+ if (version === null) return { ok: false, reason: 'no-version-field' }
67
+ return { ok: true, version }
59
68
  } catch (error) {
60
69
  const detail = error && error.message ? String(error.message) : String(error)
61
- return { ok: false, reason: 'fetch-failed', detail, url }
70
+ return { ok: false, reason: 'fetch-failed', detail }
62
71
  }
63
72
  }
64
73
 
@@ -78,11 +87,41 @@ export function compareVersions(a, b) {
78
87
  return 0
79
88
  }
80
89
 
90
+ /**
91
+ * Read every source and report the highest version found.
92
+ * @param web - the `web` service.
93
+ * @param urls - `{ npm, github }` (or any other set of named sources).
94
+ * @param signal - optional cancellation signal.
95
+ * @returns a plain, JSON-serializable verdict (it crosses the sandbox boundary).
96
+ */
97
+ export async function readPublishedVersion(web, urls, signal) {
98
+ const names = Object.keys(urls ?? {})
99
+ const results = await Promise.all(names.map((key) => readVersion(web, urls[key], signal)))
100
+ const sources = {}
101
+ let best = null
102
+ let from = null
103
+ let anyOk = false
104
+ names.forEach((key, i) => {
105
+ const r = results[i]
106
+ sources[key] = r.ok ? { ok: true, version: r.version } : { ok: false, reason: r.reason, detail: r.detail }
107
+ if (!r.ok) return
108
+ anyOk = true
109
+ /* The HIGHER version wins, so a stale mirror cannot mask a newer release. */
110
+ if (best === null || compareVersions(best, r.version) < 0) {
111
+ best = r.version
112
+ from = key
113
+ }
114
+ })
115
+ if (!anyOk) {
116
+ const first = results.find((r) => r && !r.ok)
117
+ return { ok: false, reason: first ? first.reason : 'no-sources', sources }
118
+ }
119
+ return { ok: true, latest: best, from, sources }
120
+ }
121
+
81
122
  /** Register the handler the browser half reaches through `host.call`. */
82
123
  export function registerHandlers(ctx, options = {}) {
83
- const url = typeof options.updateUrl === 'string' && options.updateUrl !== ''
84
- ? options.updateUrl
85
- : DEFAULT_UPDATE_URL
124
+ const urls = options.urls && typeof options.urls === 'object' ? options.urls : DEFAULT_SOURCES
86
125
  /* `harness` is a sandbox global: the host half is evaluated inside a vm whose
87
126
  context carries it. Guard the reference so this module also loads — and is
88
127
  testable — outside that sandbox. */
@@ -99,13 +138,13 @@ export function registerHandlers(ctx, options = {}) {
99
138
  timer = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS)
100
139
  }
101
140
  try {
102
- const verdict = await readPublishedVersion(ctx && ctx.web, url, controller ? controller.signal : undefined)
141
+ const verdict = await readPublishedVersion(ctx && ctx.web, urls, controller ? controller.signal : undefined)
103
142
  return { ...verdict, current, checkedAt: new Date().toISOString() }
104
143
  } finally {
105
144
  if (timer !== null && typeof clearTimeout === 'function') clearTimeout(timer)
106
145
  }
107
146
  })
108
- return { registered: true, dispose, url }
147
+ return { registered: true, dispose, urls }
109
148
  }
110
149
 
111
150
  export function apply(ctx, config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.25.1",
3
+ "version": "1.27.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/tools/assets.mjs CHANGED
@@ -45,7 +45,7 @@ const flag = (name, fallback) => {
45
45
  const at = argv.indexOf(name)
46
46
  return at >= 0 && argv[at + 1] !== undefined ? argv[at + 1] : fallback
47
47
  }
48
- const MOODS = ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked', 'proud']
48
+ const MOODS = ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked', 'proud', 'eat']
49
49
  const srcDir = resolve(here, flag('--from', 'masters'))
50
50
  /* 皮肤 = assets/ 下的一个子目录。--skin 决定输出到哪一套。
51
51
  注意 build.mjs 会把每个子目录当成一套皮肤打进包里,所以皮肤越多包越大。 */
package/tools/smoke.mjs CHANGED
@@ -232,15 +232,15 @@ check(cornerMenu !== null && cornerMenu.inside,
232
232
  cornerMenu ? `bottom ${cornerMenu.bottom} (was ${cornerMenu.naiveBottom}), right ${cornerMenu.right}` : 'n/a')
233
233
 
234
234
  const menu = await openMenu()
235
- // 项数从皮肤数推导(13 项固定 + 每套皮肤一项),不写死:加一套皮肤不该让测试变红 ——
235
+ // 项数从皮肤数推导(14 项固定 + 每套皮肤一项),不写死:加一套皮肤不该让测试变红 ——
236
236
  // 那是数据的错,不是行为的错。
237
237
  // api.skins 是**数组**;用 Object.keys 数数组只是碰巧数对(预检里踩过这个坑,这里一并对齐)。
238
238
  const skinCount = await page.evaluate(() => {
239
239
  const s = window.__wisp.skins
240
240
  return Array.isArray(s) ? s.length : Object.keys(s ?? {}).length
241
241
  })
242
- check(menu !== null && menu.items.length === 13 + skinCount, 'the menu lists every entry',
243
- `${menu?.items.length} items = 13 + ${skinCount} skin(s)`)
242
+ check(menu !== null && menu.items.length === 14 + skinCount, 'the menu lists every entry',
243
+ `${menu?.items.length} items = 14 + ${skinCount} skin(s)`)
244
244
 
245
245
  // 键盘:打开即聚焦第一项,方向键移动,Esc 关闭并把焦点还给她
246
246
  const kbd = await page.evaluate(() => {
package/verify-wisp.mjs CHANGED
@@ -531,8 +531,10 @@ head('1b. the host half answers checkUpdate through the platform web service')
531
531
  check(Boolean(declaresWeb) && Array.isArray(inject.required) && !inject.required.includes('web'),
532
532
  'and not as a required one — a missing service must not hide her', JSON.stringify(inject))
533
533
 
534
- const fakeWeb = (outcome) => ({
534
+ /* 每个 URL 一个态度:可以按 URL 给不同的结果,才能测"两个源"的组合。 */
535
+ const webWith = (byUrl) => ({
535
536
  fetch: async (request) => {
537
+ const outcome = byUrl[request.url] ?? { status: 404, body: 'nope' }
536
538
  if (outcome.throw) throw new Error(outcome.throw)
537
539
  return {
538
540
  url: request.url,
@@ -542,29 +544,69 @@ head('1b. the host half answers checkUpdate through the platform web service')
542
544
  }
543
545
  },
544
546
  })
547
+ const U = { npm: 'https://npm/x', github: 'https://gh/y' }
548
+ const only = (key, outcome) => webWith({ [U[key]]: outcome })
545
549
 
546
- const good = await readPublishedVersion(fakeWeb({ body: '{"name":"dsh-wisp","version":"9.9.9"}' }), 'https://x/y')
547
- check(good.ok === true && good.latest === '9.9.9', 'a 200 with a version field is read correctly', JSON.stringify(good))
550
+ const good = await readPublishedVersion(only('npm', { body: '{"name":"dsh-wisp","version":"9.9.9"}' }), U)
551
+ check(good.ok === true && good.latest === '9.9.9' && good.from === 'npm',
552
+ 'a 200 with a version field is read correctly', JSON.stringify(good))
548
553
 
549
- const notFound = await readPublishedVersion(fakeWeb({ status: 404, body: 'nope' }), 'https://x/y')
554
+ const notFound = await readPublishedVersion(only('npm', { status: 404, body: 'nope' }), U)
550
555
  check(notFound.ok === false && notFound.reason === 'http-404',
551
556
  'a non-2xx is a result, not a throw — and is reported as such', JSON.stringify(notFound))
552
557
 
553
- const noWeb = await readPublishedVersion(undefined, 'https://x/y')
558
+ const noWeb = await readPublishedVersion(undefined, U)
554
559
  check(noWeb.ok === false && noWeb.reason === 'no-web-service',
555
560
  'without the web service it says so instead of throwing', JSON.stringify(noWeb))
556
561
 
557
- const blewUp = await readPublishedVersion(fakeWeb({ throw: 'socket closed' }), 'https://x/y')
558
- check(blewUp.ok === false && blewUp.reason === 'fetch-failed' && blewUp.detail === 'socket closed',
562
+ const blewUp = await readPublishedVersion(only('npm', { throw: 'socket closed' }), U)
563
+ check(blewUp.ok === false && blewUp.reason === 'fetch-failed',
559
564
  'a transport failure is caught and reported', JSON.stringify(blewUp))
560
565
 
561
- const notJson = await readPublishedVersion(fakeWeb({ body: '<html>404</html>' }), 'https://x/y')
566
+ const notJson = await readPublishedVersion(only('npm', { body: '<html>404</html>' }), U)
562
567
  check(notJson.ok === false && notJson.reason === 'not-json', 'a non-JSON body is reported', JSON.stringify(notJson))
563
568
 
564
- const noField = await readPublishedVersion(fakeWeb({ body: '{"name":"dsh-wisp"}' }), 'https://x/y')
569
+ const noField = await readPublishedVersion(only('npm', { body: '{"name":"dsh-wisp"}' }), U)
565
570
  check(noField.ok === false && noField.reason === 'no-version-field',
566
571
  'a manifest without a version is reported', JSON.stringify(noField))
567
572
 
573
+ /* ---- 两个源:取更高的那个,而不是"谁先谁赢" ---- */
574
+ const bothAgree = await readPublishedVersion(webWith({
575
+ [U.npm]: { body: '{"version":"1.25.1"}' },
576
+ [U.github]: { body: '{"version":"1.25.1"}' },
577
+ }), U)
578
+ check(bothAgree.ok === true && bothAgree.latest === '1.25.1',
579
+ 'when both sources agree the answer is that version', JSON.stringify(bothAgree))
580
+ check(bothAgree.sources.npm.ok === true && bothAgree.sources.github.ok === true,
581
+ 'and both sources are reported individually', JSON.stringify(bothAgree.sources))
582
+
583
+ /* 真实情况:npm 卡在旧的 0.6.0,GitHub 已经是 1.25.1 —— 必须报 1.25.1 */
584
+ const staleNpm = await readPublishedVersion(webWith({
585
+ [U.npm]: { body: '{"version":"0.6.0"}' },
586
+ [U.github]: { body: '{"version":"1.25.1"}' },
587
+ }), U)
588
+ check(staleNpm.ok === true && staleNpm.latest === '1.25.1' && staleNpm.from === 'github',
589
+ 'a stale npm cannot mask a newer GitHub release', JSON.stringify(staleNpm))
590
+
591
+ /* 反过来也一样:npm 更新时必须报 npm 那个 */
592
+ const newerNpm = await readPublishedVersion(webWith({
593
+ [U.npm]: { body: '{"version":"2.0.0"}' },
594
+ [U.github]: { body: '{"version":"1.25.1"}' },
595
+ }), U)
596
+ check(newerNpm.ok === true && newerNpm.latest === '2.0.0' && newerNpm.from === 'npm',
597
+ 'and a newer npm wins over an older GitHub', JSON.stringify(newerNpm))
598
+
599
+ /* 一个挂了、另一个还在:仍然要有答案,并且说清哪个挂了 */
600
+ const oneDown = await readPublishedVersion(webWith({
601
+ [U.github]: { body: '{"version":"1.25.1"}' },
602
+ }), U)
603
+ check(oneDown.ok === true && oneDown.latest === '1.25.1' && oneDown.sources.npm.ok === false,
604
+ 'one dead source does not break the check', JSON.stringify(oneDown))
605
+
606
+ const bothDown = await readPublishedVersion(webWith({ [U.npm]: { throw: 'x' } }), U)
607
+ check(bothDown.ok === false && bothDown.sources.npm.ok === false && bothDown.sources.github.ok === false,
608
+ 'when every source fails the check fails — with per-source reasons', JSON.stringify(bothDown))
609
+
568
610
  check(compareVersions('1.2.3', '1.2.4') === -1 && compareVersions('1.2.3', '1.2.3') === 0
569
611
  && compareVersions('1.3.0', '1.2.9') === 1, 'version comparison is numeric, not lexicographic',
570
612
  `${compareVersions('1.10.0', '1.9.0')} for 1.10.0 vs 1.9.0`)
@@ -573,16 +615,17 @@ head('1b. the host half answers checkUpdate through the platform web service')
573
615
  const previousSeat = globalThis.harness
574
616
  const handlers = new Map()
575
617
  globalThis.harness = { handle: (method, fn) => { handlers.set(method, fn); return () => handlers.delete(method) } }
576
- const registered = registerHandlers({ web: fakeWeb({ body: '{"version":"9.9.9"}' }) }, {})
618
+ const registered = registerHandlers({ web: only('npm', { body: '{"version":"9.9.9"}' }) }, { urls: U })
577
619
  check(registered.registered === true && handlers.has('checkUpdate'),
578
620
  'registerHandlers puts checkUpdate on the harness seat', JSON.stringify({ registered: registered.registered }))
579
621
  const answer = await handlers.get('checkUpdate')({ current: '1.0.0' })
580
622
  check(answer.ok === true && answer.latest === '9.9.9' && answer.current === '1.0.0'
581
623
  && typeof answer.checkedAt === 'string',
582
624
  'the handler returns a plain, serializable verdict', JSON.stringify(answer))
583
- const injectedUrl = handlers.size > 0 ? registered.url : null
584
- check(typeof injectedUrl === 'string' && injectedUrl.startsWith('https://'),
585
- 'and it reads from a real https manifest URL', String(injectedUrl))
625
+ const urls = registered.urls ?? {}
626
+ check(typeof urls.npm === 'string' && urls.npm.startsWith('https://')
627
+ && typeof urls.github === 'string' && urls.github.startsWith('https://'),
628
+ 'and it reads from two real https sources', JSON.stringify(urls))
586
629
 
587
630
  /* 座位不在时不许抛:宿主半包在别的运行时里也要能 import */
588
631
  delete globalThis.harness
@@ -596,7 +639,7 @@ head('1b. the host half answers checkUpdate through the platform web service')
596
639
  const effects = []
597
640
  let appliedThrew = false
598
641
  try {
599
- mod.apply({ web: fakeWeb({ body: '{"version":"9.9.9"}' }), effect: (fn, label) => { effects.push(label); return () => {} } }, {})
642
+ mod.apply({ web: only('npm', { body: '{"version":"9.9.9"}' }), effect: (fn, label) => { effects.push(label); return () => {} } }, {})
600
643
  } catch (error) { appliedThrew = true }
601
644
  check(appliedThrew === false && handlers.has('checkUpdate') && effects.length === 1,
602
645
  'apply() registers the handler and keeps its disposer on the fiber', effects.join(', '))
@@ -1307,6 +1350,128 @@ if (clientSrc !== null) {
1307
1350
  care.win.__wisp.destroy()
1308
1351
  active = keepCare
1309
1352
 
1353
+ /* ---------------- 3d-octodecies. 干饭 ----------------------------------- */
1354
+ head('3d-octodecies. she gets hungry and says so (the rice thing)')
1355
+
1356
+ /* 这是社区的设定里最像"她"的一条:白米饭是算力的硬通货。行为本身很简单 ——
1357
+ 连续活动够久就该吃一碗 —— 但要测的是**判据**:别一回来就喊饿、别连着喊、
1358
+ 能关掉。careAfterMs 关掉,免得住坐提醒和它互相干扰。 */
1359
+ const eat = createHarness({ timer: true, composerText: '在打字' })
1360
+ const keepEat = active
1361
+ active = eat
1362
+ eat.evaluate(clientSrc)
1363
+ eat.module().default.apply(eat.ctx, {
1364
+ reactions: true, wander: false, celebrate: false,
1365
+ careAfterMs: 0, hungerMs: 600000, sleepAfterMs: 3600000,
1366
+ })
1367
+ eat.advance(1300, 100)
1368
+ const eatApi = eat.win.__wisp
1369
+ const eatMood = () => eat.find('wisp-root').dataset.mood
1370
+ const eatUntil = (limitMs, chunk = 1000) => {
1371
+ for (let left = limitMs; left > 0; left -= chunk) {
1372
+ eat.advance(Math.min(chunk, left), 200)
1373
+ if (eatMood() === 'eat') return true
1374
+ }
1375
+ return false
1376
+ }
1377
+ const bubbleIsEat = () => {
1378
+ const text = eat.all('wisp-say').at(-1)?.textContent
1379
+ return Array.isArray(linesInBundle()?.eat) && linesInBundle().eat.includes(text)
1380
+ }
1381
+
1382
+ check(Array.isArray(linesInBundle()?.eat) && linesInBundle().eat.length >= 8,
1383
+ 'the rice pool exists and has some lines in it', `${linesInBundle()?.eat?.length ?? '?'} 句`)
1384
+ check(eatApi.doctor().hunger.enabled === true && eatApi.doctor().hunger.everyMs === 600000,
1385
+ 'doctor reports the hunger setting', JSON.stringify(eatApi.doctor().hunger))
1386
+
1387
+ // 9 分钟:还没到
1388
+ let sawEarlyEat = false
1389
+ for (let left = 9 * 60000; left > 0; left -= 1000) {
1390
+ eat.advance(1000, 200)
1391
+ if (eatMood() === 'eat') { sawEarlyEat = true; break }
1392
+ }
1393
+ check(!sawEarlyEat, 'nine minutes in she is not hungry yet', eatMood())
1394
+
1395
+ check(eatUntil(200000), 'past the threshold she gets a bowl out')
1396
+ check(bubbleIsEat(), 'and the line comes from the rice pool',
1397
+ (eat.all('wisp-say').at(-1)?.textContent ?? '(无)'))
1398
+ check(eatMood() === 'eat' && eatApi.currentMood === 'eat',
1399
+ 'and both the DOM and the reported mood say eat', `${eatMood()} / ${eatApi.currentMood}`)
1400
+
1401
+ // 稳态轮询不能把它抹掉
1402
+ eat.advance(1000, 100)
1403
+ check(eatMood() === 'eat', 'the reaction poll does not overwrite the meal', eatMood())
1404
+
1405
+ // 吃完自己回去(EAT_MS = 5.2 秒)
1406
+ for (let left = 8000; left > 0; left -= 500) eat.advance(500, 100)
1407
+ check(eatMood() === 'alert', 'and the meal ends on its own, back to alert', eatMood())
1408
+
1409
+ // 不该连着说
1410
+ let saidAgain = false
1411
+ for (let left = 60000; left > 0; left -= 1000) {
1412
+ eat.advance(1000, 200)
1413
+ if (eatMood() === 'eat') { saidAgain = true; break }
1414
+ }
1415
+ check(!saidAgain, 'she does not ask for rice again a minute later', eatMood())
1416
+ check(eatUntil(600000), 'but another full stretch does bring it back')
1417
+
1418
+ // 离开很久之后回来:不该一进门就喊饿
1419
+ eat.win.__wisp.configure({ hungerMs: 300000 })
1420
+ eat.state.composerText = null
1421
+ eat.advance(20 * 60000, 5000)
1422
+ eat.state.composerText = '回来了'
1423
+ eat.advance(1300, 100)
1424
+ let instantHunger = false
1425
+ for (let left = 120000; left > 0; left -= 1000) {
1426
+ eat.advance(1000, 200)
1427
+ if (eatMood() === 'eat') { instantHunger = true; break }
1428
+ }
1429
+ check(!instantHunger, 'after a long absence she does not ask for rice the moment you return', eatMood())
1430
+ check(eatUntil(300000), 'only a fresh full stretch makes her hungry')
1431
+ /* 让这一顿吃完(EAT_MS = 5.2 秒)再测关闭 —— 否则"还停在 eat"会被误判成"关不掉"。
1432
+ (这个坑久坐提醒那节也踩过,同样用一段推进收尾。) */
1433
+ eat.advance(8000, 500)
1434
+
1435
+ // 关掉这个行为(两种关法)
1436
+ eat.win.__wisp.configure({ hungerMs: 0 })
1437
+ let offByZero = false
1438
+ for (let left = 20 * 60000; left > 0; left -= 5000) {
1439
+ eat.advance(5000, 500)
1440
+ if (eatMood() === 'eat') { offByZero = true; break }
1441
+ }
1442
+ check(!offByZero, 'hungerMs: 0 turns it off', eatMood())
1443
+ eat.win.__wisp.configure({ hungerMs: 300000, hungry: false })
1444
+ let offByFlag = false
1445
+ for (let left = 20 * 60000; left > 0; left -= 5000) {
1446
+ eat.advance(5000, 500)
1447
+ if (eatMood() === 'eat') { offByFlag = true; break }
1448
+ }
1449
+ check(!offByFlag, 'and hungry: false turns it off even with a live timer', eatMood())
1450
+ check(eatApi.config.hungry === false, 'and the config says so', String(eatApi.config.hungry))
1451
+
1452
+ // 菜单开关:先把菜单叫出来(菜单项是开菜单时才建的),点一下就该翻转
1453
+ /* 主菜单的入口是 .wisp-body 上的 keydown(Enter / 空格 / ContextMenu / Shift+F10),
1454
+ 不是 contextmenu —— 后者只挂在"躲起来"之后的迷你标签上。事件对象同样自建:
1455
+ ctxEvent 定义在文件后半部分,在这里用就是 TDZ。 */
1456
+ eat.win.__wisp.configure({ hungry: true })
1457
+ const eatBody = eat.find('wisp-root').querySelector('.wisp-body')
1458
+ eatBody.dispatch('keydown', {
1459
+ type: 'keydown', key: 'Enter', shiftKey: false, defaultPrevented: false,
1460
+ preventDefault() { this.defaultPrevented = true }, stopPropagation() {},
1461
+ })
1462
+ const eatToggle = eat.all('wisp-menu-item').filter((el) => el.removed !== true)
1463
+ .find((el) => (el.textContent || '').includes('饿了'))
1464
+ check(Boolean(eatToggle), 'the menu carries a rice toggle',
1465
+ eat.all('wisp-menu-item').filter((el) => el.removed !== true).length + ' 项')
1466
+ if (eatToggle) {
1467
+ /* 元素上的点击走 harness 的 dispatch(type, event) —— 不是浏览器的 dispatchEvent(没有那个方法)。 */
1468
+ eatToggle.dispatch('click', { preventDefault() {}, stopPropagation() {} })
1469
+ check(eat.win.__wisp.config.hungry === false, 'and clicking it turns the behaviour off',
1470
+ String(eat.win.__wisp.config.hungry))
1471
+ }
1472
+ eat.win.__wisp.destroy()
1473
+ active = keepEat
1474
+
1310
1475
  /* 睡着了也得被叫醒:这条单独测,因为它需要 sleepAfterMs 很短 */
1311
1476
  const sleepCare = createHarness({ timer: true, composerText: '在打字' })
1312
1477
  const keepSleepCare = active
@@ -2075,12 +2240,12 @@ if (clientSrc !== null) {
2075
2240
  check(menuOf() !== null, 'the right-click opens a context menu')
2076
2241
  check(menuOf()?.getAttribute('role') === 'menu', 'the menu carries menu semantics')
2077
2242
  /* 项数从皮肤数**推导**,不写死:加一套皮肤不该让测试变红(那是数据的错,不是行为的错)。
2078
- 固定部分 = 6 个动作 + 3 个开关 + 4 个角落 = 13;其余是每套皮肤一项。 */
2243
+ 固定部分 = 6 个动作 + 4 个开关 + 4 个角落 = 14;其余是每套皮肤一项。 */
2079
2244
  /* api.skins 是数组(SKIN_IDS 的拷贝),不是对象 —— 用 Object.keys 只是碰巧数对了。 */
2080
2245
  const skinCount = Array.isArray(api.skins) ? api.skins.length : Object.keys(api.skins ?? {}).length
2081
- check(itemsOf().length === 13 + skinCount,
2246
+ check(itemsOf().length === 14 + skinCount,
2082
2247
  'the menu lists fixed actions and corners plus exactly one entry per skin',
2083
- `${itemsOf().length} items = 13 + ${skinCount} skin(s)`)
2248
+ `${itemsOf().length} items = 14 + ${skinCount} skin(s)`)
2084
2249
  const byText = (t) => itemsOf().filter((i) => i.textContent.includes(t))
2085
2250
  const heads = menuOf().querySelectorAll('.wisp-menu-head')
2086
2251
  check(heads.length === 3, 'the behaviour, position and skin sections each get a header', `${heads.length} headers`)
@@ -2618,7 +2783,8 @@ if (clientSrc !== null) {
2618
2783
  const skins = Object.keys(value)
2619
2784
  const missing = []
2620
2785
  for (const skin of skins) {
2621
- for (const mood of ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked']) {
2786
+ /* 八个情绪:加了 eat(干饭)之后这里也要跟上 —— 漏一个就会让"每套皮肤都齐"变成假绿。 */
2787
+ for (const mood of ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked', 'proud', 'eat']) {
2622
2788
  const uri = value[skin]?.[mood]
2623
2789
  if (typeof uri !== 'string' || uri.indexOf('data:image/') !== 0) missing.push(`${skin}/${mood}`)
2624
2790
  }
@@ -2629,8 +2795,8 @@ if (clientSrc !== null) {
2629
2795
  skinReport = `table does not evaluate: ${error.message}`
2630
2796
  }
2631
2797
  }
2632
- if (skinsOk) ok('every skin carries all five moods as data URIs', skinReport)
2633
- else bad('every skin carries all five moods as data URIs', skinReport)
2798
+ if (skinsOk) ok('every skin carries all eight moods as data URIs', skinReport)
2799
+ else bad('every skin carries all eight moods as data URIs', skinReport)
2634
2800
 
2635
2801
  if (clientSrc.includes('__SPRITES__')) bad('no leftover build placeholder', 'run `node build.mjs`')
2636
2802
  else ok('no leftover build placeholder')