@linxin666/dsh-pet 0.1.19 → 0.2.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 (95) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +19 -12
  3. package/README.zh.md +19 -12
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +202 -38
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +897 -42
  8. package/lib/invariant.js +1 -1
  9. package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
  10. package/lib/types/affinity.d.ts +22 -18
  11. package/lib/types/affinity.d.ts.map +1 -1
  12. package/lib/types/affinity.js +32 -6
  13. package/lib/types/chatter.d.ts +111 -0
  14. package/lib/types/chatter.d.ts.map +1 -0
  15. package/lib/types/chatter.js +666 -0
  16. package/lib/types/client/PetDockEntry.d.ts +2 -0
  17. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  18. package/lib/types/client/PetDockEntry.js +2 -2
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +2 -2
  21. package/lib/types/client/PetSprite.d.ts +2 -0
  22. package/lib/types/client/PetSprite.d.ts.map +1 -1
  23. package/lib/types/client/PetSprite.js +100 -25
  24. package/lib/types/client/PluginSettingsCard.d.ts +7 -1
  25. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  26. package/lib/types/client/PluginSettingsCard.js +5 -3
  27. package/lib/types/client/index.d.ts +1 -1
  28. package/lib/types/client/index.d.ts.map +1 -1
  29. package/lib/types/client/index.js +31 -6
  30. package/lib/types/client/locales.d.ts +82 -80
  31. package/lib/types/client/locales.d.ts.map +1 -1
  32. package/lib/types/client/locales.js +2 -0
  33. package/lib/types/client/sequences.d.ts +10 -0
  34. package/lib/types/client/sequences.d.ts.map +1 -0
  35. package/lib/types/client/sequences.js +20 -0
  36. package/lib/types/client/settings-form.d.ts.map +1 -1
  37. package/lib/types/client/spritesheet.d.ts +1 -1
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +3 -12
  40. package/lib/types/event-projection.d.ts +14 -1
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +38 -17
  43. package/lib/types/index.d.ts +2 -1
  44. package/lib/types/index.d.ts.map +1 -1
  45. package/lib/types/index.js +15 -13
  46. package/lib/types/invariant.js +2 -2
  47. package/lib/types/ledger.d.ts.map +1 -1
  48. package/lib/types/ledger.js +18 -10
  49. package/lib/types/mount-once.d.ts +25 -0
  50. package/lib/types/mount-once.d.ts.map +1 -0
  51. package/lib/types/mount-once.js +42 -0
  52. package/lib/types/persist.d.ts.map +1 -1
  53. package/lib/types/persist.js +6 -4
  54. package/lib/types/registry.d.ts +7 -1
  55. package/lib/types/registry.d.ts.map +1 -1
  56. package/lib/types/registry.js +37 -1
  57. package/lib/types/remarks.d.ts +2 -0
  58. package/lib/types/remarks.d.ts.map +1 -1
  59. package/lib/types/remarks.js +5 -0
  60. package/lib/types/routes.js +2 -2
  61. package/lib/types/service.d.ts +16 -6
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +26 -8
  64. package/lib/types/state.d.ts +7 -4
  65. package/lib/types/state.d.ts.map +1 -1
  66. package/lib/types/state.js +20 -20
  67. package/package.json +9 -9
  68. package/src/affinity.test.ts +4 -2
  69. package/src/affinity.ts +37 -6
  70. package/src/chatter.test.ts +154 -0
  71. package/src/chatter.ts +697 -0
  72. package/src/client/PetDockEntry.tsx +3 -0
  73. package/src/client/PetSprite.test.tsx +246 -4
  74. package/src/client/PetSprite.tsx +132 -36
  75. package/src/client/PluginSettingsCard.tsx +10 -0
  76. package/src/client/index.ts +26 -3
  77. package/src/client/locales.ts +2 -0
  78. package/src/client/pet-css.test.ts +42 -0
  79. package/src/client/pet.module.css +173 -22
  80. package/src/client/sequences.test.ts +33 -0
  81. package/src/client/sequences.ts +33 -0
  82. package/src/client/spritesheet.ts +3 -13
  83. package/src/event-projection.ts +49 -16
  84. package/src/index.ts +4 -1
  85. package/src/ledger.test.ts +42 -1
  86. package/src/ledger.ts +15 -7
  87. package/src/mount-once.ts +48 -0
  88. package/src/persist.test.ts +18 -1
  89. package/src/persist.ts +3 -1
  90. package/src/registry.test.ts +50 -3
  91. package/src/registry.ts +47 -3
  92. package/src/remarks.ts +6 -0
  93. package/src/service.ts +42 -9
  94. package/src/state.test.ts +13 -0
  95. package/src/state.ts +24 -18
@@ -11,7 +11,7 @@
11
11
  * @module @linxin666/dsh-pet/client
12
12
  */
13
13
 
14
- import type { ClientContext, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
14
+ import type { ClientContext, ISessions, SessionId, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
15
15
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
16
16
  import type {} from '@deepseek-ai/dsh-client-locale/client'
17
17
  // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
@@ -72,8 +72,8 @@ const POLL_MS = 2000
72
72
  /** Settings namespace the pet settings card edits (the Host plugin registers it). */
73
73
  const PET_SETTINGS_NS = 'pet'
74
74
 
75
- /** Required services. */
76
- export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote']
75
+ /** Required services (sessions powers bubble-to-session navigation). */
76
+ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote', 'sessions']
77
77
 
78
78
  /** Re-exported for consumers that type against the injected face. */
79
79
  export type { PetInjected, PetDockEntryProps } from './PetDockEntry.tsx'
@@ -152,6 +152,11 @@ export function apply(ctx: ClientContext): void {
152
152
  // tick tries again. After it lands, one list feeds both the sprite and
153
153
  // the settings card's choices.
154
154
  let petsLoaded = false
155
+ // Latest-wins guard: the 2s tick, visibility recovery, and
156
+ // interaction-triggered refreshes can overlap; only the newest
157
+ // response may publish, so a slow older one can never roll the
158
+ // snapshot back.
159
+ let stateSeq = 0
155
160
  const pollNow = (): void => {
156
161
  if (!petsLoaded) {
157
162
  petApi.pets().then((list) => {
@@ -161,9 +166,13 @@ export function apply(ctx: ClientContext): void {
161
166
  // Retry on the next poll tick.
162
167
  })
163
168
  }
169
+ const seq = stateSeq + 1
170
+ stateSeq = seq
164
171
  petApi.state().then((snapshot) => {
172
+ if (seq !== stateSeq) return
165
173
  setSnapshot(snapshot)
166
174
  }, () => {
175
+ if (seq !== stateSeq) return
167
176
  setState('error', 'pet.state transport error')
168
177
  })
169
178
  }
@@ -202,9 +211,23 @@ export function apply(ctx: ClientContext): void {
202
211
  }
203
212
  }, 'pet: poll')
204
213
 
214
+ // Clicking a session bubble jumps the GUI to that session. A bubble
215
+ // can outlive its disposed session by one poll tick, and the sessions
216
+ // service fails loud on unknown ids, so consult the live list first.
217
+ // The pet's type program also loads the host-side dsh-session package
218
+ // through the service types, whose Context merge declares a different
219
+ // 'sessions' face; pin the browser runtime's outward face here.
220
+ const sessions = ctx.sessions as unknown as ISessions
221
+ const openSession = (sessionId: string): void => {
222
+ const list = sessions.list.getSnapshot()
223
+ if (list.byId[sessionId as SessionId] === undefined) return
224
+ sessions.open(sessionId as SessionId)
225
+ }
226
+
205
227
  const injected = (): PetInjected => ({
206
228
  store: petStore,
207
229
  ensure: pollNow,
230
+ openSession,
208
231
  pet: () => {
209
232
  petApi.interact('pet').then((result) => {
210
233
  setFeedback({
@@ -19,6 +19,7 @@ export const zh = {
19
19
  'pet.treats': '小鱼干 ×{n}',
20
20
  'pet.state.loading': '宠物正在赶来…',
21
21
  'pet.state.error': '宠物迷路了(连接失败)',
22
+ 'pet.openSessionHint': '点击跳转到对应会话',
22
23
  // 一级设置页(settings.section 席位)。
23
24
  'settings.title': '宠物',
24
25
  'settings.description': '选择宠物并调整它的显示布局。',
@@ -64,6 +65,7 @@ export const en = {
64
65
  'pet.treats': 'Treats ×{n}',
65
66
  'pet.state.loading': 'The pet is on its way…',
66
67
  'pet.state.error': 'The pet is lost (connection failed)',
68
+ 'pet.openSessionHint': 'Click to jump to this session',
67
69
  // First-level settings section (the `settings.section` seat).
68
70
  'settings.title': 'Pet',
69
71
  'settings.description': 'Pick a pet and tune its display layout.',
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ const css = readFileSync(new URL('./pet.module.css', import.meta.url), 'utf8')
5
+
6
+ describe('pet hover panel css', () => {
7
+ it('anchors the panel below the pet', () => {
8
+ const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
9
+ expect(panel).toContain('top: 100%')
10
+ expect(panel).toContain('margin-top: 8px')
11
+ expect(panel).not.toContain('right: 100%')
12
+ })
13
+
14
+ it('extends the hover bridge upward across the panel gap', () => {
15
+ const bridge = css.match(/\.panel::after\s*\{([^}]*)\}/)?.[1] ?? ''
16
+ expect(bridge).toContain('bottom: 100%')
17
+ expect(bridge).toContain('height: 14px')
18
+ })
19
+
20
+ it('styles the whisper bubble as a distinct non-interactive inner voice', () => {
21
+ const whisper = css.match(/\.bubbleWhisper\s*\{([^}]*)\}/)?.[1] ?? ''
22
+ expect(whisper).toContain('font-style: italic')
23
+ expect(whisper).toContain('pointer-events: none')
24
+ expect(css).toContain('@keyframes pet-whisper-in')
25
+ })
26
+
27
+ it('gives the panel and status bubbles entrance animations', () => {
28
+ expect(css).toContain('@keyframes pet-panel-in')
29
+ expect(css).toContain('@keyframes pet-bubble-in')
30
+ const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
31
+ expect(panel).toContain('animation: pet-panel-in')
32
+ })
33
+
34
+ it('can place the panel above and bridge the lower gap', () => {
35
+ const panel = css.match(/\.panelAbove\s*\{([^}]*)\}/)?.[1] ?? ''
36
+ expect(panel).toContain('bottom: 100%')
37
+ expect(panel).toContain('margin-bottom: 8px')
38
+ const bridge = css.match(/\.panelAbove::after\s*\{([^}]*)\}/)?.[1] ?? ''
39
+ expect(bridge).toContain('top: 100%')
40
+ expect(bridge).toContain('bottom: auto')
41
+ })
42
+ })
@@ -37,18 +37,63 @@
37
37
  background: rgba(56, 189, 248, 0.92);
38
38
  }
39
39
 
40
+ /* 状态气泡:深色渐变玻璃 + 青色描边辉光 + 进场浮现,
41
+ 让工作汇报从“标签”升级为“会呼吸的状态条”。 */
40
42
  .bubbleStatus {
41
43
  max-width: min(280px, calc(100vw - 24px));
42
44
  overflow: hidden;
43
45
  text-overflow: ellipsis;
44
- background: rgba(15, 23, 42, 0.9);
45
- border: 1px solid rgba(125, 211, 252, 0.5);
46
- animation: none;
46
+ background: linear-gradient(160deg, rgba(30, 41, 59, 0.88), rgba(15, 23, 42, 0.92));
47
+ border: 1px solid rgba(125, 211, 252, 0.55);
48
+ box-shadow:
49
+ 0 4px 14px rgba(2, 6, 23, 0.4),
50
+ 0 0 0 1px rgba(125, 211, 252, 0.12),
51
+ 0 0 18px rgba(56, 189, 248, 0.18);
52
+ backdrop-filter: blur(8px);
53
+ letter-spacing: 0.02em;
54
+ animation: pet-bubble-in 240ms ease-out;
55
+ }
56
+
57
+ /* 碎碎念气泡:宠物的内心悄悄话。紫粉渐变玻璃、虚线描边与斜体,
58
+ 配合「」引号,与青色的工作状态气泡一眼区分;不可点击、自动消散。 */
59
+ .bubbleWhisper {
60
+ position: relative;
61
+ max-width: min(260px, calc(100vw - 24px));
62
+ padding: 4px 12px;
63
+ border-radius: 999px;
64
+ font-size: 11.5px;
65
+ font-style: italic;
66
+ line-height: 1.45;
67
+ letter-spacing: 0.03em;
68
+ white-space: nowrap;
69
+ overflow: hidden;
70
+ text-overflow: ellipsis;
71
+ color: #f5d0fe;
72
+ background: linear-gradient(150deg, rgba(88, 28, 135, 0.52), rgba(157, 23, 77, 0.44));
73
+ border: 1px dashed rgba(240, 171, 252, 0.55);
74
+ box-shadow:
75
+ 0 3px 12px rgba(46, 16, 101, 0.4),
76
+ 0 0 14px rgba(217, 70, 239, 0.16);
77
+ backdrop-filter: blur(8px);
78
+ pointer-events: none;
79
+ animation: pet-whisper-in 320ms cubic-bezier(0.22, 1, 0.36, 1);
80
+ }
81
+
82
+ .bubbleWhisper::before {
83
+ content: '「';
84
+ margin-right: 2px;
85
+ opacity: 0.75;
86
+ }
87
+
88
+ .bubbleWhisper::after {
89
+ content: '」';
90
+ margin-left: 2px;
91
+ opacity: 0.75;
47
92
  }
48
93
 
49
94
  /* 多会话气泡栈:并行会话各自一个气泡,自精灵头顶向上堆叠,
50
95
  最近更新的会话在最上。栈内气泡改流入列布局,不再绝对定位
51
- 互相覆盖。 */
96
+ 互相覆盖。气泡可点击:点击跳转到对应会话。 */
52
97
  .bubbleStack {
53
98
  position: absolute;
54
99
  bottom: 100%;
@@ -56,13 +101,68 @@
56
101
  flex-direction: column;
57
102
  align-items: center;
58
103
  gap: 4px;
59
- pointer-events: none;
104
+ pointer-events: auto;
60
105
  }
61
106
 
62
107
  .bubbleStack .bubble {
63
108
  position: relative;
64
109
  bottom: auto;
65
110
  margin-bottom: 0;
111
+ pointer-events: auto;
112
+ }
113
+
114
+ .bubbleClickable {
115
+ cursor: pointer;
116
+ font: inherit;
117
+ text-align: center;
118
+ appearance: none;
119
+ transition: filter 120ms ease, box-shadow 120ms ease;
120
+ }
121
+
122
+ .bubbleClickable:hover {
123
+ filter: brightness(1.12);
124
+ }
125
+
126
+ .bubbleClickable:focus-visible {
127
+ outline: none;
128
+ /* 2px accent ring on the pet's own palette, theme-agnostic. */
129
+ box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
130
+ }
131
+
132
+ @keyframes pet-bubble-in {
133
+ from {
134
+ opacity: 0;
135
+ transform: translateY(6px) scale(0.96);
136
+ }
137
+ to {
138
+ opacity: 1;
139
+ transform: translateY(0) scale(1);
140
+ }
141
+ }
142
+
143
+ @keyframes pet-whisper-in {
144
+ 0% {
145
+ opacity: 0;
146
+ transform: translateY(8px) scale(0.9);
147
+ }
148
+ 60% {
149
+ transform: translateY(-1px) scale(1.02);
150
+ }
151
+ 100% {
152
+ opacity: 1;
153
+ transform: translateY(0) scale(1);
154
+ }
155
+ }
156
+
157
+ @keyframes pet-panel-in {
158
+ from {
159
+ opacity: 0;
160
+ transform: translateY(4px) scale(0.97);
161
+ }
162
+ to {
163
+ opacity: 1;
164
+ transform: translateY(0) scale(1);
165
+ }
66
166
  }
67
167
 
68
168
  @keyframes pet-bubble-pop {
@@ -86,37 +186,58 @@
86
186
  }
87
187
  }
88
188
 
189
+ /* 悬浮面板默认位于精灵脚下,不再占据精灵上方的空间:
190
+ 会话气泡栈独占精灵头顶区域,两者互不遮挡 —— 气泡因此可以始终
191
+ 显示并保持可点击,不必在 hover 时让位给面板。仅当视口底部空间
192
+ 不足时,面板才回退到精灵上方。 */
89
193
  .panel {
90
194
  position: absolute;
91
- bottom: 100%;
92
- background: rgba(15, 23, 42, 0.92);
93
- border: 1px solid rgba(148, 163, 184, 0.35);
94
- border-radius: 10px;
95
- padding: 8px 10px;
195
+ top: 100%;
196
+ margin-top: 8px;
197
+ background: linear-gradient(165deg, rgba(30, 41, 59, 0.94), rgba(15, 23, 42, 0.96));
198
+ border: 1px solid rgba(148, 163, 184, 0.4);
199
+ border-radius: 12px;
200
+ padding: 10px 12px;
96
201
  color: #e2e8f0;
97
202
  font-size: 12px;
98
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
99
- backdrop-filter: blur(6px);
203
+ box-shadow:
204
+ 0 8px 24px rgba(2, 6, 23, 0.45),
205
+ 0 0 0 1px rgba(125, 211, 252, 0.08),
206
+ inset 0 1px 0 rgba(226, 232, 240, 0.08);
207
+ backdrop-filter: blur(10px);
100
208
  display: flex;
101
209
  flex-direction: column;
102
- gap: 6px;
103
- min-width: 132px;
210
+ gap: 7px;
211
+ min-width: 148px;
212
+ animation: pet-panel-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
213
+ transform-origin: 50% 0;
104
214
  }
105
215
 
106
- /* 面板底边与图标顶边之间的 hover 桥接(issue #49):
107
- 面板绝对定位在图标上方(bottom: 100%),若两者之间存在哪怕几个
108
- 像素的间隙,指针滑过间隙时会触发容器的 pointerleave,面板在指针
109
- 抵达按钮前就收起。这段透明伪元素把命中区向下延伸、覆盖图标顶部,
110
- 使指针在图标与按钮之间始终停留在 hover 区域内。 */
216
+ /* 面板顶边与精灵底边之间的 hover 桥接(issue #49):
217
+ 面板在精灵下方留有 8px 间隙,透明伪元素向上覆盖间隙,
218
+ 使指针在精灵与按钮之间始终停留在 hover 区域。 */
111
219
  .panel::after {
112
220
  content: '';
113
221
  position: absolute;
114
- top: 100%;
222
+ bottom: 100%;
115
223
  left: 0;
116
224
  right: 0;
117
225
  height: 14px;
118
226
  }
119
227
 
228
+ .panelAbove {
229
+ top: auto;
230
+ bottom: 100%;
231
+ margin-top: 0;
232
+ margin-bottom: 8px;
233
+ transform-origin: 50% 100%;
234
+ }
235
+
236
+ .panelAbove::after {
237
+ top: 100%;
238
+ bottom: auto;
239
+ }
240
+
120
241
  .rankRow {
121
242
  display: flex;
122
243
  justify-content: space-between;
@@ -126,6 +247,28 @@
126
247
 
127
248
  .nameCell {
128
249
  font-weight: 600;
250
+ letter-spacing: 0.02em;
251
+ background: linear-gradient(90deg, #7dd3fc, #38bdf8 60%, #818cf8);
252
+ -webkit-background-clip: text;
253
+ background-clip: text;
254
+ color: transparent;
255
+ }
256
+
257
+ /* 面板数值的专属配色:称号紫、小鱼干青、亲密度琥珀,
258
+ 让两行数据扫一眼就能分辨。 */
259
+ .statRank {
260
+ color: #c4b5fd;
261
+ font-weight: 600;
262
+ }
263
+
264
+ .statTreats {
265
+ color: #7dd3fc;
266
+ font-weight: 600;
267
+ }
268
+
269
+ .statPoints {
270
+ color: #fcd34d;
271
+ font-weight: 600;
129
272
  }
130
273
 
131
274
  .renameRow {
@@ -166,15 +309,20 @@
166
309
  cursor: pointer;
167
310
  color: #0f172a;
168
311
  background: linear-gradient(180deg, #7dd3fc, #38bdf8);
169
- transition: filter 120ms ease, box-shadow 120ms ease;
312
+ box-shadow: 0 2px 6px rgba(56, 189, 248, 0.25);
313
+ transition: filter 120ms ease, box-shadow 120ms ease, transform 120ms ease;
170
314
  }
171
315
 
172
316
  .action:hover {
173
317
  filter: brightness(1.08);
318
+ transform: translateY(-1px);
319
+ box-shadow: 0 4px 10px rgba(56, 189, 248, 0.35);
174
320
  }
175
321
 
176
322
  .action:active {
177
323
  filter: brightness(0.94);
324
+ transform: translateY(0);
325
+ box-shadow: 0 1px 4px rgba(56, 189, 248, 0.25);
178
326
  }
179
327
 
180
328
  .action:focus-visible {
@@ -215,7 +363,10 @@
215
363
  /* Reduced motion: freeze the reaction bubble and kill the overlay transitions
216
364
  so the pet's motion is static or minimal without hiding interaction. */
217
365
  @media (prefers-reduced-motion: reduce) {
218
- .bubble {
366
+ .bubble,
367
+ .bubbleStatus,
368
+ .bubbleWhisper,
369
+ .panel {
219
370
  animation: none;
220
371
  opacity: 1;
221
372
  }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { PetTrackDef } from '../registry.ts'
3
+ import type { PetAnimation } from '../state.ts'
4
+ import { sequenceFrameAt } from './sequences.ts'
5
+
6
+ const track = (durations: number[]): PetTrackDef => ({
7
+ frames: durations.map((_, index) => index),
8
+ durations,
9
+ loop: true,
10
+ })
11
+
12
+ const tracks = {
13
+ running: track([100, 200]),
14
+ waiting: track([300, 100]),
15
+ } as Record<PetAnimation, PetTrackDef>
16
+
17
+ describe('sequenceFrameAt', () => {
18
+ const sequence: PetAnimation[] = ['running', 'waiting']
19
+
20
+ it('finishes every frame of one track before advancing', () => {
21
+ expect(sequenceFrameAt(sequence, tracks, 0)).toEqual({ animation: 'running', frameIndex: 0 })
22
+ expect(sequenceFrameAt(sequence, tracks, 100)).toEqual({ animation: 'running', frameIndex: 1 })
23
+ expect(sequenceFrameAt(sequence, tracks, 299)).toEqual({ animation: 'running', frameIndex: 1 })
24
+ expect(sequenceFrameAt(sequence, tracks, 300)).toEqual({ animation: 'waiting', frameIndex: 0 })
25
+ expect(sequenceFrameAt(sequence, tracks, 600)).toEqual({ animation: 'waiting', frameIndex: 1 })
26
+ })
27
+
28
+ it('loops the whole sequence and preserves overshoot', () => {
29
+ expect(sequenceFrameAt(sequence, tracks, 700)).toEqual({ animation: 'running', frameIndex: 0 })
30
+ expect(sequenceFrameAt(sequence, tracks, 850)).toEqual({ animation: 'running', frameIndex: 1 })
31
+ expect(sequenceFrameAt(sequence, tracks, 1_420)).toEqual({ animation: 'running', frameIndex: 0 })
32
+ })
33
+ })
@@ -0,0 +1,33 @@
1
+ /** Pure timing helpers for manifest-defined scene animation sequences. */
2
+
3
+ import type { PetTrackDef } from '../registry.ts'
4
+ import type { PetAnimation } from '../state.ts'
5
+
6
+ export interface SequenceFrame {
7
+ animation: PetAnimation
8
+ frameIndex: number
9
+ }
10
+
11
+ /** Resolve the active track and frame after elapsed milliseconds of a looping sequence. */
12
+ export function sequenceFrameAt(
13
+ sequence: readonly PetAnimation[],
14
+ tracks: Record<PetAnimation, PetTrackDef>,
15
+ elapsedMs: number,
16
+ ): SequenceFrame {
17
+ const itemDurations = sequence.map(animation => tracks[animation].durations.reduce((sum, value) => sum + value, 0))
18
+ const sequenceDuration = itemDurations.reduce((sum, value) => sum + value, 0)
19
+ let offset = Math.max(0, elapsedMs) % sequenceDuration
20
+ let itemIndex = 0
21
+ while (itemIndex < sequence.length - 1 && offset >= itemDurations[itemIndex]!) {
22
+ offset -= itemDurations[itemIndex]!
23
+ itemIndex += 1
24
+ }
25
+ const animation = sequence[itemIndex]!
26
+ const track = tracks[animation]
27
+ let frameIndex = 0
28
+ while (frameIndex < track.frames.length - 1 && offset >= track.durations[frameIndex]!) {
29
+ offset -= track.durations[frameIndex]!
30
+ frameIndex += 1
31
+ }
32
+ return { animation, frameIndex }
33
+ }
@@ -7,7 +7,7 @@
7
7
  * @module @linxin666/dsh-pet/client/spritesheet
8
8
  */
9
9
 
10
- import type { PetAnimation } from '../state.ts'
10
+ import { rowOf, type PetAnimation } from '../state.ts'
11
11
  import type { PetCell, PetTrackDef } from '../registry.ts'
12
12
 
13
13
  /** Animation track shape the frame loop consumes. */
@@ -15,18 +15,8 @@ export type TrackDef = PetTrackDef
15
15
 
16
16
  /** Row index of one animation track (the fixed 9-row contract). */
17
17
  export function rowOfTrack(animation: PetAnimation): number {
18
- const rows: Record<PetAnimation, number> = {
19
- idle: 0,
20
- 'running-right': 1,
21
- 'running-left': 2,
22
- waving: 3,
23
- jumping: 4,
24
- failed: 5,
25
- waiting: 6,
26
- running: 7,
27
- review: 8,
28
- }
29
- return rows[animation]
18
+ // The table itself lives in state.ts (rowOf) — the single source of truth.
19
+ return rowOf(animation)
30
20
  }
31
21
 
32
22
  /**
@@ -3,11 +3,17 @@
3
3
  * vocabulary onto the pet's visual phases and carries an optional completed-
4
4
  * turn reward for the ledger. Holds no state of its own; callers keep a
5
5
  * {@link ProjectionRuntime} per session and feed events in arrival order.
6
+ *
7
+ * Status copy comes from the chatter voice (big rotating pools, per-tool
8
+ * families, real-argument hints), and streamed model output feeds the murmur
9
+ * engine so the pet can whisper its inner voice (碎碎念). The wall clock is
10
+ * injected by the caller, keeping every projection reproducible.
6
11
  * @module @linxin666/dsh-pet/event-projection
7
12
  */
8
13
 
9
14
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
10
15
  import type { PetStateInput } from './state.ts'
16
+ import { StatusVoice, toolArgHint, WhisperEngine } from './chatter.ts'
11
17
 
12
18
  /** Runtime shape of the optional legacy activity event. */
13
19
  export interface ActivityStatusEventLike {
@@ -21,23 +27,35 @@ export interface ProjectionRuntime {
21
27
  activeTools: Set<string>
22
28
  officialEventsSeen: boolean
23
29
  stepHadFailure: boolean
30
+ /** Round-robin status copy voice (scene-stable, cadence-rotated). */
31
+ voice: StatusVoice
32
+ /** Inner-whisper engine fed by the model's own streamed output. */
33
+ whispers: WhisperEngine
24
34
  }
25
35
 
26
36
  /** One official event projection, optionally carrying a completed turn reward. */
27
37
  export interface PetActivityTransition {
28
38
  input: PetStateInput
29
39
  completedTurn?: number
40
+ /** A fresh inner whisper woken by this event's model output, when any. */
41
+ whisper?: string
30
42
  }
31
43
 
32
44
  /** Fresh projection runtime for a newly seen session. */
33
45
  export function emptyProjectionRuntime(): ProjectionRuntime {
34
- return { activeTools: new Set(), officialEventsSeen: false, stepHadFailure: false }
46
+ return {
47
+ activeTools: new Set(),
48
+ officialEventsSeen: false,
49
+ stepHadFailure: false,
50
+ voice: new StatusVoice(),
51
+ whispers: new WhisperEngine(),
52
+ }
35
53
  }
36
54
 
37
55
  /** Keep tool names readable inside the compact status bubble. */
38
56
  function displayToolName(name: string): string {
39
57
  const compact = name.replace(/\s+/g, ' ').trim() || '工具'
40
- return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`
58
+ return compact.length <= 24 ? compact : compact.slice(0, 21) + '...'
41
59
  }
42
60
 
43
61
  /** Whether a legacy phase is part of the pet's supported vocabulary. */
@@ -48,38 +66,53 @@ export function isActivityPhase(phase: string): phase is PetStateInput['phase']
48
66
  /**
49
67
  * Project the durable DSH session vocabulary into the pet's visual phases.
50
68
  * Unknown and log-only events do not disturb the last meaningful activity.
69
+ * @param nowMs - injected wall clock for copy rotation and whisper pacing.
51
70
  */
52
71
  export function projectOfficialEvent(
53
72
  event: SessionEvent,
54
73
  runtime: ProjectionRuntime,
74
+ nowMs: number = Date.now(),
55
75
  ): PetActivityTransition | undefined {
56
76
  switch (event.type) {
57
77
  case 'turn/start':
58
78
  runtime.activeTools.clear()
59
79
  runtime.stepHadFailure = false
60
- return { input: { phase: 'waiting', line: '准备开始' } }
80
+ return { input: { phase: 'waiting', line: runtime.voice.scene('prepare', nowMs) } }
61
81
  case 'step/start':
62
82
  runtime.activeTools.clear()
63
83
  runtime.stepHadFailure = false
64
- return { input: { phase: 'waiting', line: '等待模型响应' } }
84
+ return { input: { phase: 'waiting', line: runtime.voice.scene('waiting', nowMs) } }
65
85
  case 'assistant/chunk': {
66
86
  const { chunk } = event.data
67
87
  if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
68
- return { input: { phase: 'thinking', line: '正在思考' } }
88
+ const whisper = runtime.whispers.feed(chunk.text, nowMs)
89
+ return {
90
+ input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
91
+ ...(whisper === undefined ? {} : { whisper }),
92
+ }
69
93
  }
70
94
  if (chunk.type === 'text-delta' && chunk.text.length > 0) {
71
- return { input: { phase: 'review', line: '整理回复中' } }
95
+ const whisper = runtime.whispers.feed(chunk.text, nowMs)
96
+ return {
97
+ input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
98
+ ...(whisper === undefined ? {} : { whisper }),
99
+ }
72
100
  }
73
101
  return undefined
74
102
  }
75
103
  case 'assistant/message':
76
- return { input: { phase: 'review', line: '整理回复中' } }
104
+ return { input: { phase: 'review', line: runtime.voice.scene('review', nowMs) } }
77
105
  case 'tool/call':
78
106
  runtime.activeTools.add(String(event.data.callId))
79
107
  return {
80
108
  input: {
81
109
  phase: 'tool',
82
- line: `正在使用 ${displayToolName(event.data.name)}`,
110
+ line: runtime.voice.tool(
111
+ event.data.name,
112
+ displayToolName(event.data.name),
113
+ toolArgHint(event.data.name, event.data.arguments),
114
+ nowMs,
115
+ ),
83
116
  },
84
117
  }
85
118
  case 'tool/result': {
@@ -90,30 +123,30 @@ export function projectOfficialEvent(
90
123
  return {
91
124
  input: {
92
125
  phase: 'tool',
93
- line: `还有 ${runtime.activeTools.size} 个工具运行中`,
126
+ line: runtime.voice.toolRemaining(runtime.activeTools.size, nowMs),
94
127
  },
95
128
  }
96
129
  }
97
130
  return runtime.stepHadFailure
98
- ? { input: { phase: 'failed', line: '工具执行失败' } }
99
- : { input: { phase: 'thinking', line: '处理工具结果' } }
131
+ ? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) } }
132
+ : { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) } }
100
133
  }
101
134
  case 'turn/end': {
102
135
  runtime.activeTools.clear()
103
136
  switch (event.data.reason.kind) {
104
137
  case 'completed':
105
138
  return {
106
- input: { phase: 'done', line: '完成啦' },
139
+ input: { phase: 'done', line: runtime.voice.scene('done', nowMs) },
107
140
  completedTurn: event.data.turn,
108
141
  }
109
142
  case 'error':
110
- return { input: { phase: 'failed', line: '执行失败' } }
143
+ return { input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) } }
111
144
  case 'max-tokens':
112
- return { input: { phase: 'failed', line: '达到输出上限' } }
145
+ return { input: { phase: 'failed', line: runtime.voice.scene('maxTokens', nowMs) } }
113
146
  case 'interrupted':
114
- return { input: { phase: 'failed', line: '执行意外中断' } }
147
+ return { input: { phase: 'failed', line: runtime.voice.scene('interrupted', nowMs) } }
115
148
  case 'blocked':
116
- return { input: { phase: 'waiting', line: '等待继续' } }
149
+ return { input: { phase: 'waiting', line: runtime.voice.scene('blocked', nowMs) } }
117
150
  case 'aborted':
118
151
  // A stopped session settles to idle without a bubble: the pet
119
152
  // visibly calms down and the session drops out of the bubble stack.
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSec
19
19
  import { makePetRoutes } from './routes.ts'
20
20
  import { loadPetRegistry, petPackageRoot } from './registry.ts'
21
21
  import { DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
22
+ import { mountOnce } from './mount-once.ts'
22
23
 
23
24
  export { PetService, MAX_SESSION_BUBBLES } from './service.ts'
24
25
  export type {
@@ -137,7 +138,9 @@ export function makePetSettingsSchema(fallbackPetId: string) {
137
138
  }
138
139
 
139
140
  /** Register the pet service and its API + asset routes on the context. */
140
- export function apply(ctx: Context, config: PetConfig = {}): void {
141
+ export const apply = mountOnce('@linxin666/dsh-pet', applyImpl)
142
+
143
+ function applyImpl(ctx: Context, config: PetConfig = {}): void {
141
144
  const registry = config.registry
142
145
  ?? loadPetRegistry({
143
146
  packageRoot: petPackageRoot(import.meta.url),