@linxin666/dsh-pet 0.1.18 → 0.1.20

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 (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +12 -5
  3. package/README.zh.md +12 -5
  4. package/lib/client.js +95 -26
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +144 -31
  7. package/lib/invariant.js +2 -2
  8. package/lib/state-Diiq8vjk.js +394 -0
  9. package/lib/types/affinity.d.ts +39 -11
  10. package/lib/types/affinity.d.ts.map +1 -1
  11. package/lib/types/affinity.js +21 -7
  12. package/lib/types/client/PetDockEntry.d.ts +2 -0
  13. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  14. package/lib/types/client/PetDockEntry.js +2 -2
  15. package/lib/types/client/PetSettingsCard.d.ts +5 -0
  16. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/PetSettingsCard.js +9 -2
  18. package/lib/types/client/PetSprite.d.ts +2 -0
  19. package/lib/types/client/PetSprite.d.ts.map +1 -1
  20. package/lib/types/client/PetSprite.js +22 -15
  21. package/lib/types/client/PluginSettingsCard.d.ts +1 -1
  22. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  23. package/lib/types/client/index.d.ts +1 -1
  24. package/lib/types/client/index.d.ts.map +1 -1
  25. package/lib/types/client/index.js +45 -14
  26. package/lib/types/client/locales.d.ts +82 -80
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/locales.js +2 -0
  29. package/lib/types/client/settings-form.d.ts +8 -0
  30. package/lib/types/client/settings-form.d.ts.map +1 -1
  31. package/lib/types/client/settings-form.js +15 -1
  32. package/lib/types/client/spritesheet.d.ts +1 -1
  33. package/lib/types/client/spritesheet.d.ts.map +1 -1
  34. package/lib/types/client/spritesheet.js +3 -12
  35. package/lib/types/event-projection.d.ts.map +1 -1
  36. package/lib/types/event-projection.js +3 -1
  37. package/lib/types/index.d.ts +6 -3
  38. package/lib/types/index.d.ts.map +1 -1
  39. package/lib/types/index.js +15 -12
  40. package/lib/types/invariant.js +2 -2
  41. package/lib/types/ledger.d.ts +16 -1
  42. package/lib/types/ledger.d.ts.map +1 -1
  43. package/lib/types/ledger.js +31 -6
  44. package/lib/types/mount-once.d.ts +25 -0
  45. package/lib/types/mount-once.d.ts.map +1 -0
  46. package/lib/types/mount-once.js +42 -0
  47. package/lib/types/persist.js +3 -3
  48. package/lib/types/registry.d.ts +10 -0
  49. package/lib/types/registry.d.ts.map +1 -1
  50. package/lib/types/registry.js +3 -0
  51. package/lib/types/remarks.d.ts +56 -0
  52. package/lib/types/remarks.d.ts.map +1 -0
  53. package/lib/types/remarks.js +157 -0
  54. package/lib/types/routes.js +2 -2
  55. package/lib/types/service.d.ts +44 -3
  56. package/lib/types/service.d.ts.map +1 -1
  57. package/lib/types/service.js +100 -23
  58. package/lib/types/state.d.ts.map +1 -1
  59. package/lib/types/state.js +5 -5
  60. package/lib/types/treats.d.ts +3 -0
  61. package/lib/types/treats.d.ts.map +1 -1
  62. package/lib/types/treats.js +5 -2
  63. package/package.json +1 -1
  64. package/src/affinity.test.ts +19 -0
  65. package/src/affinity.ts +22 -7
  66. package/src/client/PetDockEntry.tsx +3 -0
  67. package/src/client/PetSettingsCard.tsx +8 -0
  68. package/src/client/PetSprite.test.tsx +89 -3
  69. package/src/client/PetSprite.tsx +36 -12
  70. package/src/client/index.ts +40 -11
  71. package/src/client/locales.ts +2 -0
  72. package/src/client/pet.module.css +52 -10
  73. package/src/client/settings-form.ts +15 -1
  74. package/src/client/spritesheet.ts +3 -13
  75. package/src/event-projection.ts +3 -1
  76. package/src/index.ts +16 -2
  77. package/src/ledger.test.ts +40 -9
  78. package/src/ledger.ts +33 -4
  79. package/src/mount-once.ts +48 -0
  80. package/src/persist.test.ts +1 -1
  81. package/src/registry.test.ts +27 -0
  82. package/src/registry.ts +12 -0
  83. package/src/remarks.test.ts +89 -0
  84. package/src/remarks.ts +177 -0
  85. package/src/service.ts +124 -18
  86. package/src/state.test.ts +9 -0
  87. package/src/state.ts +5 -5
  88. package/src/treats.test.ts +13 -11
  89. package/src/treats.ts +5 -2
  90. package/lib/state-CFyJv0sQ.js +0 -213
@@ -88,9 +88,13 @@ afterEach(() => {
88
88
  cleanup()
89
89
  })
90
90
 
91
- /** Render the pet with mocked callbacks; returns the rename spy. */
92
- function renderPet(overrides: Partial<PetSpriteProps> = {}): { onRename: ReturnType<typeof vi.fn> } {
91
+ /** Render the pet with mocked callbacks; returns the rename and open spys. */
92
+ function renderPet(overrides: Partial<PetSpriteProps> = {}): {
93
+ onRename: ReturnType<typeof vi.fn>
94
+ onOpenSession: ReturnType<typeof vi.fn>
95
+ } {
93
96
  const onRename = vi.fn()
97
+ const onOpenSession = vi.fn()
94
98
  const props: PetSpriteProps = {
95
99
  snapshot,
96
100
  definition: petDefinition(),
@@ -101,12 +105,13 @@ function renderPet(overrides: Partial<PetSpriteProps> = {}): { onRename: ReturnT
101
105
  onHide: vi.fn(),
102
106
  onDragEnd: vi.fn(),
103
107
  onRename,
108
+ onOpenSession,
104
109
  onFeedbackDone: vi.fn(),
105
110
  t,
106
111
  ...overrides,
107
112
  }
108
113
  render(<PetSprite {...props} />)
109
- return { onRename }
114
+ return { onRename, onOpenSession }
110
115
  }
111
116
 
112
117
  /** Hover the sprite to open the panel, then click the rename button. */
@@ -194,6 +199,87 @@ describe('PetSprite status bubble', () => {
194
199
  expect(screen.queryByText('摸摸成功')).not.toBeNull()
195
200
  expect(screen.queryByText('正在思考')).toBeNull()
196
201
  })
202
+
203
+ it('renders one bubble per concurrent session without duplicating the global bubble', () => {
204
+ renderPet({
205
+ snapshot: {
206
+ ...workingSnapshot,
207
+ bubble: '正在思考',
208
+ sessions: [
209
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
210
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
211
+ ],
212
+ },
213
+ })
214
+ // The display session appears in the stack exactly once: the legacy
215
+ // single bubble is not rendered on top of the session list.
216
+ expect(screen.getAllByText('正在思考')).toHaveLength(1)
217
+ expect(screen.queryByText('正在使用 grep')).not.toBeNull()
218
+ })
219
+
220
+ it('lets feedback replace the whole session bubble stack', () => {
221
+ renderPet({
222
+ snapshot: {
223
+ ...workingSnapshot,
224
+ sessions: [
225
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
226
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
227
+ ],
228
+ },
229
+ feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
230
+ })
231
+ expect(screen.queryByText('摸摸成功')).not.toBeNull()
232
+ expect(screen.queryByText('正在思考')).toBeNull()
233
+ expect(screen.queryByText('正在使用 grep')).toBeNull()
234
+ })
235
+
236
+ it('clicking a session bubble navigates to that session', () => {
237
+ const { onOpenSession } = renderPet({
238
+ snapshot: {
239
+ ...workingSnapshot,
240
+ bubble: '正在思考',
241
+ sessions: [
242
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
243
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
244
+ ],
245
+ },
246
+ })
247
+ fireEvent.click(screen.getByText('正在使用 grep'))
248
+ expect(onOpenSession).toHaveBeenCalledTimes(1)
249
+ expect(onOpenSession).toHaveBeenCalledWith('s-b')
250
+ fireEvent.click(screen.getByText('正在思考'))
251
+ expect(onOpenSession).toHaveBeenCalledTimes(2)
252
+ expect(onOpenSession).toHaveBeenCalledWith('s-a')
253
+ // Petting stays on the sprite only: bubble clicks must not pet.
254
+ })
255
+
256
+ it('clicking the legacy single bubble does not navigate (no session identity)', () => {
257
+ const { onOpenSession } = renderPet({ snapshot: workingSnapshot })
258
+ fireEvent.click(screen.getByText('正在思考'))
259
+ expect(onOpenSession).not.toHaveBeenCalled()
260
+ })
261
+
262
+ it('keeps session bubbles visible and clickable while the hover panel is open', () => {
263
+ // Regression: the panel used to occupy the same region as the bubble
264
+ // stack and hide it on hover, so reaching a bubble was impossible. The
265
+ // panel now opens beside the sprite and the stack stays interactive.
266
+ const { onOpenSession } = renderPet({
267
+ snapshot: {
268
+ ...workingSnapshot,
269
+ sessions: [
270
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
271
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
272
+ ],
273
+ },
274
+ })
275
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
276
+ // The hover panel is open...
277
+ expect(screen.queryByText('改名')).not.toBeNull()
278
+ // ...and the bubbles are still there, still clickable.
279
+ expect(screen.getByText('正在使用 grep')).not.toBeNull()
280
+ fireEvent.click(screen.getByText('正在使用 grep'))
281
+ expect(onOpenSession).toHaveBeenCalledWith('s-b')
282
+ })
197
283
  })
198
284
 
199
285
  describe('PetSprite definition-driven render', () => {
@@ -43,6 +43,8 @@ export interface PetSpriteProps {
43
43
  onDragEnd: (right: number, bottom: number) => void
44
44
  /** Rename the selected pet (persisted by the host). */
45
45
  onRename: (name: string) => void
46
+ /** Navigate to the session one status bubble reports on. */
47
+ onOpenSession: (sessionId: string) => void
46
48
  /** Clear the reaction bubble (after its CSS animation). */
47
49
  onFeedbackDone: () => void
48
50
  /** Locale translate seat (namespace-bound). */
@@ -125,10 +127,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
125
127
  const tick = (ts: number): void => {
126
128
  const delta = ts - last
127
129
  last = ts
128
- // Trim the track to the row's real frame count (transparent cells
129
- // would render as a vanishing pet).
130
- const row = rowOfTrack(animation)
131
- const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length)
130
+ // row/track come from the effect scope: they were computed once above
131
+ // and this effect re-runs when animation/tracks/rows change, so the
132
+ // per-frame recompute (trimTrack slices fresh arrays) is pure waste.
132
133
  const st = frameRef.current
133
134
  if (st.track !== animation) {
134
135
  st.track = animation
@@ -209,7 +210,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
209
210
  const pos = dragPos ?? { right: display.right, bottom: display.bottom }
210
211
  const spriteWidth = Math.round(cell.width * spriteScale)
211
212
  const spriteHeight = Math.round(cell.height * spriteScale)
212
- const statusBubble = feedback === null && !hovered ? snapshot?.bubble : undefined
213
+ // Concurrent sessions each render their own bubble (stacked above the
214
+ // sprite); the legacy single 'bubble' is the fallback when the host serves
215
+ // no per-session list. The hover panel now sits beside the sprite, so the
216
+ // bubbles stay visible and clickable while hovering — no region swap.
217
+ const sessionBubbles = snapshot?.sessions ?? []
218
+ const statusBubble = feedback === null && sessionBubbles.length === 0
219
+ ? snapshot?.bubble
220
+ : undefined
213
221
  const displayName = snapshot?.name ?? definition.displayName
214
222
 
215
223
  const float = (
@@ -222,13 +230,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
222
230
  setHovered(true)
223
231
  }}
224
232
  onPointerLeave={(e) => {
225
- // The panel and bubble render OUTSIDE the container's box (absolute,
226
- // above the sprite), so moving onto them fires pointerleave on the
227
- // container. Treat a target still inside the container's DOM (the
228
- // overflowed panel) as "still hovering"; otherwise give the pointer a
229
- // short grace period to reach the panel across the gap above it. The
230
- // bridge ('.panel::after') keeps the pointer inside the hit area, and
231
- // the grace period covers a slow mouse crossing the remaining sliver.
233
+ // The panel renders OUTSIDE the container's box (absolute, beside
234
+ // the sprite), so moving onto it fires pointerleave on the container.
235
+ // Treat a target still inside the container's DOM (the overflowed
236
+ // panel) as "still hovering"; otherwise give the pointer a short
237
+ // grace period to reach the panel across the gap beside the sprite.
238
+ // The bridge ('.panel::after') keeps the pointer inside the hit
239
+ // area, and the grace period covers a slow mouse crossing the
240
+ // remaining sliver.
232
241
  const next = e.relatedTarget
233
242
  if (next instanceof Node && floatRef.current?.contains(next)) return
234
243
  clearHideTimer()
@@ -264,6 +273,21 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
264
273
  {feedback.text}
265
274
  </div>
266
275
  )}
276
+ {feedback === null && sessionBubbles.length > 0 && (
277
+ <div className={styles.bubbleStack}>
278
+ {sessionBubbles.map(session => (
279
+ <button
280
+ key={session.sessionId}
281
+ type="button"
282
+ className={clsx(styles.bubble, styles.bubbleStatus, styles.bubbleClickable)}
283
+ title={props.t('pet.openSessionHint')}
284
+ onClick={() => { props.onOpenSession(session.sessionId) }}
285
+ >
286
+ {session.bubble}
287
+ </button>
288
+ ))}
289
+ </div>
290
+ )}
267
291
  {statusBubble !== undefined && (
268
292
  <div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
269
293
  {statusBubble}
@@ -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'
@@ -116,14 +116,20 @@ export function apply(ctx: ClientContext): void {
116
116
  // namespace, registered as a top-level settings page. The controller loads
117
117
  // the petId choices from the registry endpoint itself.
118
118
  const petSettings = new PetSettingsCardController(settingsScope)
119
- ctx.slots.inject('settings.section', () => ctx.slots.register({
120
- name: 'settings.section',
121
- id: 'pet',
122
- order: 130,
123
- label: () => ctx.locale.bind('pet')('settings.title'),
124
- locale: 'pet',
125
- inject: () => petSettings.inject(),
126
- }, PetSettingsSection))
119
+ ctx.slots.inject('settings.section', () => {
120
+ const unregister = ctx.slots.register({
121
+ name: 'settings.section',
122
+ id: 'pet',
123
+ order: 130,
124
+ label: () => ctx.locale.bind('pet')('settings.title'),
125
+ locale: 'pet',
126
+ inject: () => petSettings.inject(),
127
+ }, PetSettingsSection)
128
+ return () => {
129
+ petSettings.dispose()
130
+ unregister()
131
+ }
132
+ })
127
133
 
128
134
  // The global pet entry, its store, and the poll loop live while the plugin
129
135
  // is enabled; toggling the setting off hides the pet and stops polling.
@@ -146,6 +152,11 @@ export function apply(ctx: ClientContext): void {
146
152
  // tick tries again. After it lands, one list feeds both the sprite and
147
153
  // the settings card's choices.
148
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
149
160
  const pollNow = (): void => {
150
161
  if (!petsLoaded) {
151
162
  petApi.pets().then((list) => {
@@ -155,9 +166,13 @@ export function apply(ctx: ClientContext): void {
155
166
  // Retry on the next poll tick.
156
167
  })
157
168
  }
169
+ const seq = stateSeq + 1
170
+ stateSeq = seq
158
171
  petApi.state().then((snapshot) => {
172
+ if (seq !== stateSeq) return
159
173
  setSnapshot(snapshot)
160
174
  }, () => {
175
+ if (seq !== stateSeq) return
161
176
  setState('error', 'pet.state transport error')
162
177
  })
163
178
  }
@@ -196,9 +211,23 @@ export function apply(ctx: ClientContext): void {
196
211
  }
197
212
  }, 'pet: poll')
198
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
+
199
227
  const injected = (): PetInjected => ({
200
228
  store: petStore,
201
229
  ensure: pollNow,
230
+ openSession,
202
231
  pet: () => {
203
232
  petApi.interact('pet').then((result) => {
204
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.',
@@ -46,6 +46,44 @@
46
46
  animation: none;
47
47
  }
48
48
 
49
+ /* 多会话气泡栈:并行会话各自一个气泡,自精灵头顶向上堆叠,
50
+ 最近更新的会话在最上。栈内气泡改流入列布局,不再绝对定位
51
+ 互相覆盖。气泡可点击:点击跳转到对应会话。 */
52
+ .bubbleStack {
53
+ position: absolute;
54
+ bottom: 100%;
55
+ display: flex;
56
+ flex-direction: column;
57
+ align-items: center;
58
+ gap: 4px;
59
+ pointer-events: auto;
60
+ }
61
+
62
+ .bubbleStack .bubble {
63
+ position: relative;
64
+ bottom: auto;
65
+ margin-bottom: 0;
66
+ pointer-events: auto;
67
+ }
68
+
69
+ .bubbleClickable {
70
+ cursor: pointer;
71
+ font: inherit;
72
+ text-align: center;
73
+ appearance: none;
74
+ transition: filter 120ms ease, box-shadow 120ms ease;
75
+ }
76
+
77
+ .bubbleClickable:hover {
78
+ filter: brightness(1.12);
79
+ }
80
+
81
+ .bubbleClickable:focus-visible {
82
+ outline: none;
83
+ /* 2px accent ring on the pet's own palette, theme-agnostic. */
84
+ box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
85
+ }
86
+
49
87
  @keyframes pet-bubble-pop {
50
88
  0% {
51
89
  opacity: 0;
@@ -67,9 +105,14 @@
67
105
  }
68
106
  }
69
107
 
108
+ /* 悬浮面板位于精灵左侧(bottom 对齐),不再占据精灵上方的空间:
109
+ 会话气泡栈独占精灵头顶区域,两者互不遮挡 —— 气泡因此可以始终
110
+ 显示并保持可点击,不必在 hover 时让位给面板。 */
70
111
  .panel {
71
112
  position: absolute;
72
- bottom: 100%;
113
+ bottom: 0;
114
+ right: 100%;
115
+ margin-right: 8px;
73
116
  background: rgba(15, 23, 42, 0.92);
74
117
  border: 1px solid rgba(148, 163, 184, 0.35);
75
118
  border-radius: 10px;
@@ -84,18 +127,17 @@
84
127
  min-width: 132px;
85
128
  }
86
129
 
87
- /* 面板底边与图标顶边之间的 hover 桥接(issue #49):
88
- 面板绝对定位在图标上方(bottom: 100%),若两者之间存在哪怕几个
89
- 像素的间隙,指针滑过间隙时会触发容器的 pointerleave,面板在指针
90
- 抵达按钮前就收起。这段透明伪元素把命中区向下延伸、覆盖图标顶部,
91
- 使指针在图标与按钮之间始终停留在 hover 区域内。 */
130
+ /* 面板右缘与图标左缘之间的 hover 桥接(issue #49 的水平版):
131
+ 面板在图标左侧留有 8px 间隙,指针滑过间隙时会触发容器的
132
+ pointerleave,面板会在指针抵达按钮前收起。这段透明伪元素把命中区
133
+ 向右延伸、覆盖间隙,使指针在图标与按钮之间始终停留在 hover 区域。 */
92
134
  .panel::after {
93
135
  content: '';
94
136
  position: absolute;
95
- top: 100%;
96
- left: 0;
97
- right: 0;
98
- height: 14px;
137
+ top: 0;
138
+ bottom: 0;
139
+ left: 100%;
140
+ width: 14px;
99
141
  }
100
142
 
101
143
  .rankRow {
@@ -234,6 +234,9 @@ export class CardForm<T> {
234
234
  private readonly specs: Map<string, FieldSpec>
235
235
  private readonly staged = new Map<string, StagedEdit>()
236
236
  private readonly listeners = new Set<() => void>()
237
+ /** The scope subscription installed in the constructor; released by dispose(). */
238
+ private readonly disposeScope: () => void
239
+ private disposed = false
237
240
  private saving = false
238
241
  private failed = false
239
242
  private failedReason: string | undefined
@@ -244,7 +247,18 @@ export class CardForm<T> {
244
247
  specs: FieldSpec[],
245
248
  ) {
246
249
  this.specs = new Map(specs.map(spec => [spec.field, spec]))
247
- scope.subscribe(() => { this.publish() })
250
+ this.disposeScope = scope.subscribe(() => { this.publish() })
251
+ }
252
+
253
+ /**
254
+ * Release the scope subscription and every bound store listener. The card
255
+ * must call this on teardown; later calls are no-ops.
256
+ */
257
+ dispose(): void {
258
+ if (this.disposed) return
259
+ this.disposed = true
260
+ this.disposeScope()
261
+ this.listeners.clear()
248
262
  }
249
263
 
250
264
  /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
@@ -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
  /**
@@ -115,7 +115,9 @@ export function projectOfficialEvent(
115
115
  case 'blocked':
116
116
  return { input: { phase: 'waiting', line: '等待继续' } }
117
117
  case 'aborted':
118
- return { input: { phase: 'idle', line: '已停止' } }
118
+ // A stopped session settles to idle without a bubble: the pet
119
+ // visibly calms down and the session drops out of the bubble stack.
120
+ return { input: { phase: 'idle' } }
119
121
  default:
120
122
  // TurnEndReasonMap is merge-extensible; a newer ending must not
121
123
  // leave the pet showing stale in-progress work.
package/src/index.ts CHANGED
@@ -19,12 +19,14 @@ 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
- export { PetService } from './service.ts'
24
+ export { PetService, MAX_SESSION_BUBBLES } from './service.ts'
24
25
  export type {
25
26
  PetConfig,
26
27
  PetInteractResult,
27
28
  PetSettingsSection,
29
+ PetSessionView,
28
30
  PetStateView,
29
31
  } from './service.ts'
30
32
  export {
@@ -60,6 +62,16 @@ export {
60
62
  settleTreatGrants,
61
63
  } from './treats.ts'
62
64
  export type { TreatConfig, TreatLedger, TreatSettlement } from './treats.ts'
65
+ export {
66
+ BUILTIN_REMARKS,
67
+ REMARK_KINDS,
68
+ REMARK_LINE_MAX,
69
+ REMARK_LINES_MAX,
70
+ RemarkPicker,
71
+ builtinRemark,
72
+ normalizePetRemarks,
73
+ } from './remarks.ts'
74
+ export type { PetRemarks, PetRemarksManifest, RemarkKind } from './remarks.ts'
63
75
  export {
64
76
  DEFAULT_PET_ID,
65
77
  DEFAULT_PET_NAME,
@@ -126,7 +138,9 @@ export function makePetSettingsSchema(fallbackPetId: string) {
126
138
  }
127
139
 
128
140
  /** Register the pet service and its API + asset routes on the context. */
129
- 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 {
130
144
  const registry = config.registry
131
145
  ?? loadPetRegistry({
132
146
  packageRoot: petPackageRoot(import.meta.url),
@@ -5,13 +5,11 @@ import { emptyPersist } from './persist.ts'
5
5
  import { PetLedger } from './ledger.ts'
6
6
 
7
7
  describe('PetLedger', () => {
8
- it('settles the economy on completed turns (work treat per 3 turns)', () => {
8
+ it('settles the economy on completed turns (work treat per 30 turns)', () => {
9
9
  const ledger = new PetLedger(emptyPersist())
10
10
  const n = 1_000_000
11
- ledger.rewardTurn('s1', 1, n)
12
- ledger.rewardTurn('s1', 2, n + 1)
13
- ledger.rewardTurn('s1', 3, n + 2)
14
- expect(ledger.snapshot.affinity.turns).toBe(3)
11
+ for (let turn = 1; turn <= 30; turn++) ledger.rewardTurn('s1', turn, n + turn)
12
+ expect(ledger.snapshot.affinity.turns).toBe(30)
15
13
  expect(ledger.snapshot.treats.treats).toBe(1)
16
14
  expect(ledger.takeDirty()).toBe(true)
17
15
  })
@@ -25,6 +23,19 @@ describe('PetLedger', () => {
25
23
  expect(ledger.snapshot.affinity.turns).toBe(1)
26
24
  })
27
25
 
26
+ it('forgetSession re-arms a session so a repeated reward can award again', () => {
27
+ const ledger = new PetLedger(emptyPersist())
28
+ const n = 1_000_000
29
+ ledger.rewardTurn('s1', 1, n)
30
+ expect(ledger.snapshot.affinity.turns).toBe(1)
31
+ // The disposed-session eviction drops only the per-session bookkeeping.
32
+ ledger.forgetSession('s1')
33
+ // The same turn number is now treated as fresh: a same-turn retry on a
34
+ // new session lifecycle re-awards rather than being deduplicated.
35
+ expect(ledger.rewardTurn('s1', 1, n + 1)).toBe(true)
36
+ expect(ledger.snapshot.affinity.turns).toBe(2)
37
+ })
38
+
28
39
  it('a read of the view does not mark dirty (no settle on read)', () => {
29
40
  const ledger = new PetLedger(emptyPersist())
30
41
  ledger.affinityView(1_000_000)
@@ -34,16 +45,36 @@ describe('PetLedger', () => {
34
45
  it('feed consumes a treat and applies the feed reward', () => {
35
46
  const ledger = new PetLedger(emptyPersist())
36
47
  const n = 1_000_000
37
- ledger.rewardTurn('s1', 1, n)
38
- ledger.rewardTurn('s1', 2, n + 1)
39
- ledger.rewardTurn('s1', 3, n + 2)
48
+ for (let turn = 1; turn <= 30; turn++) ledger.rewardTurn('s1', turn, n + turn)
40
49
  expect(ledger.snapshot.treats.treats).toBe(1)
41
- const res = ledger.interact('feed', n + 10)
50
+ const res = ledger.interact('feed', n + 40)
42
51
  expect(res.delta).toBe(defaultAffinityConfig.feedReward)
43
52
  expect(ledger.snapshot.treats.treats).toBe(0)
44
53
  expect(ledger.snapshot.affinity.feeds).toBe(1)
45
54
  })
46
55
 
56
+ it('cycles per-pet custom remarks per slot and falls back to built-ins', () => {
57
+ const ledger = new PetLedger(emptyPersist(), {
58
+ remarks: { pet: ['第一句', '第二句'] },
59
+ })
60
+ const n = 1_000_000
61
+ expect(ledger.interact('pet', n).reaction).toBe('第一句')
62
+ // Per-slot counters: the cooldown refusal still opens with the built-in
63
+ // legacy line, not the custom pool above.
64
+ expect(ledger.interact('pet', n + 1).reaction).toBe('摸过头啦,让鲸鱼娘歇口气~')
65
+ // Feeding falls back to the built-in pool when the pet declares none.
66
+ expect(ledger.interact('feed', n + 2).reaction).toBe('没有小鱼干了,多陪我工作一会儿吧~')
67
+ })
68
+
69
+ it('swaps remark pools when the selected pet changes', () => {
70
+ const ledger = new PetLedger(emptyPersist(), { remarks: { pet: ['旧宠物台词'] } })
71
+ expect(ledger.interact('pet', 1_000_000).reaction).toBe('旧宠物台词')
72
+ ledger.setRemarks({ pet: ['新宠物台词'] })
73
+ expect(ledger.interact('pet', 1_011_000).reaction).toBe('新宠物台词')
74
+ ledger.setRemarks(undefined)
75
+ expect(ledger.interact('pet', 1_022_000).reaction).toBe('咕噜咕噜~被摸摸好舒服!')
76
+ })
77
+
47
78
  it('refuses a feed on an empty stock without burning anything', () => {
48
79
  const ledger = new PetLedger(emptyPersist())
49
80
  const res = ledger.interact('feed', 1_000_000)