mocode-pet-app 1.3.0 → 1.6.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.
Files changed (60) hide show
  1. package/assets/pets/01-robo-cat.motion.css +42 -0
  2. package/assets/pets/01-robo-cat.svg +2 -2
  3. package/assets/pets/02-robo-dog.motion.css +42 -0
  4. package/assets/pets/02-robo-dog.svg +1 -1
  5. package/assets/pets/03-robo-fox.svg +1 -1
  6. package/assets/pets/04-robo-panda.svg +1 -1
  7. package/assets/pets/05-robo-owl.svg +3 -3
  8. package/assets/pets/06-robo-bunny.svg +2 -2
  9. package/assets/pets/07-robo-frog.svg +3 -3
  10. package/assets/pets/09-robo-penguin.svg +3 -3
  11. package/assets/pets/10-robo-dino.svg +3 -3
  12. package/assets/pets/11-slime-blob.motion.css +44 -0
  13. package/assets/pets/11-slime-blob.svg +3 -3
  14. package/assets/pets/12-ghost-byte.svg +3 -3
  15. package/assets/pets/13-cactus-bot.svg +2 -2
  16. package/assets/pets/15-satellite-bot.svg +1 -1
  17. package/assets/pets/16-jellyfish-bot.svg +3 -3
  18. package/assets/pets/18-star-bot.svg +3 -3
  19. package/assets/pets/manifest.json +20 -3
  20. package/assets/tray-icon.png +0 -0
  21. package/dist/assets/pets/01-robo-cat.motion.css +42 -0
  22. package/dist/assets/pets/01-robo-cat.svg +2 -2
  23. package/dist/assets/pets/02-robo-dog.motion.css +42 -0
  24. package/dist/assets/pets/02-robo-dog.svg +1 -1
  25. package/dist/assets/pets/03-robo-fox.svg +1 -1
  26. package/dist/assets/pets/04-robo-panda.svg +1 -1
  27. package/dist/assets/pets/05-robo-owl.svg +3 -3
  28. package/dist/assets/pets/06-robo-bunny.svg +2 -2
  29. package/dist/assets/pets/07-robo-frog.svg +3 -3
  30. package/dist/assets/pets/09-robo-penguin.svg +3 -3
  31. package/dist/assets/pets/10-robo-dino.svg +3 -3
  32. package/dist/assets/pets/11-slime-blob.motion.css +44 -0
  33. package/dist/assets/pets/11-slime-blob.svg +3 -3
  34. package/dist/assets/pets/12-ghost-byte.svg +3 -3
  35. package/dist/assets/pets/13-cactus-bot.svg +2 -2
  36. package/dist/assets/pets/15-satellite-bot.svg +1 -1
  37. package/dist/assets/pets/16-jellyfish-bot.svg +3 -3
  38. package/dist/assets/pets/18-star-bot.svg +3 -3
  39. package/dist/assets/pets/manifest.json +20 -3
  40. package/dist/assets/tray-icon.png +0 -0
  41. package/dist/e2e-mood-check.js +128 -0
  42. package/dist/main.js +49 -2
  43. package/dist/mood-current-state.test.js +55 -0
  44. package/dist/mood-determinism.pbt.js +66 -0
  45. package/dist/mood-tracker-timing.test.js +70 -0
  46. package/dist/mood-tracker.js +41 -0
  47. package/dist/mood-tracker.test.js +56 -0
  48. package/dist/mood.js +102 -0
  49. package/dist/mood.pbt.js +52 -0
  50. package/dist/quips.js +45 -0
  51. package/dist/quips.test.js +57 -0
  52. package/dist/renderer/dom-mood.js +53 -0
  53. package/dist/renderer/dom-mood.test.js +105 -0
  54. package/dist/renderer/index.html +2 -0
  55. package/dist/renderer/preload.js +9 -2
  56. package/dist/renderer/renderer.js +25 -9
  57. package/dist/renderer/style.css +89 -0
  58. package/dist/skins.js +32 -1
  59. package/dist/skins.test.js +73 -0
  60. package/package.json +1 -1
@@ -36,11 +36,11 @@
36
36
  </g>
37
37
 
38
38
  <!-- Beak -->
39
- <path d="M116 156 L128 172 L140 156 Z" fill="#ffb74d"/>
39
+ <path id="pet-mouth" d="M116 156 L128 172 L140 156 Z" fill="#ffb74d"/>
40
40
 
41
41
  <!-- Flipper arms(填充块面而非线条,贴身收成圆润小翼) -->
42
- <path d="M42 146 Q16 162 22 190 Q26 204 42 202 Q50 200 46 188 Q40 168 46 150 Z" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2"/>
43
- <path d="M214 146 Q240 162 234 190 Q230 204 214 202 Q206 200 210 188 Q216 168 210 150 Z" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2"/>
42
+ <path id="pet-arm-left" d="M42 146 Q16 162 22 190 Q26 204 42 202 Q50 200 46 188 Q40 168 46 150 Z" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2"/>
43
+ <path id="pet-arm-right" d="M214 146 Q240 162 234 190 Q230 204 214 202 Q206 200 210 188 Q216 168 210 150 Z" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2"/>
44
44
 
45
45
  <!-- Feet -->
46
46
  <ellipse cx="104" cy="234" rx="16" ry="8" fill="#ffb74d"/>
@@ -39,12 +39,12 @@
39
39
  </g>
40
40
 
41
41
  <!-- Jagged mouth -->
42
- <path d="M108 148 L118 156 L128 148 L138 156 L148 148" stroke="#69f0ae" stroke-width="4" fill="none" stroke-linecap="round" filter="url(#glow)"/>
42
+ <path id="pet-mouth" d="M108 148 L118 156 L128 148 L138 156 L148 148" stroke="#69f0ae" stroke-width="4" fill="none" stroke-linecap="round" filter="url(#glow)"/>
43
43
 
44
44
  <!-- Small arms(填充块面而非线条,末端一颗圆球小手) -->
45
- <ellipse cx="31" cy="142" rx="5.5" ry="15" fill="url(#accentGrad)" transform="rotate(-34 31 142)"/>
45
+ <ellipse id="pet-arm-left" cx="31" cy="142" rx="5.5" ry="15" fill="url(#accentGrad)" transform="rotate(-34 31 142)"/>
46
46
  <circle cx="25" cy="156" r="5" fill="url(#accentGrad)"/>
47
- <ellipse cx="223" cy="142" rx="5.5" ry="15" fill="url(#accentGrad)" transform="rotate(34 223 142)"/>
47
+ <ellipse id="pet-arm-right" cx="223" cy="142" rx="5.5" ry="15" fill="url(#accentGrad)" transform="rotate(34 223 142)"/>
48
48
  <circle cx="231" cy="156" r="5" fill="url(#accentGrad)"/>
49
49
 
50
50
  <!-- Legs / feet -->
@@ -0,0 +1,44 @@
1
+ /* 史莱姆(slime-blob)专属演出:利用"形变"这个史莱姆独有的视觉语言覆盖通用规则。
2
+ * 史莱姆没有 arm-left/right/tail 等 id,通用规则里依赖这些 id 的动画(如 tool_call 摆臂、
3
+ * flustered 摆臂)对它本来就不生效,这里改用挤压/压扁形变来填补对应状态的演出。 */
4
+
5
+ /* ── mood-tired:身体压扁变矮,模拟疲惫瘫软,替代通用的"下沉" ── */
6
+ @keyframes slime-tired-squash {
7
+ 0%, 100% { transform: scaleY(1) scaleX(1); }
8
+ 50% { transform: scaleY(0.82) scaleX(1.08); }
9
+ }
10
+ .mood-tired #pet-body-group {
11
+ animation: slime-tired-squash 3.6s ease-in-out infinite;
12
+ transform-origin: 128px 200px;
13
+ }
14
+
15
+ /* ── tool_call:挤压弹跳形变代替通用双臂摆动 ── */
16
+ @keyframes slime-tool-bounce {
17
+ 0%, 100% { transform: scaleY(1) scaleX(1); }
18
+ 30% { transform: scaleY(0.85) scaleX(1.12); }
19
+ 60% { transform: scaleY(1.12) scaleX(0.9); }
20
+ }
21
+ .pet-tool #pet-body-group {
22
+ animation: slime-tool-bounce 0.5s ease-in-out infinite;
23
+ transform-origin: 128px 200px;
24
+ }
25
+
26
+ /* ── idle:呼吸感的轻微鼓起收缩,比通用的上下浮动更软糯 ── */
27
+ @keyframes slime-idle-breathe {
28
+ 0%, 100% { transform: scale(1); }
29
+ 50% { transform: scale(1.03); }
30
+ }
31
+ .pet-idle #pet-body-group {
32
+ animation: slime-idle-breathe 2.8s ease-in-out infinite;
33
+ transform-origin: 128px 138px;
34
+ }
35
+
36
+ /* ── mood-urging:急促地一颤一颤,像在催你快点 ── */
37
+ @keyframes slime-urging-jiggle {
38
+ 0%, 100% { transform: scaleY(1); }
39
+ 50% { transform: scaleY(0.92); }
40
+ }
41
+ .mood-urging #pet-body-group {
42
+ animation: slime-urging-jiggle 0.45s ease-in-out infinite;
43
+ transform-origin: 128px 200px;
44
+ }
@@ -8,7 +8,7 @@
8
8
  <feGaussianBlur stdDeviation="3" result="blur"/>
9
9
  <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
10
10
  </filter>
11
- </defs>
11
+ </defs>
12
12
  <g id="pet-body-group">
13
13
 
14
14
  <!-- Blob body -->
@@ -31,7 +31,7 @@
31
31
  </g>
32
32
 
33
33
  <!-- Smile -->
34
- <path d="M110 156 Q128 170 146 156" stroke="#0a1420" stroke-width="3.5" fill="none" stroke-linecap="round"/>
34
+ <path id="pet-mouth" d="M110 156 Q128 170 146 156" stroke="#0a1420" stroke-width="3.5" fill="none" stroke-linecap="round"/>
35
35
 
36
36
  <!-- Drips -->
37
37
  <path d="M84 216 Q84 232 92 232 Q100 232 96 216 Z" fill="url(#blobGrad)"/>
@@ -39,5 +39,5 @@
39
39
 
40
40
  <!-- Highlight -->
41
41
  <ellipse cx="92" cy="86" rx="18" ry="10" fill="#ffffff" fill-opacity="0.25"/>
42
- </g>
42
+ </g>
43
43
  </svg>
@@ -8,7 +8,7 @@
8
8
  <feGaussianBlur stdDeviation="3" result="blur"/>
9
9
  <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
10
10
  </filter>
11
- </defs>
11
+ </defs>
12
12
  <g id="pet-body-group">
13
13
 
14
14
  <!-- Floating shadow -->
@@ -36,6 +36,6 @@
36
36
  </g>
37
37
 
38
38
  <!-- Mouth -->
39
- <ellipse cx="128" cy="142" rx="10" ry="8" fill="#0a1420" fill-opacity="0.85"/>
40
- </g>
39
+ <ellipse id="pet-mouth" cx="128" cy="142" rx="10" ry="8" fill="#0a1420" fill-opacity="0.85"/>
40
+ </g>
41
41
  </svg>
@@ -26,8 +26,8 @@
26
26
  <rect x="48" y="150" width="160" height="70" rx="40" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="3"/>
27
27
 
28
28
  <!-- Spike arms(填充块面而非线条,细刺仍保留为短线装饰) -->
29
- <path d="M62 108 Q26 112 24 142 Q22 156 38 158 Q50 158 48 144 Q44 122 62 118 Z" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2.5"/>
30
- <path d="M194 108 Q230 112 232 142 Q234 156 218 158 Q206 158 208 144 Q212 122 194 118 Z" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2.5"/>
29
+ <path id="pet-arm-left" d="M62 108 Q26 112 24 142 Q22 156 38 158 Q50 158 48 144 Q44 122 62 118 Z" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2.5"/>
30
+ <path id="pet-arm-right" d="M194 108 Q230 112 232 142 Q234 156 218 158 Q206 158 208 144 Q212 122 194 118 Z" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2.5"/>
31
31
  <path d="M34 118 L24 108 M34 128 L22 124 M222 118 L232 108 M222 128 L234 124"
32
32
  stroke="url(#accentGrad)" stroke-width="3" stroke-linecap="round"/>
33
33
 
@@ -18,7 +18,7 @@
18
18
  <!-- Antenna dish (kept 8px lower than original so the dish curve's peak stays clear
19
19
  of the viewBox top edge during the idle float animation's -6px translateY) -->
20
20
  <line x1="128" y1="24" x2="128" y2="54" stroke="url(#accentGrad)" stroke-width="4"/>
21
- <path d="M100 24 Q128 4 156 24 Q128 36 100 24 Z" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2.5"/>
21
+ <path id="pet-antenna" d="M100 24 Q128 4 156 24 Q128 36 100 24 Z" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2.5"/>
22
22
 
23
23
  <!-- Solar panel wings -->
24
24
  <rect x="4" y="88" width="50" height="70" rx="6" fill="url(#bodyGrad)" stroke="url(#accentGrad)" stroke-width="2.5"/>
@@ -8,7 +8,7 @@
8
8
  <feGaussianBlur stdDeviation="3" result="blur"/>
9
9
  <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
10
10
  </filter>
11
- </defs>
11
+ </defs>
12
12
  <g id="pet-body-group">
13
13
 
14
14
  <!-- Dome head -->
@@ -20,7 +20,7 @@
20
20
  <circle cx="102" cy="86" r="8" fill="#0a1420"/>
21
21
  <circle cx="154" cy="86" r="8" fill="#0a1420"/>
22
22
  </g>
23
- <path d="M112 104 Q128 114 144 104" stroke="#0a1420" stroke-width="3" fill="none" stroke-linecap="round"/>
23
+ <path id="pet-mouth" d="M112 104 Q128 114 144 104" stroke="#0a1420" stroke-width="3" fill="none" stroke-linecap="round"/>
24
24
 
25
25
  <!-- Tentacles -->
26
26
  <path d="M64 110 Q56 150 66 190 Q72 210 62 232" stroke="#ba68c8" stroke-width="7" fill="none" stroke-linecap="round">
@@ -43,5 +43,5 @@
43
43
  <circle cx="66" cy="170" r="3" fill="#e040fb" filter="url(#glow)"/>
44
44
  <circle cx="132" cy="180" r="3" fill="#e040fb" filter="url(#glow)"/>
45
45
  <circle cx="196" cy="170" r="3" fill="#e040fb" filter="url(#glow)"/>
46
- </g>
46
+ </g>
47
47
  </svg>
@@ -8,7 +8,7 @@
8
8
  <feGaussianBlur stdDeviation="3" result="blur"/>
9
9
  <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
10
10
  </filter>
11
- </defs>
11
+ </defs>
12
12
  <g id="pet-body-group">
13
13
 
14
14
  <!-- Star body: 5-point star -->
@@ -40,6 +40,6 @@
40
40
  </g>
41
41
 
42
42
  <!-- Smile -->
43
- <path d="M114 148 Q128 158 142 148" stroke="#ffe082" stroke-width="3" fill="none" stroke-linecap="round"/>
44
- </g>
43
+ <path id="pet-mouth" d="M114 148 Q128 158 142 148" stroke="#ffe082" stroke-width="3" fill="none" stroke-linecap="round"/>
44
+ </g>
45
45
  </svg>
@@ -3,8 +3,20 @@
3
3
  "viewBox": "0 0 256 256",
4
4
  "style": "赛博终端风,深色机身(#1b263b/#0d1b2a)+ 屏幕脸(黑底彩色像素眼,呼吸/眨眼动画),与 assets/mascot.svg 视觉语言一致",
5
5
  "pets": [
6
- { "id": "robo-cat", "file": "01-robo-cat.svg", "name": "机械猫", "accent": "#2afadf" },
7
- { "id": "robo-dog", "file": "02-robo-dog.svg", "name": "机械犬", "accent": "#ffb74d" },
6
+ { "id": "robo-cat", "file": "01-robo-cat.svg", "name": "机械猫", "accent": "#2afadf",
7
+ "motionFile": "01-robo-cat.motion.css",
8
+ "quips": {
9
+ "tired": ["猫猫要趴一下…", "喵…有点累了,尾巴都垂下来了"],
10
+ "bored": ["理理毛,顺便等你", "尾巴甩甩,好无聊喵"],
11
+ "urging": ["喵?还没好吗", "尾巴都等急了喵"]
12
+ } },
13
+ { "id": "robo-dog", "file": "02-robo-dog.svg", "name": "机械犬", "accent": "#ffb74d",
14
+ "motionFile": "02-robo-dog.motion.css",
15
+ "quips": {
16
+ "bored": ["打了个哈欠,好无聊呀", "尾巴都不知道摇给谁看了"],
17
+ "urging": ["快点快点,舌头都伸出来啦", "汪!还在等你呢"],
18
+ "flustered": ["汪汪汪,有点忙不过来了", "尾巴摇得比脑子转得快"]
19
+ } },
8
20
  { "id": "robo-fox", "file": "03-robo-fox.svg", "name": "机械狐", "accent": "#ff7043" },
9
21
  { "id": "robo-panda", "file": "04-robo-panda.svg", "name": "机械熊猫", "accent": "#2afadf" },
10
22
  { "id": "robo-owl", "file": "05-robo-owl.svg", "name": "机械猫头鹰", "accent": "#7c4dff" },
@@ -13,7 +25,12 @@
13
25
  { "id": "robo-bear", "file": "08-robo-bear.svg", "name": "机械熊", "accent": "#ffa726" },
14
26
  { "id": "robo-penguin", "file": "09-robo-penguin.svg", "name": "机械企鹅", "accent": "#42a5f5" },
15
27
  { "id": "robo-dino", "file": "10-robo-dino.svg", "name": "机械龙", "accent": "#00c853" },
16
- { "id": "slime-blob", "file": "11-slime-blob.svg", "name": "史莱姆", "accent": "#00acc1" },
28
+ { "id": "slime-blob", "file": "11-slime-blob.svg", "name": "史莱姆", "accent": "#00acc1",
29
+ "motionFile": "11-slime-blob.motion.css",
30
+ "quips": {
31
+ "tired": ["软软地瘫下去了…", "身体有点撑不住形状了"],
32
+ "urging": ["晃一晃,还在等你哦", "抖一抖,催你一下"]
33
+ } },
17
34
  { "id": "ghost-byte", "file": "12-ghost-byte.svg", "name": "字节幽灵", "accent": "#4dd0e1" },
18
35
  { "id": "cactus-bot", "file": "13-cactus-bot.svg", "name": "仙人掌兽", "accent": "#66bb6a" },
19
36
  { "id": "crystal-bot", "file": "14-crystal-bot.svg", "name": "水晶精灵", "accent": "#7c4dff" },
Binary file
@@ -0,0 +1,128 @@
1
+ // 任务 8:端到端逻辑验证(脚本化,不依赖真实 Electron/图形环境/真实 mocode CLI 进程)。
2
+ // 直接调用已完成的纯逻辑模块 mood.ts / mood-tracker.ts / quips.ts / skins.ts,
3
+ // 模拟 design.md 里描述的完整事件序列,断言 Mood 链路与皮肤个性化行为符合预期。
4
+ // 本脚本只读验证,不修改被验证的任何模块。
5
+ // 运行(在 f:\mocode 根目录):npx tsx packages/pet-app/src/e2e-mood-check.ts
6
+ import { createMoodTracker } from './mood-tracker.js';
7
+ import { listSkinEntries } from './skins.js';
8
+ import { UNIVERSAL_QUIPS, pickQuip } from './quips.js';
9
+ let passed = 0;
10
+ const assert = (cond, msg) => {
11
+ if (!cond) {
12
+ console.error('✗ ' + msg);
13
+ process.exit(1);
14
+ }
15
+ passed++;
16
+ console.log('✓ ' + msg);
17
+ };
18
+ (async () => {
19
+ // ── 场景1:agent 长时间停留在 thinking(同理 tool_call)触发 tired ─────────
20
+ // 每 5000ms 上报一次 thinking,ts: 0..60000。默认 tiredMs=60000。
21
+ {
22
+ const tracker = createMoodTracker();
23
+ let reachedTired = false;
24
+ for (let ts = 0; ts <= 60000; ts += 5000) {
25
+ tracker.recordState('thinking', ts);
26
+ const res = tracker.evaluate(ts);
27
+ if (ts < 60000) {
28
+ assert(res === null, `场景1: ts=${ts} (<60000) 时不应推送 tired(evaluate 应返回 null,实际 ${JSON.stringify(res)})`);
29
+ }
30
+ else {
31
+ assert(res !== null && res.mood === 'tired', `场景1: ts=60000(达到 tiredMs 阈值)时 mood 应变为 tired(实际 ${JSON.stringify(res)})`);
32
+ reachedTired = true;
33
+ }
34
+ }
35
+ assert(reachedTired, '场景1: 已跑到 ts=60000 并验证 tired 触发');
36
+ }
37
+ // ── 场景2:10 秒内 5 次 tool_call 触发 flustered ─────────────────────────
38
+ // 默认 toolWindowMs=10000, toolCount=5。
39
+ {
40
+ const tracker = createMoodTracker();
41
+ for (const ts of [0, 2000, 4000, 6000, 8000]) {
42
+ tracker.recordState('tool_call', ts);
43
+ }
44
+ const res = tracker.evaluate(8000);
45
+ assert(res !== null && res.mood === 'flustered', `场景2: 10s 内 5 次 tool_call → mood 应为 flustered(实际 ${JSON.stringify(res)})`);
46
+ }
47
+ // ── 场景3:60 秒内 2 次 error 触发 frustrated ────────────────────────────
48
+ // 默认 errorWindowMs=60000, errorCount=2。
49
+ {
50
+ const tracker = createMoodTracker();
51
+ tracker.recordState('error', 0);
52
+ tracker.recordState('error', 30000);
53
+ const res = tracker.evaluate(30000);
54
+ assert(res !== null && res.mood === 'frustrated', `场景3: 60s 窗口内 2 次 error → mood 应为 frustrated(实际 ${JSON.stringify(res)})`);
55
+ }
56
+ // ── 场景4:waiting_human 超时触发 urging ─────────────────────────────────
57
+ // 默认 urgingMs=30000。
58
+ {
59
+ const tracker = createMoodTracker();
60
+ tracker.recordState('waiting_human', 0);
61
+ const res = tracker.evaluate(30000);
62
+ assert(res !== null && res.mood === 'urging', `场景4: waiting_human 持续 30000ms → mood 应为 urging(实际 ${JSON.stringify(res)})`);
63
+ }
64
+ // ── 场景5:idle 超时触发 bored ────────────────────────────────────────────
65
+ // 默认 boredMs=300000。
66
+ {
67
+ const tracker = createMoodTracker();
68
+ tracker.recordState('idle', 0);
69
+ const res = tracker.evaluate(300000);
70
+ assert(res !== null && res.mood === 'bored', `场景5: idle 持续 300000ms → mood 应为 bored(实际 ${JSON.stringify(res)})`);
71
+ }
72
+ // ── 场景6:多条件同时满足,只呈现最高优先级 mood ──────────────────────────
73
+ // 选用组合:frustrated(errorCount=2,errorWindowMs=60000) vs tired(tiredMs=60000)。
74
+ // 关键设计:tired/bored/urging 只看"末尾同状态连续段"的起始时间(currentStateSince),
75
+ // 而 frustrated/flustered 看的是"整个事件历史里落在滑动窗口内"的计数,两者不互斥。
76
+ // 序列:error@0, error@0, thinking@0 —— 末尾状态是 thinking,起始时间就是它自己的 ts=0;
77
+ // 同时前面两条 error 事件的 ts=0 仍落在以 now=60000、宽度 60000 的窗口 [0,60000] 内。
78
+ // 于是在 now=60000 时:tired 条件成立(60000-0>=60000)且 frustrated 条件成立(窗口内 2 条 error)。
79
+ // MOOD_PRIORITY = [frustrated, flustered, urging, tired, bored],frustrated 排在 tired 之前,
80
+ // 因此应最终呈现 frustrated 而不是 tired。
81
+ {
82
+ const tracker = createMoodTracker();
83
+ tracker.recordState('error', 0);
84
+ tracker.recordState('error', 0);
85
+ tracker.recordState('thinking', 0);
86
+ const res = tracker.evaluate(60000);
87
+ assert(res !== null && res.mood === 'frustrated', `场景6: frustrated(2次error@60000ms窗口内) 与 tired(thinking持续60000ms) 同时满足 → 应呈现优先级更高的 frustrated,不是 tired(实际 ${JSON.stringify(res)})`);
88
+ }
89
+ // ── 场景7:断开活跃连接重新连接后,mood 判定不受上一个会话历史事件影响 ────
90
+ {
91
+ const tracker = createMoodTracker();
92
+ tracker.recordState('thinking', 0);
93
+ const tiredRes = tracker.evaluate(60000);
94
+ assert(tiredRes !== null && tiredRes.mood === 'tired', `场景7: 会话A 先触发 tired 作为前置条件(实际 ${JSON.stringify(tiredRes)})`);
95
+ tracker.reset();
96
+ // 用同一个 now=60000 再 evaluate 一次:若 events 未被真正清空,tired 条件依旧成立且
97
+ // mood 与上次推送的 'tired' 相同 → evaluate 会因"未变化"返回 JS null(不会是 {mood:null})。
98
+ // 只有 events 真被清空,deriveMood([], 60000) 才会算出 null,与上次推送的 'tired' 不同,
99
+ // 从而触发一次"变化推送"返回 {mood:null}。用这个差异来验证 reset 确实清空了事件缓冲。
100
+ const afterReset = tracker.evaluate(60000);
101
+ assert(afterReset !== null && afterReset.mood === null, `场景7: reset() 后立即 evaluate 应返回 {mood:null}(证明 events 已清空,不是简单的"未变化"判定;实际 ${JSON.stringify(afterReset)})`);
102
+ // 新会话从零开始:只记一条 idle 事件,时间差远小于 boredMs(300000),不应触发任何 mood,
103
+ // 也不应因为旧会话的 tired 历史残留而误判。
104
+ tracker.recordState('idle', 60000);
105
+ const newSessionRes = tracker.evaluate(61000);
106
+ assert(newSessionRes === null, `场景7: 新会话记一条 idle、时间差(1000ms)远小于 boredMs → mood 应仍是 null(evaluate 返回 null,无残留旧 mood;实际 ${JSON.stringify(newSessionRes)})`);
107
+ }
108
+ // ── 场景8:定制皮肤展示专属文案,未定制皮肤正确回退通用池 ──────────────────
109
+ {
110
+ const entries = listSkinEntries();
111
+ const cat = entries.find((e) => e.id === 'robo-cat');
112
+ assert(!!cat, '场景8: manifest.json 中应存在 robo-cat 条目');
113
+ assert(!!cat?.quips && Array.isArray(cat.quips.tired) && Array.isArray(cat.quips.bored) && Array.isArray(cat.quips.urging), `场景8: robo-cat.quips 应包含 tired/bored/urging 数组字段(实际 ${JSON.stringify(cat?.quips)})`);
114
+ const catTired = cat.quips.tired;
115
+ for (let i = 0; i < 20; i++) {
116
+ const q = pickQuip('tired', cat.quips);
117
+ assert(catTired.includes(q), `场景8: robo-cat 的 tired 文案应从专属池选取(第 ${i + 1} 次得到 "${q}",专属池 ${JSON.stringify(catTired)})`);
118
+ }
119
+ const fox = entries.find((e) => e.id === 'robo-fox');
120
+ assert(!!fox, '场景8: manifest.json 中应存在 robo-fox 条目');
121
+ assert(fox?.quips === undefined, `场景8: robo-fox 未定制 quips,应解析为 undefined(实际 ${JSON.stringify(fox?.quips)})`);
122
+ for (let i = 0; i < 20; i++) {
123
+ const q = pickQuip('tired', fox.quips);
124
+ assert(UNIVERSAL_QUIPS.tired.includes(q), `场景8: robo-fox 未定制皮肤应回退通用池(第 ${i + 1} 次得到 "${q}",通用池 ${JSON.stringify(UNIVERSAL_QUIPS.tired)})`);
125
+ }
126
+ }
127
+ console.log(`\nOK: ${passed} passed, 0 failed`);
128
+ })();
package/dist/main.js CHANGED
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { parseClientMessage, } from './protocol.js';
9
9
  import { loadConfig, saveConfig } from './config.js';
10
10
  import { listSkinEntries, resolveSkinPath } from './skins.js';
11
+ import { createMoodTracker } from './mood-tracker.js';
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
13
  /** 默认端口;MOCODE_PET_PORT 环境变量覆盖(与 mocode 主包 src/pet/bridge.ts 保持一致的默认假设)。 */
13
14
  const DEFAULT_PORT = 47821;
@@ -21,6 +22,8 @@ const HEARTBEAT_CHECK_INTERVAL_MS = 15000;
21
22
  const HEARTBEAT_TIMEOUT_MS = 10000;
22
23
  /** 状态展示超时后自动回落(design.md 默认假设:done/aborted/error 短暂展示 1500ms)。 */
23
24
  const TRANSIENT_STATE_TIMEOUT_MS = 1500;
25
+ /** Mood ticker 检查间隔(design.md 默认假设:2000ms,状态变化时另外立即求值一次,不必等 ticker)。 */
26
+ const MOOD_CHECK_INTERVAL_MS = 2000;
24
27
  /** 全局连接表 + 唯一活跃连接引用("最新连接覆盖"算法的状态载体,见 design.md Low-Level)。 */
25
28
  const connections = new Map();
26
29
  let activeSocket = null;
@@ -29,6 +32,8 @@ let tray = null;
29
32
  let transientTimer = null;
30
33
  /** 当前生效的皮肤 id('default' = assets/mascot.svg);启动时从持久化配置读取,运行期随 set_skin/托盘菜单变化。 */
31
34
  let currentSkinId = 'default';
35
+ /** Mood 判定与文案调度(纯逻辑,见 mood-tracker.ts);随活跃连接建立/断开重置,详见 onConnection/onDisconnect。 */
36
+ const moodTracker = createMoodTracker();
32
37
  /** 把状态推给渲染进程(IPC)。渲染进程窗口未就绪时静默丢弃(下次状态到达会重推)。 */
33
38
  function broadcastToRenderer(state, meta) {
34
39
  if (!mainWindow || mainWindow.isDestroyed())
@@ -72,6 +77,7 @@ function onConnection(socket) {
72
77
  };
73
78
  connections.set(socket, record);
74
79
  activeSocket = socket;
80
+ moodTracker.reset(); // 新会话从空事件历史开始,不受上一个活跃连接遗留事件影响(design.md 事件缓冲生命周期)
75
81
  send(socket, { type: 'welcome', isActive: true, ts: Date.now() });
76
82
  broadcastToRenderer('idle'); // 新连接刚建立还没收到它的第一条 state,先归 idle 兜底
77
83
  socket.on('message', (data) => onMessage(socket, String(data)));
@@ -88,6 +94,7 @@ function onDisconnect(socket) {
88
94
  if (wasActive) {
89
95
  activeSocket = null; // 强制清空,不回退到任何仍 open 的连接
90
96
  broadcastToRenderer('idle');
97
+ moodTracker.reset(); // 避免下一个连接复用旧历史(design.md 事件缓冲生命周期)
91
98
  }
92
99
  // wasActive = false:该连接原本就被忽略,断开对当前状态源无影响
93
100
  }
@@ -119,6 +126,8 @@ function onMessage(socket, raw) {
119
126
  if (socket !== activeSocket)
120
127
  return; // 非活跃连接的状态消息静默丢弃
121
128
  broadcastToRenderer(msg.state, msg.meta);
129
+ moodTracker.recordState(msg.state);
130
+ pushMoodEvaluation(moodTracker.evaluate(Date.now(), currentSkinQuips()));
122
131
  return;
123
132
  case 'shutdown':
124
133
  // 关闭桌宠(方案C的 CLI 入口):不要求发送方是活跃连接,任何已连接的 mocode 进程均可请求关闭。
@@ -156,6 +165,11 @@ function currentSkinAssetPath() {
156
165
  const abs = resolveSkinPath(currentSkinId);
157
166
  return abs ? `../assets/pets/${path.basename(abs)}` : '../assets/mascot.svg';
158
167
  }
168
+ /** 当前皮肤的个性化动作覆盖 CSS 文件名(manifest.json 的 motionFile 字段);
169
+ * 'default' 或未定制该字段时返回 undefined,渲染进程据此清空 `#pet-skin-motion` 的 href(回退通用样式)。 */
170
+ function currentSkinMotionFile() {
171
+ return listSkinEntries().find((e) => e.id === currentSkinId)?.motionFile;
172
+ }
159
173
  /** 把当前皮肤推给渲染进程(运行期切换用,如托盘菜单点击 / CLI set_skin 消息)。
160
174
  * 注:启动时的初始皮肤不走这条路径——渲染进程通过 'pet:get-skin' invoke 主动拉取,
161
175
  * 避免 did-finish-load 与渲染进程异步注册监听器之间的时序竞争(IPC 消息不会缓冲,
@@ -164,14 +178,46 @@ function pushSkinToRenderer() {
164
178
  if (!mainWindow || mainWindow.isDestroyed())
165
179
  return;
166
180
  try {
167
- mainWindow.webContents.send('pet:skin', { assetPath: currentSkinAssetPath() });
181
+ mainWindow.webContents.send('pet:skin', {
182
+ assetPath: currentSkinAssetPath(),
183
+ motionFile: currentSkinMotionFile(),
184
+ });
168
185
  }
169
186
  catch {
170
187
  // 静默:渲染进程未就绪时忽略,下次运行期切换会重新推送
171
188
  }
172
189
  }
173
190
  /** 渲染进程启动时主动拉取当前皮肤(同步于其自身初始化时机,不依赖 did-finish-load 的时序假设)。 */
174
- ipcMain.handle('pet:get-skin', () => ({ assetPath: currentSkinAssetPath() }));
191
+ ipcMain.handle('pet:get-skin', () => ({
192
+ assetPath: currentSkinAssetPath(),
193
+ motionFile: currentSkinMotionFile(),
194
+ }));
195
+ /** 当前皮肤的专属文案池(manifest.json 的 quips 字段),供 mood-tracker 的 pickQuip 优先选取。 */
196
+ function currentSkinQuips() {
197
+ return listSkinEntries().find((e) => e.id === currentSkinId)?.quips;
198
+ }
199
+ /** 把 mood-tracker 求值结果推给渲染进程(IPC 频道 pet:mood)。result 为 null 表示本次求值无需推送。
200
+ * 写法与 broadcastToRenderer 一致的 null/isDestroyed 检查 + try/catch 静默降级,不影响 Server 主流程。 */
201
+ function pushMoodEvaluation(result) {
202
+ if (!result)
203
+ return;
204
+ if (!mainWindow || mainWindow.isDestroyed())
205
+ return;
206
+ try {
207
+ mainWindow.webContents.send('pet:mood', result);
208
+ }
209
+ catch {
210
+ // 静默:渲染进程尚未加载完毕或已崩溃,不影响 Server 侧状态机
211
+ }
212
+ }
213
+ /** Mood ticker:每 MOOD_CHECK_INTERVAL_MS 定时求值一次(状态变化时另有立即求值,见 onMessage 的 'state' 分支)。 */
214
+ function startMoodTicker() {
215
+ const timer = setInterval(() => {
216
+ pushMoodEvaluation(moodTracker.evaluate(Date.now(), currentSkinQuips()));
217
+ }, MOOD_CHECK_INTERVAL_MS);
218
+ timer.unref?.();
219
+ return timer;
220
+ }
175
221
  function send(socket, msg) {
176
222
  try {
177
223
  socket.send(JSON.stringify(msg));
@@ -420,6 +466,7 @@ app.whenReady().then(async () => {
420
466
  mainWindow = createPetWindow();
421
467
  tray = createTray();
422
468
  rebuildTrayMenu();
469
+ startMoodTicker();
423
470
  });
424
471
  // 不再监听 window-all-closed → app.quit():悬浮窗本身不可关闭(frame:false 且无关闭按钮),
425
472
  // 该事件设计上永不触发。退出统一走两个显式入口:托盘菜单"退出桌宠" 或 CLI 侧 shutdown 消息
@@ -0,0 +1,55 @@
1
+ // currentStateSince 边界场景单测(mood.ts 内部辅助函数,已临时加 export 以便测试)。
2
+ // 覆盖:空数组、单条事件、末尾多条同状态、末尾单条状态与前一条不同、
3
+ // 中间有切换但末尾又切回同状态(不应误判 since)。
4
+ // 运行:npx tsx packages/pet-app/src/mood-current-state.test.ts
5
+ import { currentStateSince } from './mood.js';
6
+ let passed = 0;
7
+ const assert = (cond, msg) => {
8
+ if (!cond) {
9
+ console.error('✗ ' + msg);
10
+ process.exit(1);
11
+ }
12
+ passed++;
13
+ console.log('✓ ' + msg);
14
+ };
15
+ // 1) 空数组 → null
16
+ {
17
+ const r = currentStateSince([]);
18
+ assert(r === null, '空数组 → 返回 null');
19
+ }
20
+ // 2) 单条事件 → { state, since } 就是该条自身
21
+ {
22
+ const events = [{ state: 'idle', ts: 100 }];
23
+ const r = currentStateSince(events);
24
+ assert(r !== null && r.state === 'idle' && r.since === 100, "单条事件 [{state:'idle',ts:100}] → {state:'idle', since:100}");
25
+ }
26
+ // 3) 末尾多条同状态 → since 是这段连续区间里最早那条的 ts
27
+ {
28
+ const events = [
29
+ { state: 'thinking', ts: 100 },
30
+ { state: 'thinking', ts: 200 },
31
+ { state: 'thinking', ts: 300 },
32
+ ];
33
+ const r = currentStateSince(events);
34
+ assert(r !== null && r.state === 'thinking' && r.since === 100, "末尾多条同状态(thinking x3, ts:100/200/300) → since:100(最早那条,不是300)");
35
+ }
36
+ // 4) 末尾单条状态与前一条不同 → since 就是该条自身的 ts,不把前一条算进去
37
+ {
38
+ const events = [
39
+ { state: 'idle', ts: 100 },
40
+ { state: 'thinking', ts: 200 },
41
+ ];
42
+ const r = currentStateSince(events);
43
+ assert(r !== null && r.state === 'thinking' && r.since === 200, "末尾状态与前一条不同([idle@100, thinking@200]) → {state:'thinking', since:200}");
44
+ }
45
+ // 5) 中间有切换但末尾又切回同一状态 → 只看末尾连续同状态的最新一段,不被更早处同状态干扰
46
+ {
47
+ const events = [
48
+ { state: 'idle', ts: 100 },
49
+ { state: 'thinking', ts: 200 },
50
+ { state: 'idle', ts: 300 },
51
+ ];
52
+ const r = currentStateSince(events);
53
+ assert(r !== null && r.state === 'idle' && r.since === 300, "末尾切回同状态但中间有切换([idle@100, thinking@200, idle@300]) → {state:'idle', since:300}(不误判为100)");
54
+ }
55
+ console.log(`\nOK: ${passed} passed, 0 failed`);
@@ -0,0 +1,66 @@
1
+ // PBT: Property 2 (Mood 确定性,纯函数) —— packages/pet-app/src/mood.ts 的 deriveMood。
2
+ // 覆盖 tasks.md 任务 2.2 / design.md Property 2:
3
+ // 给定任意 (events, now, thresholds) 输入,连续多次调用 deriveMood 返回值恒定
4
+ // (无隐藏可变状态,纯函数),且调用过程不修改传入的 events 数组(无副作用)。
5
+ // 运行:npx tsx packages/pet-app/src/mood-determinism.pbt.ts (在 f:\mocode 根目录下)
6
+ import fc from 'fast-check';
7
+ import { deriveMood, DEFAULT_MOOD_THRESHOLDS } from './mood.js';
8
+ import { PET_STATES } from './protocol.js';
9
+ let passed = 0;
10
+ const assert = (cond, msg) => {
11
+ if (!cond) {
12
+ console.error('✗ ' + msg);
13
+ process.exit(1);
14
+ }
15
+ passed++;
16
+ console.log('✓ ' + msg);
17
+ };
18
+ // 任意单条 MoodEvent:state 从 8 个合法 PetState 里选,ts 为非负整数。
19
+ const moodEventArb = fc.record({
20
+ state: fc.constantFrom(...PET_STATES),
21
+ ts: fc.integer({ min: 0, max: 10_000_000 }),
22
+ });
23
+ // 任意 MoodEvent[]:生成后按 ts 升序排序,满足 deriveMood 的前置条件(events 按 ts 非降序排列)。
24
+ const moodEventsArb = fc
25
+ .array(moodEventArb, { maxLength: 40 })
26
+ .map((events) => [...events].sort((a, b) => a.ts - b.ts));
27
+ // 任意正整数(用于自定义阈值的 7 个字段)。
28
+ const positiveIntArb = fc.integer({ min: 1, max: 1_000_000 });
29
+ // 任意 MoodThresholds:一半概率直接用 DEFAULT_MOOD_THRESHOLDS,一半概率用随机生成的自定义阈值。
30
+ const thresholdsArb = fc.oneof(fc.constant(DEFAULT_MOOD_THRESHOLDS), fc.record({
31
+ tiredMs: positiveIntArb,
32
+ boredMs: positiveIntArb,
33
+ urgingMs: positiveIntArb,
34
+ errorWindowMs: positiveIntArb,
35
+ errorCount: positiveIntArb,
36
+ toolWindowMs: positiveIntArb,
37
+ toolCount: positiveIntArb,
38
+ }));
39
+ async function main() {
40
+ // Property 2: 对任意 (events, now, thresholds),多次调用 deriveMood 返回值恒定,且不修改 events。
41
+ try {
42
+ fc.assert(fc.property(moodEventsArb, fc.integer({ min: 0, max: 10_000_000 }), thresholdsArb, (events, now, thresholds) => {
43
+ const before = JSON.stringify(events);
44
+ const r1 = deriveMood(events, now, thresholds);
45
+ const r2 = deriveMood(events, now, thresholds);
46
+ const r3 = deriveMood(events, now, thresholds);
47
+ const after = JSON.stringify(events);
48
+ // 返回值恒定(===,mood 是字符串或 null,可直接比较)。
49
+ if (!(r1 === r2 && r2 === r3))
50
+ return false;
51
+ // events 数组未被修改(无副作用)。
52
+ if (before !== after)
53
+ return false;
54
+ return true;
55
+ }), { numRuns: 500 });
56
+ assert(true, 'Property 2 (Mood 确定性): 500 次随机样本,deriveMood 多次调用返回值恒定且不修改 events');
57
+ }
58
+ catch (e) {
59
+ console.error('✗ Property 2 (Mood 确定性) 失败:');
60
+ console.error(e instanceof Error ? e.message : String(e));
61
+ process.exit(1);
62
+ }
63
+ console.log(`\nOK: ${passed} passed, 0 failed`);
64
+ process.exit(0);
65
+ }
66
+ main();