@linxin666/dsh-pet 0.1.17 → 0.1.19

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 (75) 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 +134 -78
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +100 -30
  7. package/lib/invariant.js +2 -2
  8. package/lib/state-Diiq8vjk.js +394 -0
  9. package/lib/types/affinity.d.ts +31 -3
  10. package/lib/types/affinity.d.ts.map +1 -1
  11. package/lib/types/affinity.js +21 -7
  12. package/lib/types/client/PetSettingsCard.d.ts +16 -5
  13. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PetSettingsCard.js +14 -1
  15. package/lib/types/client/PetSprite.d.ts.map +1 -1
  16. package/lib/types/client/PetSprite.js +8 -2
  17. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  18. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PluginSettingsCard.js +11 -5
  20. package/lib/types/client/index.d.ts +3 -22
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/index.js +19 -12
  23. package/lib/types/client/locales.js +2 -2
  24. package/lib/types/client/settings-form.d.ts +8 -0
  25. package/lib/types/client/settings-form.d.ts.map +1 -1
  26. package/lib/types/client/settings-form.js +15 -1
  27. package/lib/types/event-projection.d.ts.map +1 -1
  28. package/lib/types/event-projection.js +3 -1
  29. package/lib/types/index.d.ts +4 -2
  30. package/lib/types/index.d.ts.map +1 -1
  31. package/lib/types/index.js +2 -1
  32. package/lib/types/ledger.d.ts +16 -1
  33. package/lib/types/ledger.d.ts.map +1 -1
  34. package/lib/types/ledger.js +29 -4
  35. package/lib/types/registry.d.ts +10 -0
  36. package/lib/types/registry.d.ts.map +1 -1
  37. package/lib/types/registry.js +3 -0
  38. package/lib/types/remarks.d.ts +56 -0
  39. package/lib/types/remarks.d.ts.map +1 -0
  40. package/lib/types/remarks.js +157 -0
  41. package/lib/types/service.d.ts +40 -3
  42. package/lib/types/service.d.ts.map +1 -1
  43. package/lib/types/service.js +90 -18
  44. package/lib/types/state.js +5 -5
  45. package/lib/types/treats.d.ts +3 -0
  46. package/lib/types/treats.d.ts.map +1 -1
  47. package/lib/types/treats.js +5 -2
  48. package/package.json +1 -1
  49. package/src/affinity.test.ts +19 -0
  50. package/src/affinity.ts +22 -7
  51. package/src/client/PetSettingsCard.tsx +35 -6
  52. package/src/client/PetSprite.test.tsx +33 -0
  53. package/src/client/PetSprite.tsx +16 -1
  54. package/src/client/PluginSettingsCard.tsx +50 -47
  55. package/src/client/index.ts +20 -30
  56. package/src/client/locales.ts +2 -2
  57. package/src/client/pet.module.css +19 -0
  58. package/src/client/settings-card.module.css +9 -0
  59. package/src/client/settings-form.ts +15 -1
  60. package/src/client/settings-section.module.css +5 -0
  61. package/src/event-projection.ts +3 -1
  62. package/src/index.ts +12 -1
  63. package/src/ledger.test.ts +40 -9
  64. package/src/ledger.ts +33 -4
  65. package/src/persist.test.ts +1 -1
  66. package/src/registry.test.ts +27 -0
  67. package/src/registry.ts +12 -0
  68. package/src/remarks.test.ts +89 -0
  69. package/src/remarks.ts +177 -0
  70. package/src/service.ts +116 -18
  71. package/src/state.test.ts +9 -0
  72. package/src/state.ts +5 -5
  73. package/src/treats.test.ts +13 -11
  74. package/src/treats.ts +5 -2
  75. package/lib/state-CFyJv0sQ.js +0 -213
@@ -1,15 +1,20 @@
1
1
  /**
2
2
  * The pet settings card: pet selection plus display layout, bound to the
3
- * 'pet' settings namespace the host plugin registers. Registered into the
4
- * 'web-ui.plugin.item' slot the plugin-configuration section renders. The
5
- * petId choices come from the registry endpoint ('/api/pet/pets') — the same
6
- * list the sprite renders from — so the card carries no per-pet knowledge.
3
+ * 'pet' settings namespace the host plugin registers. Rendered as an
4
+ * always-open first-level settings page; the section wrapper below mounts it
5
+ * as the content of the top-level 'settings.section' nav entry. The petId
6
+ * choices come from the registry endpoint ('/api/pet/pets') — the same list
7
+ * the sprite renders from — so the card carries no per-pet knowledge.
7
8
  */
8
9
 
10
+ import type { ReactNode } from 'react'
9
11
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
10
12
  import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
13
+ // Type-only: pulls the settings-surface SlotMap merge (the 'settings.section' entry).
14
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
11
15
  import { PluginSettingsCard, ValueField, BooleanField, ChoiceField } from './PluginSettingsCard.tsx'
12
16
  import { CardForm, booleanField, choiceField, numberField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
17
+ import sectionCss from './settings-section.module.css'
13
18
 
14
19
  /** The pet's settings fields this card edits (the namespace's full schema). */
15
20
  export interface PetSettings {
@@ -129,12 +134,19 @@ export class PetSettingsCardController {
129
134
  inject(): PetSettingsCardFace {
130
135
  return { hooks: { petSettingsCard: this.store }, ...this.form.actions() }
131
136
  }
137
+
138
+ /**
139
+ * Release the card's scope subscription and bound stores; the slot
140
+ * disposer calls this on teardown.
141
+ */
142
+ dispose(): void {
143
+ this.form.dispose()
144
+ }
132
145
  }
133
146
 
134
147
  /** Props the renderer binds for the pet settings card. */
135
148
  export type PetSettingsCardProps =
136
- PropsRuntime<'web-ui.plugin.item'>
137
- & PropsLocale<'pet'>
149
+ PropsLocale<'pet'>
138
150
  & InjectFace<PetSettingsCardFace>
139
151
 
140
152
  /**
@@ -160,6 +172,7 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
160
172
  state={state}
161
173
  onSave={props.save}
162
174
  onDiscard={props.discard}
175
+ alwaysOpen
163
176
  >
164
177
  <BooleanField
165
178
  id="settings-pet-enabled"
@@ -229,3 +242,19 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
229
242
  </PluginSettingsCard>
230
243
  )
231
244
  }
245
+
246
+ /** Props the settings section binds for the pet card page. */
247
+ export type PetSettingsSectionProps =
248
+ PropsRuntime<'settings.section'>
249
+ & PropsLocale<'pet'>
250
+ & InjectFace<PetSettingsCardFace>
251
+
252
+ /** Render the pet settings card as a first-level settings page. */
253
+ export function PetSettingsSection(props: PetSettingsSectionProps): ReactNode {
254
+ const { t, usePetSettingsCard, save, discard, edit, resetField } = props
255
+ return (
256
+ <ul className={sectionCss.sectionList}>
257
+ <PetSettingsCard t={t} usePetSettingsCard={usePetSettingsCard} save={save} discard={discard} edit={edit} resetField={resetField} />
258
+ </ul>
259
+ )
260
+ }
@@ -194,6 +194,39 @@ describe('PetSprite status bubble', () => {
194
194
  expect(screen.queryByText('摸摸成功')).not.toBeNull()
195
195
  expect(screen.queryByText('正在思考')).toBeNull()
196
196
  })
197
+
198
+ it('renders one bubble per concurrent session without duplicating the global bubble', () => {
199
+ renderPet({
200
+ snapshot: {
201
+ ...workingSnapshot,
202
+ bubble: '正在思考',
203
+ sessions: [
204
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
205
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
206
+ ],
207
+ },
208
+ })
209
+ // The display session appears in the stack exactly once: the legacy
210
+ // single bubble is not rendered on top of the session list.
211
+ expect(screen.getAllByText('正在思考')).toHaveLength(1)
212
+ expect(screen.queryByText('正在使用 grep')).not.toBeNull()
213
+ })
214
+
215
+ it('lets feedback replace the whole session bubble stack', () => {
216
+ renderPet({
217
+ snapshot: {
218
+ ...workingSnapshot,
219
+ sessions: [
220
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
221
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
222
+ ],
223
+ },
224
+ feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
225
+ })
226
+ expect(screen.queryByText('摸摸成功')).not.toBeNull()
227
+ expect(screen.queryByText('正在思考')).toBeNull()
228
+ expect(screen.queryByText('正在使用 grep')).toBeNull()
229
+ })
197
230
  })
198
231
 
199
232
  describe('PetSprite definition-driven render', () => {
@@ -209,7 +209,13 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
209
209
  const pos = dragPos ?? { right: display.right, bottom: display.bottom }
210
210
  const spriteWidth = Math.round(cell.width * spriteScale)
211
211
  const spriteHeight = Math.round(cell.height * spriteScale)
212
- const statusBubble = feedback === null && !hovered ? snapshot?.bubble : undefined
212
+ // Concurrent sessions each render their own bubble (stacked above the
213
+ // sprite); the legacy single 'bubble' is the fallback when the host serves
214
+ // no per-session list.
215
+ const sessionBubbles = snapshot?.sessions ?? []
216
+ const statusBubble = feedback === null && !hovered && sessionBubbles.length === 0
217
+ ? snapshot?.bubble
218
+ : undefined
213
219
  const displayName = snapshot?.name ?? definition.displayName
214
220
 
215
221
  const float = (
@@ -264,6 +270,15 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
264
270
  {feedback.text}
265
271
  </div>
266
272
  )}
273
+ {feedback === null && !hovered && sessionBubbles.length > 0 && (
274
+ <div className={styles.bubbleStack} role="status" aria-live="polite">
275
+ {sessionBubbles.map(session => (
276
+ <div key={session.sessionId} className={clsx(styles.bubble, styles.bubbleStatus)}>
277
+ {session.bubble}
278
+ </div>
279
+ ))}
280
+ </div>
281
+ )}
267
282
  {statusBubble !== undefined && (
268
283
  <div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
269
284
  {statusBubble}
@@ -45,6 +45,17 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
45
45
  onSave: () => void
46
46
  /** Drop every staged edit. */
47
47
  onDiscard: () => void
48
+ /**
49
+ * Render the controls expanded on arrival (still collapsible). Defaults to
50
+ * true: promoted first-level sections show their settings immediately.
51
+ */
52
+ defaultOpen?: boolean
53
+ /**
54
+ * Render without the collapse affordance: a static header and an always
55
+ * visible body. Used by first-level settings sections whose nav entry
56
+ * already provides the selection.
57
+ */
58
+ alwaysOpen?: boolean
48
59
  /** The plugin's controls. */
49
60
  children: ReactNode
50
61
  }
@@ -55,57 +66,27 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
55
66
  * @returns the card, or nothing while the namespace is still loading.
56
67
  */
57
68
  export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
58
- const [open, setOpen] = useState(false)
59
- const { state } = props
69
+ const [open, setOpen] = useState(props.defaultOpen ?? true)
70
+ const { state, alwaysOpen } = props
60
71
  if (!state.available) return null
61
72
  const title = props.t(props.titleKey)
62
73
  const description = props.t(props.descriptionKey)
63
74
  const blocked = !state.dirty || state.invalid || state.saving
64
- const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
65
- // The namespace exists but the Host does not serve it to this client (the
66
- // official settings allowlist omits third-party namespaces): show a card
67
- // that explains the gap instead of vanishing, so a missing card never
68
- // reads as a missing plugin.
69
- if (!state.exposed) {
70
- return (
71
- <li className={cardClass}>
72
- <button
73
- type="button"
74
- className={css.header}
75
- aria-expanded={open}
76
- aria-label={`${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
77
- onClick={() => { setOpen(!open) }}
78
- >
79
- <span className={css.headText}>
80
- <span className={css.name} title={title}>{title}</span>
81
- <span className={css.description} title={description}>{description}</span>
82
- </span>
83
- <svg
84
- width="14"
85
- height="14"
86
- viewBox="0 0 14 14"
87
- fill="none"
88
- xmlns="http://www.w3.org/2000/svg"
89
- className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
90
- >
91
- <path
92
- d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
93
- fill="currentColor"
94
- />
95
- </svg>
96
- </button>
97
- {open
98
- ? (
99
- <div className={css.body}>
100
- <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
101
- </div>
102
- )
103
- : null}
104
- </li>
75
+ const expanded = alwaysOpen === true || open
76
+ const cardClass = expanded ? `${css.cardOpen} ${css.card}` : css.card
77
+ // With alwaysOpen the nav entry already provides the selection, so the
78
+ // header is a static title row instead of a disclosure button.
79
+ const header = alwaysOpen === true
80
+ ? (
81
+ <div className={css.headerStatic}>
82
+ <span className={css.headText}>
83
+ <span className={css.name} title={title}>{title}</span>
84
+ <span className={css.description} title={description}>{description}</span>
85
+ </span>
86
+ {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
87
+ </div>
105
88
  )
106
- }
107
- return (
108
- <li className={cardClass}>
89
+ : (
109
90
  <button
110
91
  type="button"
111
92
  className={css.header}
@@ -132,7 +113,29 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
132
113
  />
133
114
  </svg>
134
115
  </button>
135
- {open
116
+ )
117
+ // The namespace exists but the Host does not serve it to this client (the
118
+ // official settings allowlist omits third-party namespaces): show a card
119
+ // that explains the gap instead of vanishing, so a missing card never
120
+ // reads as a missing plugin.
121
+ if (!state.exposed) {
122
+ return (
123
+ <li className={cardClass}>
124
+ {header}
125
+ {expanded
126
+ ? (
127
+ <div className={css.body}>
128
+ <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
129
+ </div>
130
+ )
131
+ : null}
132
+ </li>
133
+ )
134
+ }
135
+ return (
136
+ <li className={cardClass}>
137
+ {header}
138
+ {expanded
136
139
  ? (
137
140
  <div className={css.body}>
138
141
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
@@ -26,7 +26,7 @@ import { createElement } from 'react'
26
26
  import { createRoot } from 'react-dom/client'
27
27
  import { createPetStore, type PetStoreInstance } from './pet-store.ts'
28
28
  import { PetDockEntry, type PetInjected } from './PetDockEntry.tsx'
29
- import { PetSettingsCard, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
29
+ import { PetSettingsSection, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
30
30
  import { NS, en, zh, t } from './locales.ts'
31
31
 
32
32
  /** The host pet API as the browser sees it (same-origin JSON endpoints). */
@@ -80,26 +80,9 @@ export type { PetInjected, PetDockEntryProps } from './PetDockEntry.tsx'
80
80
  export type { PetSpriteProps } from './PetSprite.tsx'
81
81
  export type { PetUiState, PetFeedback } from './pet-store.ts'
82
82
  export type { PetSettingsCardFace, PetSettingsCardState } from './PetSettingsCard.tsx'
83
+ export type { PetSettingsSectionProps } from './PetSettingsCard.tsx'
83
84
  export type { PetDefinition } from '../registry.ts'
84
85
 
85
- declare module '@deepseek-ai/dsh-client-ui-slots' {
86
- interface SlotMap {
87
- /**
88
- * The child slot the Web UI plugin group declares; this card registers
89
- * into the group instead of the top-level 'settings.plugin.item' list.
90
- * Spelled here with the same shape so this package can register without
91
- * depending on the sibling UI package.
92
- */
93
- 'web-ui.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
94
- }
95
- }
96
-
97
- /** Owner share of a plugin card (the group card supplies nothing). */
98
- export interface SettingsPluginItemOwnerProps {
99
- /** Marker field: card owner props are intentionally empty. */
100
- children?: never
101
- }
102
-
103
86
  declare module '@deepseek-ai/cordis' {
104
87
  interface Context {
105
88
  /**
@@ -113,8 +96,8 @@ declare module '@deepseek-ai/cordis' {
113
96
 
114
97
  /**
115
98
  * Client plugin body: register dictionaries, mount the global pet entry and
116
- * poll loop while the plugin is enabled, and seat the settings card in the
117
- * Web UI plugin group.
99
+ * poll loop while the plugin is enabled, and seat the settings card as a
100
+ * first-level settings section.
118
101
  * @param ctx - client root context.
119
102
  */
120
103
  export function apply(ctx: ClientContext): void {
@@ -129,17 +112,24 @@ export function apply(ctx: ClientContext): void {
129
112
  : snapshot.status === 'unavailable'
130
113
  }
131
114
 
132
- // Plugin configuration card: one staged form over the 'pet' settings
133
- // namespace, contributed to the Web UI plugin group. The controller loads
115
+ // First-level settings section: one staged form over the 'pet' settings
116
+ // namespace, registered as a top-level settings page. The controller loads
134
117
  // the petId choices from the registry endpoint itself.
135
118
  const petSettings = new PetSettingsCardController(settingsScope)
136
- ctx.slots.inject('web-ui.plugin.item', () => ctx.slots.register({
137
- name: 'web-ui.plugin.item',
138
- id: 'pet-settings',
139
- order: 140,
140
- locale: NS,
141
- inject: () => petSettings.inject(),
142
- }, PetSettingsCard))
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
+ })
143
133
 
144
134
  // The global pet entry, its store, and the poll loop live while the plugin
145
135
  // is enabled; toggling the setting off hides the pet and stops polling.
@@ -19,7 +19,7 @@ export const zh = {
19
19
  'pet.treats': '小鱼干 ×{n}',
20
20
  'pet.state.loading': '宠物正在赶来…',
21
21
  'pet.state.error': '宠物迷路了(连接失败)',
22
- // 插件设置卡片(settings.plugin.item 席位)。
22
+ // 一级设置页(settings.section 席位)。
23
23
  'settings.title': '宠物',
24
24
  'settings.description': '选择宠物并调整它的显示布局。',
25
25
  'settings.pet': '宠物',
@@ -64,7 +64,7 @@ export const en = {
64
64
  'pet.treats': 'Treats ×{n}',
65
65
  'pet.state.loading': 'The pet is on its way…',
66
66
  'pet.state.error': 'The pet is lost (connection failed)',
67
- // Plugin settings card (the `settings.plugin.item` seat).
67
+ // First-level settings section (the `settings.section` seat).
68
68
  'settings.title': 'Pet',
69
69
  'settings.description': 'Pick a pet and tune its display layout.',
70
70
  'settings.pet': 'Pet',
@@ -46,6 +46,25 @@
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: none;
60
+ }
61
+
62
+ .bubbleStack .bubble {
63
+ position: relative;
64
+ bottom: auto;
65
+ margin-bottom: 0;
66
+ }
67
+
49
68
  @keyframes pet-bubble-pop {
50
69
  0% {
51
70
  opacity: 0;
@@ -42,6 +42,15 @@
42
42
  outline-offset: -2px;
43
43
  }
44
44
 
45
+ .headerStatic {
46
+ width: 100%;
47
+ border-radius: 12px;
48
+ align-items: center;
49
+ gap: 12px;
50
+ padding: 14px 16px;
51
+ display: flex;
52
+ }
53
+
45
54
  .headText {
46
55
  flex-direction: column;
47
56
  flex: 1;
@@ -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. */
@@ -0,0 +1,5 @@
1
+ .sectionList {
2
+ list-style: none;
3
+ margin: 0;
4
+ padding: 0;
5
+ }
@@ -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
@@ -20,11 +20,12 @@ 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
22
 
23
- export { PetService } from './service.ts'
23
+ export { PetService, MAX_SESSION_BUBBLES } from './service.ts'
24
24
  export type {
25
25
  PetConfig,
26
26
  PetInteractResult,
27
27
  PetSettingsSection,
28
+ PetSessionView,
28
29
  PetStateView,
29
30
  } from './service.ts'
30
31
  export {
@@ -60,6 +61,16 @@ export {
60
61
  settleTreatGrants,
61
62
  } from './treats.ts'
62
63
  export type { TreatConfig, TreatLedger, TreatSettlement } from './treats.ts'
64
+ export {
65
+ BUILTIN_REMARKS,
66
+ REMARK_KINDS,
67
+ REMARK_LINE_MAX,
68
+ REMARK_LINES_MAX,
69
+ RemarkPicker,
70
+ builtinRemark,
71
+ normalizePetRemarks,
72
+ } from './remarks.ts'
73
+ export type { PetRemarks, PetRemarksManifest, RemarkKind } from './remarks.ts'
63
74
  export {
64
75
  DEFAULT_PET_ID,
65
76
  DEFAULT_PET_NAME,
@@ -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)