@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.
- package/README.i18n.yaml +2 -2
- package/README.md +12 -5
- package/README.zh.md +12 -5
- package/lib/client.js +134 -78
- package/lib/client.js.map +1 -1
- package/lib/index.js +100 -30
- package/lib/invariant.js +2 -2
- package/lib/state-Diiq8vjk.js +394 -0
- package/lib/types/affinity.d.ts +31 -3
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +21 -7
- package/lib/types/client/PetSettingsCard.d.ts +16 -5
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +14 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +8 -2
- package/lib/types/client/PluginSettingsCard.d.ts +11 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +11 -5
- package/lib/types/client/index.d.ts +3 -22
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +19 -12
- package/lib/types/client/locales.js +2 -2
- package/lib/types/client/settings-form.d.ts +8 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +15 -1
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +3 -1
- package/lib/types/index.d.ts +4 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -1
- package/lib/types/ledger.d.ts +16 -1
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +29 -4
- package/lib/types/registry.d.ts +10 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +3 -0
- package/lib/types/remarks.d.ts +56 -0
- package/lib/types/remarks.d.ts.map +1 -0
- package/lib/types/remarks.js +157 -0
- package/lib/types/service.d.ts +40 -3
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +90 -18
- package/lib/types/state.js +5 -5
- package/lib/types/treats.d.ts +3 -0
- package/lib/types/treats.d.ts.map +1 -1
- package/lib/types/treats.js +5 -2
- package/package.json +1 -1
- package/src/affinity.test.ts +19 -0
- package/src/affinity.ts +22 -7
- package/src/client/PetSettingsCard.tsx +35 -6
- package/src/client/PetSprite.test.tsx +33 -0
- package/src/client/PetSprite.tsx +16 -1
- package/src/client/PluginSettingsCard.tsx +50 -47
- package/src/client/index.ts +20 -30
- package/src/client/locales.ts +2 -2
- package/src/client/pet.module.css +19 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/client/settings-form.ts +15 -1
- package/src/client/settings-section.module.css +5 -0
- package/src/event-projection.ts +3 -1
- package/src/index.ts +12 -1
- package/src/ledger.test.ts +40 -9
- package/src/ledger.ts +33 -4
- package/src/persist.test.ts +1 -1
- package/src/registry.test.ts +27 -0
- package/src/registry.ts +12 -0
- package/src/remarks.test.ts +89 -0
- package/src/remarks.ts +177 -0
- package/src/service.ts +116 -18
- package/src/state.test.ts +9 -0
- package/src/state.ts +5 -5
- package/src/treats.test.ts +13 -11
- package/src/treats.ts +5 -2
- 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.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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', () => {
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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}
|
package/src/client/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
117
|
-
*
|
|
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
|
-
//
|
|
133
|
-
// namespace,
|
|
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('
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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.
|
package/src/client/locales.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const zh = {
|
|
|
19
19
|
'pet.treats': '小鱼干 ×{n}',
|
|
20
20
|
'pet.state.loading': '宠物正在赶来…',
|
|
21
21
|
'pet.state.error': '宠物迷路了(连接失败)',
|
|
22
|
-
//
|
|
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
|
-
//
|
|
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;
|
|
@@ -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. */
|
package/src/event-projection.ts
CHANGED
|
@@ -115,7 +115,9 @@ export function projectOfficialEvent(
|
|
|
115
115
|
case 'blocked':
|
|
116
116
|
return { input: { phase: 'waiting', line: '等待继续' } }
|
|
117
117
|
case 'aborted':
|
|
118
|
-
|
|
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,
|
package/src/ledger.test.ts
CHANGED
|
@@ -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
|
|
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',
|
|
12
|
-
ledger.
|
|
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',
|
|
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 +
|
|
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)
|