@linxin666/dsh-pet 0.1.20 → 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 (65) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -10
  3. package/README.zh.md +17 -10
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +158 -26
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +853 -41
  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 +4 -0
  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/PetSprite.d.ts.map +1 -1
  17. package/lib/types/client/PetSprite.js +88 -14
  18. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  19. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PluginSettingsCard.js +5 -3
  21. package/lib/types/client/sequences.d.ts +10 -0
  22. package/lib/types/client/sequences.d.ts.map +1 -0
  23. package/lib/types/client/sequences.js +20 -0
  24. package/lib/types/event-projection.d.ts +14 -1
  25. package/lib/types/event-projection.d.ts.map +1 -1
  26. package/lib/types/event-projection.js +38 -17
  27. package/lib/types/ledger.d.ts.map +1 -1
  28. package/lib/types/ledger.js +15 -7
  29. package/lib/types/persist.d.ts.map +1 -1
  30. package/lib/types/persist.js +3 -1
  31. package/lib/types/registry.d.ts +7 -1
  32. package/lib/types/registry.d.ts.map +1 -1
  33. package/lib/types/registry.js +36 -0
  34. package/lib/types/remarks.d.ts +2 -0
  35. package/lib/types/remarks.d.ts.map +1 -1
  36. package/lib/types/remarks.js +5 -0
  37. package/lib/types/service.d.ts +6 -0
  38. package/lib/types/service.d.ts.map +1 -1
  39. package/lib/types/service.js +15 -2
  40. package/lib/types/state.d.ts +7 -4
  41. package/lib/types/state.d.ts.map +1 -1
  42. package/lib/types/state.js +20 -20
  43. package/package.json +9 -9
  44. package/src/affinity.test.ts +4 -2
  45. package/src/affinity.ts +37 -6
  46. package/src/chatter.test.ts +154 -0
  47. package/src/chatter.ts +697 -0
  48. package/src/client/PetSprite.test.tsx +190 -1
  49. package/src/client/PetSprite.tsx +112 -25
  50. package/src/client/PluginSettingsCard.tsx +10 -0
  51. package/src/client/pet-css.test.ts +42 -0
  52. package/src/client/pet.module.css +154 -26
  53. package/src/client/sequences.test.ts +33 -0
  54. package/src/client/sequences.ts +33 -0
  55. package/src/event-projection.ts +49 -16
  56. package/src/ledger.test.ts +42 -1
  57. package/src/ledger.ts +15 -7
  58. package/src/persist.test.ts +18 -1
  59. package/src/persist.ts +3 -1
  60. package/src/registry.test.ts +50 -3
  61. package/src/registry.ts +47 -3
  62. package/src/remarks.ts +6 -0
  63. package/src/service.ts +27 -2
  64. package/src/state.test.ts +13 -0
  65. package/src/state.ts +24 -18
@@ -37,13 +37,58 @@
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
  /* 多会话气泡栈:并行会话各自一个气泡,自精灵头顶向上堆叠,
@@ -84,6 +129,42 @@
84
129
  box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
85
130
  }
86
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
+ }
166
+ }
167
+
87
168
  @keyframes pet-bubble-pop {
88
169
  0% {
89
170
  opacity: 0;
@@ -105,39 +186,56 @@
105
186
  }
106
187
  }
107
188
 
108
- /* 悬浮面板位于精灵左侧(bottom 对齐),不再占据精灵上方的空间:
189
+ /* 悬浮面板默认位于精灵脚下,不再占据精灵上方的空间:
109
190
  会话气泡栈独占精灵头顶区域,两者互不遮挡 —— 气泡因此可以始终
110
- 显示并保持可点击,不必在 hover 时让位给面板。 */
191
+ 显示并保持可点击,不必在 hover 时让位给面板。仅当视口底部空间
192
+ 不足时,面板才回退到精灵上方。 */
111
193
  .panel {
112
194
  position: absolute;
113
- bottom: 0;
114
- right: 100%;
115
- margin-right: 8px;
116
- background: rgba(15, 23, 42, 0.92);
117
- border: 1px solid rgba(148, 163, 184, 0.35);
118
- border-radius: 10px;
119
- 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;
120
201
  color: #e2e8f0;
121
202
  font-size: 12px;
122
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
123
- 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);
124
208
  display: flex;
125
209
  flex-direction: column;
126
- gap: 6px;
127
- 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;
128
214
  }
129
215
 
130
- /* 面板右缘与图标左缘之间的 hover 桥接(issue #49 的水平版):
131
- 面板在图标左侧留有 8px 间隙,指针滑过间隙时会触发容器的
132
- pointerleave,面板会在指针抵达按钮前收起。这段透明伪元素把命中区
133
- 向右延伸、覆盖间隙,使指针在图标与按钮之间始终停留在 hover 区域。 */
216
+ /* 面板顶边与精灵底边之间的 hover 桥接(issue #49):
217
+ 面板在精灵下方留有 8px 间隙,透明伪元素向上覆盖间隙,
218
+ 使指针在精灵与按钮之间始终停留在 hover 区域。 */
134
219
  .panel::after {
135
220
  content: '';
136
221
  position: absolute;
137
- top: 0;
138
- bottom: 0;
139
- left: 100%;
140
- width: 14px;
222
+ bottom: 100%;
223
+ left: 0;
224
+ right: 0;
225
+ height: 14px;
226
+ }
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;
141
239
  }
142
240
 
143
241
  .rankRow {
@@ -149,6 +247,28 @@
149
247
 
150
248
  .nameCell {
151
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;
152
272
  }
153
273
 
154
274
  .renameRow {
@@ -189,15 +309,20 @@
189
309
  cursor: pointer;
190
310
  color: #0f172a;
191
311
  background: linear-gradient(180deg, #7dd3fc, #38bdf8);
192
- 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;
193
314
  }
194
315
 
195
316
  .action:hover {
196
317
  filter: brightness(1.08);
318
+ transform: translateY(-1px);
319
+ box-shadow: 0 4px 10px rgba(56, 189, 248, 0.35);
197
320
  }
198
321
 
199
322
  .action:active {
200
323
  filter: brightness(0.94);
324
+ transform: translateY(0);
325
+ box-shadow: 0 1px 4px rgba(56, 189, 248, 0.25);
201
326
  }
202
327
 
203
328
  .action:focus-visible {
@@ -238,7 +363,10 @@
238
363
  /* Reduced motion: freeze the reaction bubble and kill the overlay transitions
239
364
  so the pet's motion is static or minimal without hiding interaction. */
240
365
  @media (prefers-reduced-motion: reduce) {
241
- .bubble {
366
+ .bubble,
367
+ .bubbleStatus,
368
+ .bubbleWhisper,
369
+ .panel {
242
370
  animation: none;
243
371
  opacity: 1;
244
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
+ }
@@ -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.
@@ -3,6 +3,7 @@ import { defaultTreatConfig } from './treats.ts'
3
3
  import { defaultAffinityConfig } from './affinity.ts'
4
4
  import { emptyPersist } from './persist.ts'
5
5
  import { PetLedger } from './ledger.ts'
6
+ import { BUILTIN_REMARKS } from './remarks.ts'
6
7
 
7
8
  describe('PetLedger', () => {
8
9
  it('settles the economy on completed turns (work treat per 30 turns)', () => {
@@ -66,13 +67,53 @@ describe('PetLedger', () => {
66
67
  expect(ledger.interact('feed', n + 2).reaction).toBe('没有小鱼干了,多陪我工作一会儿吧~')
67
68
  })
68
69
 
70
+ it('selects success remarks from persisted lifetime interaction counts', () => {
71
+ const n = 1_000_000
72
+ const persist = emptyPersist()
73
+ persist.affinity.pets = 1
74
+ persist.affinity.feeds = 1
75
+ persist.treats.treats = 1
76
+ persist.treats.lastTreatGrantAt = n
77
+ const ledger = new PetLedger(persist, {
78
+ remarks: {
79
+ pet: ['摸头一', '摸头二'],
80
+ feed: ['喂食一', '喂食二'],
81
+ },
82
+ })
83
+ expect(ledger.interact('pet', n).reaction).toBe('摸头二')
84
+ expect(ledger.interact('feed', n).reaction).toBe('喂食二')
85
+ })
86
+
87
+ it('rotates cooldown remarks by persisted rejection counts', () => {
88
+ const n = 1_000_000
89
+ const remarks = {
90
+ petCooldown: ['摸头冷却一', '摸头冷却二', '摸头冷却三'],
91
+ feedCooldown: ['喂食冷却一', '喂食冷却二', '喂食冷却三'],
92
+ }
93
+ const persist = emptyPersist()
94
+ persist.affinity.lastPetAt = n
95
+ persist.affinity.lastFeedAt = n
96
+ const ledger = new PetLedger(persist, { remarks })
97
+ expect(ledger.interact('pet', n + 1).reaction).toBe('摸头冷却一')
98
+ expect(ledger.interact('pet', n + 2).reaction).toBe('摸头冷却二')
99
+ expect(ledger.interact('feed', n + 1).reaction).toBe('喂食冷却一')
100
+ expect(ledger.interact('feed', n + 2).reaction).toBe('喂食冷却二')
101
+ expect(ledger.snapshot.affinity.petRejects).toBe(2)
102
+ expect(ledger.snapshot.affinity.feedRejects).toBe(2)
103
+
104
+ const restored = new PetLedger(ledger.snapshot, { remarks })
105
+ expect(restored.interact('pet', n + 3).reaction).toBe('摸头冷却三')
106
+ expect(restored.interact('feed', n + 3).reaction).toBe('喂食冷却三')
107
+ })
108
+
69
109
  it('swaps remark pools when the selected pet changes', () => {
70
110
  const ledger = new PetLedger(emptyPersist(), { remarks: { pet: ['旧宠物台词'] } })
71
111
  expect(ledger.interact('pet', 1_000_000).reaction).toBe('旧宠物台词')
72
112
  ledger.setRemarks({ pet: ['新宠物台词'] })
73
113
  expect(ledger.interact('pet', 1_011_000).reaction).toBe('新宠物台词')
74
114
  ledger.setRemarks(undefined)
75
- expect(ledger.interact('pet', 1_022_000).reaction).toBe('咕噜咕噜~被摸摸好舒服!')
115
+ // Changing pools does not reset the persisted lifetime interaction count.
116
+ expect(ledger.interact('pet', 1_022_000).reaction).toBe(BUILTIN_REMARKS.pet[2])
76
117
  })
77
118
 
78
119
  it('refuses a feed on an empty stock without burning anything', () => {
package/src/ledger.ts CHANGED
@@ -182,12 +182,19 @@ export class PetLedger {
182
182
  */
183
183
  interact(kind: PetInteraction, nowMs: number): LedgerInteractionResult {
184
184
  if (kind === 'feed') this.settleTreats(nowMs)
185
- const outcome = applyInteraction(this.current.affinity, kind, nowMs, this.affinityConfig)
185
+ const before = this.current.affinity
186
+ const outcome = applyInteraction(before, kind, nowMs, this.affinityConfig)
186
187
  // Reactions come from the picker pools (custom per-pet slots override
187
188
  // the built-in library per slot); outcome.reaction stays the fallback
188
189
  // copy for direct applyInteraction callers.
189
190
  if (kind === 'feed' && !outcome.accepted) {
190
- return { reaction: this.picker.pick('feedCooldown'), delta: 0, affinity: this.affinityView(nowMs) }
191
+ this.current = { ...this.current, affinity: outcome.affinity }
192
+ this.dirty = true
193
+ return {
194
+ reaction: this.picker.pickAt('feedCooldown', before.feedRejects),
195
+ delta: 0,
196
+ affinity: this.affinityView(nowMs),
197
+ }
191
198
  }
192
199
  if (kind === 'feed') {
193
200
  const consume = consumeTreat(this.current.treats)
@@ -201,12 +208,13 @@ export class PetLedger {
201
208
  this.current = { ...this.current, treats: consume.ledger }
202
209
  this.dirty = true
203
210
  }
204
- if (outcome.accepted) {
205
- this.current = { ...this.current, affinity: outcome.affinity }
206
- this.dirty = true
207
- }
211
+ this.current = { ...this.current, affinity: outcome.affinity }
212
+ this.dirty = true
213
+ const count = kind === 'pet'
214
+ ? outcome.accepted ? before.pets : before.petRejects
215
+ : before.feeds
208
216
  return {
209
- reaction: this.picker.pick(outcome.accepted ? kind : 'petCooldown'),
217
+ reaction: this.picker.pickAt(outcome.accepted ? kind : 'petCooldown', count),
210
218
  delta: outcome.delta,
211
219
  affinity: this.affinityView(nowMs),
212
220
  }
@@ -20,6 +20,10 @@ function tempDir(): string {
20
20
  }
21
21
 
22
22
  describe('loadPetPersist', () => {
23
+ it('reserves enough default space for the hover panel below the pet', () => {
24
+ expect(defaultDisplayConfig.bottom).toBeGreaterThanOrEqual(100)
25
+ })
26
+
23
27
  it('falls back to defaults when the file is missing', () => {
24
28
  const dir = tempDir()
25
29
  try {
@@ -67,6 +71,8 @@ describe('loadPetPersist', () => {
67
71
  expect(loaded.petId).toBe(DEFAULT_PET_ID)
68
72
  expect(loaded.names).toEqual({ [DEFAULT_PET_ID]: '泡泡' })
69
73
  expect(loaded.affinity.points).toBe(5)
74
+ expect(loaded.affinity.petRejects).toBe(0)
75
+ expect(loaded.affinity.feedRejects).toBe(0)
70
76
  expect(loaded.display).toEqual(defaultDisplayConfig)
71
77
  } finally {
72
78
  rmSync(dir, { recursive: true, force: true })
@@ -108,7 +114,16 @@ describe('loadPetPersist', () => {
108
114
  writeFileSync(join(dir, 'pet.json'), JSON.stringify({
109
115
  name: ' ',
110
116
  names: { bad: ' ' },
111
- affinity: { points: AFFINITY_MAX + 5000, lastPetAt: -5, lastFeedAt: 'x', pets: -1, feeds: 1.5, turns: 0 },
117
+ affinity: {
118
+ points: AFFINITY_MAX + 5000,
119
+ lastPetAt: -5,
120
+ lastFeedAt: 'x',
121
+ pets: -1,
122
+ feeds: 1.5,
123
+ turns: 0,
124
+ petRejects: 4,
125
+ feedRejects: -2,
126
+ },
112
127
  treats: { treats: 150, lastTreatGrantAt: -1, turnsAtLastTreatGrant: 0 },
113
128
  display: { visible: 'yes', size: -10, right: 1e12, bottom: 20 },
114
129
  }), 'utf8')
@@ -120,6 +135,8 @@ describe('loadPetPersist', () => {
120
135
  expect(loaded.affinity.lastFeedAt).toBe(0)
121
136
  expect(loaded.affinity.pets).toBe(0)
122
137
  expect(loaded.affinity.feeds).toBe(1.5) // finite numbers pass through
138
+ expect(loaded.affinity.petRejects).toBe(4)
139
+ expect(loaded.affinity.feedRejects).toBe(0)
123
140
  expect(loaded.treats.treats).toBe(defaultTreatConfig.maxTreats)
124
141
  expect(loaded.treats.lastTreatGrantAt).toBe(0)
125
142
  expect(loaded.display.visible).toBe(defaultDisplayConfig.visible)
package/src/persist.ts CHANGED
@@ -27,7 +27,7 @@ export const defaultDisplayConfig: PetDisplayConfig = {
27
27
  visible: true,
28
28
  size: 160,
29
29
  right: 24,
30
- bottom: 20,
30
+ bottom: 120,
31
31
  }
32
32
 
33
33
  /** Display value bounds (shared by load-time validation and setConfig). */
@@ -117,6 +117,8 @@ export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
117
117
  lastFeedAt: clamp(finiteNum(rawAffinity.lastFeedAt, 0), Number.MAX_SAFE_INTEGER),
118
118
  pets: clamp(finiteNum(rawAffinity.pets, 0), Number.MAX_SAFE_INTEGER),
119
119
  feeds: clamp(finiteNum(rawAffinity.feeds, 0), Number.MAX_SAFE_INTEGER),
120
+ petRejects: clamp(finiteNum(rawAffinity.petRejects, 0), Number.MAX_SAFE_INTEGER),
121
+ feedRejects: clamp(finiteNum(rawAffinity.feedRejects, 0), Number.MAX_SAFE_INTEGER),
120
122
  turns: clamp(finiteNum(rawAffinity.turns, 0), Number.MAX_SAFE_INTEGER),
121
123
  }
122
124
  const rawTreats = (parsed.treats ?? {}) as Partial<TreatLedger>