@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.
- package/README.i18n.yaml +2 -2
- package/README.md +12 -5
- package/README.zh.md +12 -5
- package/lib/client.js +95 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +144 -31
- package/lib/invariant.js +2 -2
- package/lib/state-Diiq8vjk.js +394 -0
- package/lib/types/affinity.d.ts +39 -11
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +21 -7
- package/lib/types/client/PetDockEntry.d.ts +2 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -2
- package/lib/types/client/PetSettingsCard.d.ts +5 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +9 -2
- package/lib/types/client/PetSprite.d.ts +2 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +22 -15
- package/lib/types/client/PluginSettingsCard.d.ts +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +45 -14
- package/lib/types/client/locales.d.ts +82 -80
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +2 -0
- 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/client/spritesheet.d.ts +1 -1
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +3 -12
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +3 -1
- package/lib/types/index.d.ts +6 -3
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +15 -12
- package/lib/types/invariant.js +2 -2
- package/lib/types/ledger.d.ts +16 -1
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +31 -6
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/lib/types/mount-once.js +42 -0
- package/lib/types/persist.js +3 -3
- 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/routes.js +2 -2
- package/lib/types/service.d.ts +44 -3
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +100 -23
- package/lib/types/state.d.ts.map +1 -1
- 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/PetDockEntry.tsx +3 -0
- package/src/client/PetSettingsCard.tsx +8 -0
- package/src/client/PetSprite.test.tsx +89 -3
- package/src/client/PetSprite.tsx +36 -12
- package/src/client/index.ts +40 -11
- package/src/client/locales.ts +2 -0
- package/src/client/pet.module.css +52 -10
- package/src/client/settings-form.ts +15 -1
- package/src/client/spritesheet.ts +3 -13
- package/src/event-projection.ts +3 -1
- package/src/index.ts +16 -2
- package/src/ledger.test.ts +40 -9
- package/src/ledger.ts +33 -4
- package/src/mount-once.ts +48 -0
- 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 +124 -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
|
@@ -88,9 +88,13 @@ afterEach(() => {
|
|
|
88
88
|
cleanup()
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
-
/** Render the pet with mocked callbacks; returns the rename
|
|
92
|
-
function renderPet(overrides: Partial<PetSpriteProps> = {}): {
|
|
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', () => {
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
// bridge ('.panel::after') keeps the pointer inside the hit
|
|
231
|
-
// the grace period covers a slow mouse crossing the
|
|
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}
|
package/src/client/index.ts
CHANGED
|
@@ -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', () =>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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({
|
package/src/client/locales.ts
CHANGED
|
@@ -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:
|
|
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
|
-
/*
|
|
88
|
-
|
|
89
|
-
|
|
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:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
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
|
/**
|
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
|
@@ -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
|
|
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),
|
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)
|