@linxin666/dsh-pet 0.3.4 → 0.3.6
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 +13 -10
- package/README.zh.md +13 -10
- package/contracts/voice-pack-v1.schema.json +53 -28
- package/cordis.patch.yml +1 -1
- package/lib/client.js +166 -30
- package/lib/client.js.map +1 -1
- package/lib/index.js +400 -478
- package/lib/invariant.js +1 -1
- package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
- package/lib/types/access.d.ts +5 -4
- package/lib/types/access.d.ts.map +1 -1
- package/lib/types/access.js +2 -12
- package/lib/types/affinity.d.ts +2 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +12 -0
- package/lib/types/chatter.d.ts +68 -46
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +243 -301
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +23 -18
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +26 -9
- package/lib/types/client/locales.d.ts +18 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +18 -0
- package/lib/types/client/renderers/frames2d.d.ts +12 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
- package/lib/types/client/renderers/frames2d.js +143 -18
- package/lib/types/event-projection.d.ts +9 -4
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +46 -14
- package/lib/types/ledger.d.ts +4 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +4 -4
- package/lib/types/pair-access.d.ts +35 -0
- package/lib/types/pair-access.d.ts.map +1 -0
- package/lib/types/pair-access.js +19 -0
- package/lib/types/remarks.d.ts +1 -1
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +11 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +8 -3
- package/lib/types/service.d.ts +7 -11
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +31 -20
- package/lib/types/voice-pack.d.ts +11 -7
- package/lib/types/voice-pack.d.ts.map +1 -1
- package/lib/types/voice-pack.js +80 -49
- package/package.json +1 -1
- package/src/access.ts +7 -27
- package/src/affinity.ts +13 -0
- package/src/chatter.test.ts +68 -38
- package/src/chatter.ts +269 -316
- package/src/client/PetSprite.test.tsx +50 -15
- package/src/client/PetSprite.tsx +30 -25
- package/src/client/index.test.tsx +10 -1
- package/src/client/index.ts +29 -10
- package/src/client/locales.ts +18 -0
- package/src/client/renderers/frames2d.test.ts +101 -0
- package/src/client/renderers/frames2d.ts +138 -18
- package/src/event-projection.ts +57 -16
- package/src/ledger.ts +6 -4
- package/src/pair-access.ts +49 -0
- package/src/remarks.test.ts +10 -0
- package/src/remarks.ts +9 -2
- package/src/routes.ts +9 -4
- package/src/service.ts +36 -30
- package/src/voice-pack.test.ts +87 -25
- package/src/voice-pack.ts +87 -49
|
@@ -176,6 +176,24 @@ describe('PetSprite hide control (#1126)', () => {
|
|
|
176
176
|
})
|
|
177
177
|
})
|
|
178
178
|
|
|
179
|
+
describe('PetSprite affinity rank localization (#1226)', () => {
|
|
180
|
+
it('localizes the rank name in the hover panel when document language is en', () => {
|
|
181
|
+
document.documentElement.lang = 'en'
|
|
182
|
+
try {
|
|
183
|
+
renderPet({
|
|
184
|
+
snapshot: {
|
|
185
|
+
...snapshot,
|
|
186
|
+
affinity: { ...snapshot.affinity, rank: '幼鲸' },
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
190
|
+
expect(screen.getByText('Affinity Baby Whale')).toBeTruthy()
|
|
191
|
+
} finally {
|
|
192
|
+
document.documentElement.lang = 'zh'
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
179
197
|
describe('PetSprite rename input', () => {
|
|
180
198
|
it('submits the draft on Enter outside composition', () => {
|
|
181
199
|
const { onRename } = renderPet()
|
|
@@ -424,27 +442,18 @@ describe('PetSprite status bubble', () => {
|
|
|
424
442
|
expect(screen.queryByText('正在使用 grep')).not.toBeNull()
|
|
425
443
|
})
|
|
426
444
|
|
|
427
|
-
it('lets
|
|
428
|
-
renderPet({ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' } })
|
|
429
|
-
// The whisper speaks THROUGH the bubble: it replaces (not accompanies)
|
|
430
|
-
// the status copy, so the pet never shows two bubbles at once.
|
|
431
|
-
expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).not.toBeNull()
|
|
432
|
-
expect(screen.queryByText('正在思考')).toBeNull()
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
it('lets the whisper take over the display session bubble in the stack', () => {
|
|
445
|
+
it('lets a session whisper take over its own bubble while others stay collapsed', () => {
|
|
436
446
|
const { onOpenSession } = renderPet({
|
|
437
447
|
snapshot: {
|
|
438
448
|
...workingSnapshot,
|
|
439
|
-
whisper: '我在这儿陪着你呢,别急别急',
|
|
440
449
|
sessions: [
|
|
441
|
-
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
|
|
450
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '我在这儿陪着你呢,别急别急' },
|
|
442
451
|
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
|
|
443
452
|
],
|
|
444
453
|
},
|
|
445
454
|
})
|
|
446
|
-
// The
|
|
447
|
-
//
|
|
455
|
+
// The lead session's bubble speaks ITS whisper instead of its status
|
|
456
|
+
// copy; the collapsed extra session hides behind the badge.
|
|
448
457
|
expect(screen.queryByText('我在这儿陪着你呢,别急别急')).not.toBeNull()
|
|
449
458
|
expect(screen.queryByText('正在思考')).toBeNull()
|
|
450
459
|
expect(screen.queryByText('正在使用 grep')).toBeNull()
|
|
@@ -456,9 +465,28 @@ describe('PetSprite status bubble', () => {
|
|
|
456
465
|
expect(onOpenSession).toHaveBeenCalledWith('s-a')
|
|
457
466
|
})
|
|
458
467
|
|
|
468
|
+
it('renders each session whisper on its own expanded bubble', () => {
|
|
469
|
+
renderPet({
|
|
470
|
+
snapshot: {
|
|
471
|
+
...workingSnapshot,
|
|
472
|
+
sessions: [
|
|
473
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: 'A 的碎碎念' },
|
|
474
|
+
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep', whisper: 'B 的碎碎念' },
|
|
475
|
+
],
|
|
476
|
+
},
|
|
477
|
+
})
|
|
478
|
+
fireEvent.pointerOver(screen.getByText('A 的碎碎念').closest('div')!)
|
|
479
|
+
expect(screen.queryByText('A 的碎碎念')).not.toBeNull()
|
|
480
|
+
expect(screen.queryByText('B 的碎碎念')).not.toBeNull()
|
|
481
|
+
expect(screen.queryByText('正在思考')).toBeNull()
|
|
482
|
+
})
|
|
483
|
+
|
|
459
484
|
it('lets transient interaction feedback take over the whisper too', () => {
|
|
460
485
|
renderPet({
|
|
461
|
-
snapshot: {
|
|
486
|
+
snapshot: {
|
|
487
|
+
...workingSnapshot,
|
|
488
|
+
sessions: [{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '哼哧哼哧,大脑转得飞快~' }],
|
|
489
|
+
},
|
|
462
490
|
feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
|
|
463
491
|
})
|
|
464
492
|
expect(screen.queryByText('摸摸成功')).not.toBeNull()
|
|
@@ -754,7 +782,14 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
754
782
|
})
|
|
755
783
|
|
|
756
784
|
it('yields the bubble to the whisper (voice moment hides the ornament)', () => {
|
|
757
|
-
renderPet({
|
|
785
|
+
renderPet({
|
|
786
|
+
snapshot: {
|
|
787
|
+
...snapshot,
|
|
788
|
+
phase: 'thinking',
|
|
789
|
+
decoration,
|
|
790
|
+
sessions: [{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '冲了冲了' }],
|
|
791
|
+
},
|
|
792
|
+
})
|
|
758
793
|
expect(ornament()).toBeNull()
|
|
759
794
|
expect(document.body.textContent).toContain('冲了冲了')
|
|
760
795
|
})
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -432,14 +432,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
432
432
|
const statusBubble = feedback === null && sessionBubbles.length === 0
|
|
433
433
|
? snapshot?.bubble
|
|
434
434
|
: undefined
|
|
435
|
-
//
|
|
436
|
-
// woken by
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
// voices at once. Interaction feedback takes over the
|
|
440
|
-
// while it plays, so whispers yield to it like status
|
|
441
|
-
|
|
442
|
-
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined
|
|
435
|
+
// Each session's inner whisper (碎碎念) rides its own bubble — short
|
|
436
|
+
// inner-voice copy woken by that session's activity, never the model's or
|
|
437
|
+
// another session's. Instead of a second bubble of its own, a fresh
|
|
438
|
+
// whisper takes over its session's bubble and re-tints it, so the pet
|
|
439
|
+
// never wears two voices at once. Interaction feedback takes over the
|
|
440
|
+
// whole bubble area while it plays, so whispers yield to it like status
|
|
441
|
+
// copy.
|
|
442
|
+
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined
|
|
443
443
|
const displayName = snapshot?.name ?? definition.displayName
|
|
444
444
|
// The host-served status decoration (M5, #567); absent = text-only bubbles.
|
|
445
445
|
const decoration = snapshot?.decoration
|
|
@@ -549,7 +549,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
549
549
|
{feedback.text}
|
|
550
550
|
</div>
|
|
551
551
|
)}
|
|
552
|
-
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined
|
|
552
|
+
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined) && (
|
|
553
553
|
<div
|
|
554
554
|
ref={bubbleRef}
|
|
555
555
|
className={styles.bubbleStack}
|
|
@@ -557,15 +557,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
557
557
|
onPointerLeave={() => setStackPeek(false)}
|
|
558
558
|
>
|
|
559
559
|
{visibleSessions.map((session, index) => {
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
const speaksWhisper = index === 0 && whisper !== undefined
|
|
560
|
+
// A session's whisper rides ITS OWN bubble (the stack lead when
|
|
561
|
+
// the current session has one, any bubble when the stack is
|
|
562
|
+
// expanded). The key swap restarts the entrance animation so the
|
|
563
|
+
// mood change reads as the bubble re-speaking.
|
|
564
|
+
const speaksWhisper = session.whisper !== undefined
|
|
566
565
|
const bubble = (
|
|
567
566
|
<button
|
|
568
|
-
key={speaksWhisper ? 'whisper:' + whisper : session.sessionId}
|
|
567
|
+
key={speaksWhisper ? 'whisper:' + session.whisper : session.sessionId}
|
|
569
568
|
type="button"
|
|
570
569
|
className={clsx(
|
|
571
570
|
styles.bubble,
|
|
@@ -579,7 +578,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
579
578
|
{index === 0 && !speaksWhisper && decoration !== undefined && (
|
|
580
579
|
<StatusOrnament decoration={decoration} phase={phase} />
|
|
581
580
|
)}
|
|
582
|
-
{
|
|
581
|
+
{session.whisper ?? session.bubble}
|
|
583
582
|
</button>
|
|
584
583
|
)
|
|
585
584
|
// The primary bubble carries the '+N' badge while other sessions
|
|
@@ -608,19 +607,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
608
607
|
</span>
|
|
609
608
|
)
|
|
610
609
|
})}
|
|
611
|
-
{sessionBubbles.length === 0 &&
|
|
612
|
-
// The key swap (status copy <-> whisper) restarts the entrance
|
|
613
|
-
// animation on every mood change.
|
|
610
|
+
{sessionBubbles.length === 0 && statusBubble !== undefined && (
|
|
614
611
|
<div
|
|
615
|
-
key=
|
|
616
|
-
className={clsx(styles.bubble, styles.bubbleStatus
|
|
612
|
+
key="status"
|
|
613
|
+
className={clsx(styles.bubble, styles.bubbleStatus)}
|
|
617
614
|
role="status"
|
|
618
615
|
aria-live="polite"
|
|
619
616
|
>
|
|
620
|
-
{
|
|
617
|
+
{decoration !== undefined && (
|
|
621
618
|
<StatusOrnament decoration={decoration} phase={phase} />
|
|
622
619
|
)}
|
|
623
|
-
{
|
|
620
|
+
{statusBubble}
|
|
624
621
|
</div>
|
|
625
622
|
)}
|
|
626
623
|
</div>
|
|
@@ -688,7 +685,15 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
688
685
|
<>
|
|
689
686
|
<div className={styles.rankRow}>
|
|
690
687
|
<span className={styles.nameCell}>{displayName}</span>
|
|
691
|
-
<span className={styles.statRank}>
|
|
688
|
+
<span className={styles.statRank}>
|
|
689
|
+
{(() => {
|
|
690
|
+
const rawRank = snapshot?.affinity.rank ?? '?'
|
|
691
|
+
const rankKey = `pet.rank.name.${rawRank}`
|
|
692
|
+
const localized = props.t(rankKey as any)
|
|
693
|
+
const rankName = localized !== rankKey ? localized : rawRank
|
|
694
|
+
return panelStat('rank', 'pet.rank', { rank: rankName })
|
|
695
|
+
})()}
|
|
696
|
+
</span>
|
|
692
697
|
</div>
|
|
693
698
|
<div className={styles.rankRow}>
|
|
694
699
|
<span className={styles.statTreats}>{panelStat('treats', 'pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
|
|
@@ -110,7 +110,16 @@ function fakeContext(): FakeClientLifecycle {
|
|
|
110
110
|
},
|
|
111
111
|
register: () => () => {},
|
|
112
112
|
},
|
|
113
|
-
sessions:
|
|
113
|
+
sessions: {
|
|
114
|
+
list: {
|
|
115
|
+
getSnapshot: () => ({ current: undefined, byId: {} }),
|
|
116
|
+
subscribe: (listener: () => void) => {
|
|
117
|
+
const set = new Set<() => void>([listener])
|
|
118
|
+
return () => { set.delete(listener) }
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
open: () => {},
|
|
122
|
+
},
|
|
114
123
|
} as unknown as ClientContext
|
|
115
124
|
let disposed = false
|
|
116
125
|
const lifecycle: FakeClientLifecycle = {
|
package/src/client/index.ts
CHANGED
|
@@ -36,7 +36,8 @@ import { reportDailyHeartbeat } from './telemetry.ts'
|
|
|
36
36
|
|
|
37
37
|
/** The host pet API as the browser sees it (same-origin JSON endpoints). */
|
|
38
38
|
interface PetHttpApi {
|
|
39
|
-
|
|
39
|
+
/** Poll the host snapshot; the GUI's current session id rides the query. */
|
|
40
|
+
state(currentSessionId?: string): Promise<PetStateView>
|
|
40
41
|
pets(): Promise<PetDefinition[]>
|
|
41
42
|
interact(kind: PetInteraction): Promise<PetInteractResult>
|
|
42
43
|
setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }>
|
|
@@ -66,7 +67,8 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
|
|
|
66
67
|
|
|
67
68
|
/** The live host API instance (always defined; failures surface per call). */
|
|
68
69
|
const petApi: PetHttpApi = {
|
|
69
|
-
state: () => petFetch('/api/pet/state'
|
|
70
|
+
state: (currentSessionId) => petFetch('/api/pet/state'
|
|
71
|
+
+ (currentSessionId === undefined ? '' : '?current=' + encodeURIComponent(currentSessionId))),
|
|
70
72
|
pets: () => petFetch('/api/pet/pets'),
|
|
71
73
|
interact: (kind) => petFetch('/api/pet/interact', { kind }),
|
|
72
74
|
setVisible: (visible) => petFetch('/api/pet/set-visible', { visible }),
|
|
@@ -197,6 +199,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
197
199
|
const setState = petStore.actions.setState
|
|
198
200
|
const setFeedback = petStore.actions.setFeedback
|
|
199
201
|
|
|
202
|
+
// Clicking a session bubble jumps the GUI to that session; the same
|
|
203
|
+
// sessions face reports which session the user is currently on, so the
|
|
204
|
+
// host can lead the bubble stack with it. A bubble can outlive its
|
|
205
|
+
// disposed session by one poll tick, and the sessions service fails
|
|
206
|
+
// loud on unknown ids, so consult the live list first. The pet's type
|
|
207
|
+
// program also loads the host-side dsh-session package through the
|
|
208
|
+
// service types, whose Context merge declares a different 'sessions'
|
|
209
|
+
// face; pin the browser runtime's outward face here.
|
|
210
|
+
const sessions = ctx.sessions as unknown as ISessions
|
|
211
|
+
const currentSessionId = (): string | undefined => {
|
|
212
|
+
const current = sessions.list.getSnapshot().current
|
|
213
|
+
return current === undefined ? undefined : String(current)
|
|
214
|
+
}
|
|
215
|
+
|
|
200
216
|
// The registry list is fetched lazily with retries baked into the poll
|
|
201
217
|
// cycle: until it lands, the dock entry renders nothing and every 2s
|
|
202
218
|
// tick tries again. After it lands, one list feeds both the sprite and
|
|
@@ -218,7 +234,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
218
234
|
}
|
|
219
235
|
const seq = stateSeq + 1
|
|
220
236
|
stateSeq = seq
|
|
221
|
-
petApi.state().then((snapshot) => {
|
|
237
|
+
petApi.state(currentSessionId()).then((snapshot) => {
|
|
222
238
|
if (seq !== stateSeq) return
|
|
223
239
|
setSnapshot(snapshot)
|
|
224
240
|
}, () => {
|
|
@@ -261,13 +277,16 @@ export function apply(ctx: ClientContext): void {
|
|
|
261
277
|
}
|
|
262
278
|
}, 'pet: poll')
|
|
263
279
|
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
280
|
+
// A current-session switch should re-order the bubble stack right away,
|
|
281
|
+
// not on the next 2s tick. Poll only while the tab is visible, as the
|
|
282
|
+
// poll loop itself does.
|
|
283
|
+
const disposeSessionWatch = ctx.effect(() => {
|
|
284
|
+
const unsubscribe = sessions.list.subscribe(() => {
|
|
285
|
+
if (document.visibilityState === 'visible') pollNow()
|
|
286
|
+
})
|
|
287
|
+
return unsubscribe
|
|
288
|
+
}, 'pet: current-session watch')
|
|
289
|
+
|
|
271
290
|
const openSession = (sessionId: string): void => {
|
|
272
291
|
const list = sessions.list.getSnapshot()
|
|
273
292
|
if (list.byId[sessionId as SessionId] === undefined) return
|
package/src/client/locales.ts
CHANGED
|
@@ -15,6 +15,15 @@ export const zh = {
|
|
|
15
15
|
'pet.namePlaceholder': '输入新名字',
|
|
16
16
|
'pet.summon': '召唤{name}',
|
|
17
17
|
'pet.rank': '亲密度 {rank}',
|
|
18
|
+
'pet.rank.name.幼鲸': '幼鲸',
|
|
19
|
+
'pet.rank.name.伙伴': '伙伴',
|
|
20
|
+
'pet.rank.name.挚友': '挚友',
|
|
21
|
+
'pet.rank.name.深海羁绊': '深海羁绊',
|
|
22
|
+
'pet.rank.name.心有灵犀': '心有灵犀',
|
|
23
|
+
'pet.rank.name.传说羁绊': '传说羁绊',
|
|
24
|
+
'pet.rank.name.神话羁绊': '神话羁绊',
|
|
25
|
+
'pet.rank.name.永恒之契': '永恒之契',
|
|
26
|
+
'pet.rank.name.鲸生共渡': '鲸生共渡',
|
|
18
27
|
'pet.points': '{points} 点',
|
|
19
28
|
'pet.treats': '小鱼干 ×{n}',
|
|
20
29
|
'pet.state.loading': '宠物正在赶来…',
|
|
@@ -88,6 +97,15 @@ export const en = {
|
|
|
88
97
|
'pet.namePlaceholder': 'Enter a new name',
|
|
89
98
|
'pet.summon': 'Summon {name}',
|
|
90
99
|
'pet.rank': 'Affinity {rank}',
|
|
100
|
+
'pet.rank.name.幼鲸': 'Baby Whale',
|
|
101
|
+
'pet.rank.name.伙伴': 'Companion',
|
|
102
|
+
'pet.rank.name.挚友': 'Close Friend',
|
|
103
|
+
'pet.rank.name.深海羁绊': 'Deep Sea Bond',
|
|
104
|
+
'pet.rank.name.心有灵犀': 'Kindred Spirit',
|
|
105
|
+
'pet.rank.name.传说羁绊': 'Legendary Bond',
|
|
106
|
+
'pet.rank.name.神话羁绊': 'Mythic Bond',
|
|
107
|
+
'pet.rank.name.永恒之契': 'Eternal Covenant',
|
|
108
|
+
'pet.rank.name.鲸生共渡': 'Lifelong Companion',
|
|
91
109
|
'pet.points': '{points} pts',
|
|
92
110
|
'pet.treats': 'Treats ×{n}',
|
|
93
111
|
'pet.state.loading': 'The pet is on its way…',
|
|
@@ -135,3 +135,104 @@ describe('frames2dRenderer', () => {
|
|
|
135
135
|
expect(container.querySelector('img')).toBeNull()
|
|
136
136
|
})
|
|
137
137
|
})
|
|
138
|
+
|
|
139
|
+
describe('frames2dRenderer canvas bitmap path', () => {
|
|
140
|
+
// The canvas branch needs createImageBitmap + fetch + a real 2D context;
|
|
141
|
+
// jsdom has none of them, so each test installs fakes and restores after.
|
|
142
|
+
const flush = async (): Promise<void> => {
|
|
143
|
+
await Promise.resolve()
|
|
144
|
+
await Promise.resolve()
|
|
145
|
+
await Promise.resolve()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let draws = 0
|
|
149
|
+
const bitmaps: { width: number; height: number; close: ReturnType<typeof vi.fn> }[] = []
|
|
150
|
+
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
vi.useFakeTimers()
|
|
153
|
+
document.body.innerHTML = ''
|
|
154
|
+
draws = 0
|
|
155
|
+
bitmaps.length = 0
|
|
156
|
+
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, blob: async () => ({}) })))
|
|
157
|
+
vi.stubGlobal('createImageBitmap', vi.fn(async () => {
|
|
158
|
+
const bmp = { width: 32, height: 32, close: vi.fn() }
|
|
159
|
+
bitmaps.push(bmp)
|
|
160
|
+
return bmp
|
|
161
|
+
}))
|
|
162
|
+
;(HTMLCanvasElement.prototype as unknown as { getContext: unknown }).getContext = () => ({
|
|
163
|
+
clearRect: () => {},
|
|
164
|
+
drawImage: () => {
|
|
165
|
+
draws += 1
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
vi.unstubAllGlobals()
|
|
172
|
+
Reflect.deleteProperty(HTMLCanvasElement.prototype as unknown as Record<string, unknown>, 'getContext')
|
|
173
|
+
vi.useRealTimers()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
function canvasSetup() {
|
|
177
|
+
const container = document.createElement('div')
|
|
178
|
+
document.body.appendChild(container)
|
|
179
|
+
const ctx: PetRendererContext = {
|
|
180
|
+
petId: 'miku',
|
|
181
|
+
assetBase: '/pet/miku',
|
|
182
|
+
container,
|
|
183
|
+
phase: createPhaseStream('idle'),
|
|
184
|
+
interact: () => {},
|
|
185
|
+
onCleanup: () => {},
|
|
186
|
+
}
|
|
187
|
+
return { ctx, container }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it('mounts a canvas instead of an img and paints advanced frames via drawImage', async () => {
|
|
191
|
+
const { ctx, container } = canvasSetup()
|
|
192
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
193
|
+
expect(container.querySelector('canvas')).not.toBeNull()
|
|
194
|
+
expect(container.querySelector('img')).toBeNull()
|
|
195
|
+
await flush()
|
|
196
|
+
// Warm pass decoded every configured frame exactly once; the first frame
|
|
197
|
+
// of the idle track painted during mount.
|
|
198
|
+
expect(bitmaps.length).toBe(6)
|
|
199
|
+
const paintsAtMount = draws
|
|
200
|
+
expect(paintsAtMount).toBeGreaterThanOrEqual(1)
|
|
201
|
+
vi.advanceTimersByTime(100)
|
|
202
|
+
await flush()
|
|
203
|
+
expect(draws).toBeGreaterThan(paintsAtMount)
|
|
204
|
+
handle.dispose()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('sizes the backing store from decoded dimensions and releases bitmaps on dispose', async () => {
|
|
208
|
+
const { ctx, container } = canvasSetup()
|
|
209
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
210
|
+
await flush()
|
|
211
|
+
const canvas = container.querySelector('canvas')!
|
|
212
|
+
expect(canvas.width).toBe(32)
|
|
213
|
+
expect(canvas.height).toBe(32)
|
|
214
|
+
handle.dispose()
|
|
215
|
+
await flush()
|
|
216
|
+
for (const bmp of bitmaps) expect(bmp.close).toHaveBeenCalled()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('keeps painting through a phase switch without touching the DOM tree', async () => {
|
|
220
|
+
const { container } = canvasSetup()
|
|
221
|
+
const stream = createPhaseStream('idle')
|
|
222
|
+
const ctx: PetRendererContext = {
|
|
223
|
+
petId: 'miku', assetBase: '/pet/miku', container, phase: stream, interact: () => {}, onCleanup: () => {},
|
|
224
|
+
}
|
|
225
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
226
|
+
await flush()
|
|
227
|
+
const nodesBefore = container.querySelectorAll('*').length
|
|
228
|
+
stream.push('thinking' as ActivityPhase)
|
|
229
|
+
expect(handle.currentTrack()).toBe('work')
|
|
230
|
+
await flush()
|
|
231
|
+
vi.advanceTimersByTime(100)
|
|
232
|
+
await flush()
|
|
233
|
+
// Steady-state playback is DOM-mutation-free: no element added or removed.
|
|
234
|
+
expect(container.querySelectorAll('*').length).toBe(nodesBefore)
|
|
235
|
+
expect(draws).toBeGreaterThanOrEqual(3)
|
|
236
|
+
handle.dispose()
|
|
237
|
+
})
|
|
238
|
+
})
|
|
@@ -8,6 +8,18 @@
|
|
|
8
8
|
* phase-mapped track. Rendering never throws: a broken track list degrades
|
|
9
9
|
* to the first decodable frame, and the 1.2 s stall watchdog re-kicks the
|
|
10
10
|
* playback chain after timer throttling.
|
|
11
|
+
*
|
|
12
|
+
* Frame presentation has two modes picked once at mount by capability
|
|
13
|
+
* probing:
|
|
14
|
+
* - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
|
|
15
|
+
* exist): every frame is decoded exactly once into an ImageBitmap during
|
|
16
|
+
* the warm pass and drawn onto one <canvas> - steady-state playback issues
|
|
17
|
+
* zero DOM mutations and zero re-decodes (measured hotspot: swapping
|
|
18
|
+
* <img>.src per frame drove image decode + invalidation every tick).
|
|
19
|
+
* - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
|
|
20
|
+
* historical behavior - cache-warm Image elements plus guarded src swaps,
|
|
21
|
+
* so environments without modern decoding keep working unchanged.
|
|
22
|
+
*
|
|
11
23
|
* @module @linxin666/dsh-pet/client/renderers/frames2d
|
|
12
24
|
*/
|
|
13
25
|
|
|
@@ -69,6 +81,12 @@ function validateFrames2dConfig(config: unknown): PetFrames2dConfig {
|
|
|
69
81
|
return { tracks, phases: phases as PetFrames2dConfig['phases'] }
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
interface DecodedFrame {
|
|
85
|
+
source: ImageBitmap | HTMLImageElement
|
|
86
|
+
width: number
|
|
87
|
+
height: number
|
|
88
|
+
}
|
|
89
|
+
|
|
72
90
|
export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
73
91
|
id: 'frames2d',
|
|
74
92
|
apiVersion: PET_RENDERER_API_VERSION,
|
|
@@ -79,23 +97,78 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
79
97
|
&& typeof window.matchMedia === 'function'
|
|
80
98
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
81
99
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
img
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
for (const url of track.frames) {
|
|
96
|
-
const pre = new Image()
|
|
97
|
-
pre.src = url
|
|
100
|
+
// Capability probe: the canvas path needs decode-to-bitmap, same-origin
|
|
101
|
+
// fetch, and a real 2D context (jsdom returns null and falls back below).
|
|
102
|
+
let canvas: HTMLCanvasElement | null = null
|
|
103
|
+
let context2d: CanvasRenderingContext2D | null = null
|
|
104
|
+
let img: HTMLImageElement | null = null
|
|
105
|
+
try {
|
|
106
|
+
if (typeof createImageBitmap === 'function' && typeof fetch === 'function') {
|
|
107
|
+
const probe = document.createElement('canvas')
|
|
108
|
+
const c2d = probe.getContext('2d')
|
|
109
|
+
if (c2d !== null) {
|
|
110
|
+
canvas = probe
|
|
111
|
+
context2d = c2d
|
|
112
|
+
}
|
|
98
113
|
}
|
|
114
|
+
} catch {
|
|
115
|
+
canvas = null
|
|
116
|
+
context2d = null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (canvas !== null && context2d !== null) {
|
|
120
|
+
canvas.dataset.dshPetFrames2d = ctx.petId
|
|
121
|
+
canvas.draggable = false
|
|
122
|
+
canvas.style.width = '100%'
|
|
123
|
+
canvas.style.height = '100%'
|
|
124
|
+
canvas.style.objectFit = 'contain'
|
|
125
|
+
canvas.style.pointerEvents = 'none'
|
|
126
|
+
ctx.container.appendChild(canvas)
|
|
127
|
+
} else {
|
|
128
|
+
img = document.createElement('img')
|
|
129
|
+
img.dataset.dshPetFrames2d = ctx.petId
|
|
130
|
+
img.alt = ''
|
|
131
|
+
img.draggable = false
|
|
132
|
+
img.style.width = '100%'
|
|
133
|
+
img.style.height = '100%'
|
|
134
|
+
img.style.objectFit = 'contain'
|
|
135
|
+
img.style.pointerEvents = 'none'
|
|
136
|
+
ctx.container.appendChild(img)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Decode cache: one concurrent decode per URL, memoized forever (frames
|
|
140
|
+
// are tiny same-origin webp files; failures resolve undefined and keep
|
|
141
|
+
// the last painted frame instead of breaking playback).
|
|
142
|
+
const decoding = new Map<string, Promise<DecodedFrame | undefined>>()
|
|
143
|
+
const decodedAll: Promise<void>[] = []
|
|
144
|
+
const loadFrame = (url: string): Promise<DecodedFrame | undefined> => {
|
|
145
|
+
const cached = decoding.get(url)
|
|
146
|
+
if (cached !== undefined) return cached
|
|
147
|
+
const job: Promise<DecodedFrame | undefined> = (async () => {
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(url)
|
|
150
|
+
if (!response.ok) throw new Error('http ' + response.status)
|
|
151
|
+
const bitmap = await createImageBitmap(await response.blob())
|
|
152
|
+
return { source: bitmap, width: bitmap.width, height: bitmap.height }
|
|
153
|
+
} catch {
|
|
154
|
+
// Fail-open: classic Image decode keeps non-modern runtimes alive.
|
|
155
|
+
return await new Promise<DecodedFrame | undefined>((resolve) => {
|
|
156
|
+
try {
|
|
157
|
+
const pre = new Image()
|
|
158
|
+
pre.onload = (): void => {
|
|
159
|
+
resolve(pre.naturalWidth > 0 ? { source: pre, width: pre.naturalWidth, height: pre.naturalHeight } : undefined)
|
|
160
|
+
}
|
|
161
|
+
pre.onerror = (): void => resolve(undefined)
|
|
162
|
+
pre.src = url
|
|
163
|
+
} catch {
|
|
164
|
+
resolve(undefined)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
})()
|
|
169
|
+
decoding.set(url, job)
|
|
170
|
+
decodedAll.push(job.then(() => undefined, () => undefined))
|
|
171
|
+
return job
|
|
99
172
|
}
|
|
100
173
|
|
|
101
174
|
let disposed = false
|
|
@@ -105,16 +178,42 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
105
178
|
let frameIndex = 0
|
|
106
179
|
let lastAdvance = Date.now()
|
|
107
180
|
let override: string | undefined
|
|
181
|
+
let drawToken = 0
|
|
182
|
+
let lastDrawnUrl: string | undefined
|
|
108
183
|
|
|
109
184
|
const trackForPhase = (phase: ActivityPhase): string => {
|
|
110
185
|
const mapped = config.phases[phase]
|
|
111
186
|
return mapped !== undefined && config.tracks[mapped] !== undefined ? mapped : config.phases.idle
|
|
112
187
|
}
|
|
113
188
|
|
|
189
|
+
/** Canvas path: paints the newest requested frame; stale draws drop out. */
|
|
190
|
+
const paintCanvas = (url: string): void => {
|
|
191
|
+
if (context2d === null || canvas === null) return
|
|
192
|
+
const myToken = ++drawToken
|
|
193
|
+
void loadFrame(url).then((frame) => {
|
|
194
|
+
if (disposed || frame === undefined || myToken !== drawToken) return
|
|
195
|
+
if (lastDrawnUrl === url) return
|
|
196
|
+
lastDrawnUrl = url
|
|
197
|
+
// Resizing clears the canvas, so size only when it actually differs.
|
|
198
|
+
if (canvas.width !== frame.width || canvas.height !== frame.height) {
|
|
199
|
+
canvas.width = frame.width
|
|
200
|
+
canvas.height = frame.height
|
|
201
|
+
} else {
|
|
202
|
+
context2d.clearRect(0, 0, canvas.width, canvas.height)
|
|
203
|
+
}
|
|
204
|
+
context2d.drawImage(frame.source as CanvasImageSource, 0, 0)
|
|
205
|
+
}).catch(() => { /* keep last frame */ })
|
|
206
|
+
}
|
|
207
|
+
|
|
114
208
|
const show = (trackId: string, index: number): void => {
|
|
115
209
|
const def = config.tracks[trackId]
|
|
116
210
|
const url = def?.frames[index]
|
|
117
|
-
if (url
|
|
211
|
+
if (url === undefined) return
|
|
212
|
+
if (img !== null) {
|
|
213
|
+
if (img.getAttribute('src') !== url) img.src = url
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
paintCanvas(url)
|
|
118
217
|
}
|
|
119
218
|
|
|
120
219
|
const schedule = (ms: number): void => {
|
|
@@ -180,6 +279,13 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
180
279
|
}, WATCHDOG_MS)
|
|
181
280
|
}
|
|
182
281
|
|
|
282
|
+
// Warm pass: decode every frame up front (tiny same-origin webp files)
|
|
283
|
+
// so loops and phase switches never wait on a first decode - same intent
|
|
284
|
+
// as the historical Image-cache warm loop, now feeding the decode cache.
|
|
285
|
+
for (const warmTrack of Object.values(config.tracks)) {
|
|
286
|
+
for (const warmUrl of warmTrack.frames) void loadFrame(warmUrl)
|
|
287
|
+
}
|
|
288
|
+
|
|
183
289
|
play(track)
|
|
184
290
|
|
|
185
291
|
let disposedOnce = false
|
|
@@ -190,7 +296,21 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
190
296
|
unsubscribe()
|
|
191
297
|
if (timer !== undefined) clearTimeout(timer)
|
|
192
298
|
if (watchdog !== undefined) clearInterval(watchdog)
|
|
193
|
-
|
|
299
|
+
// Release decoded bitmaps after pending decodes settle; close() is
|
|
300
|
+
// browser-only, so guard it for exotic hosts.
|
|
301
|
+
void Promise.allSettled(decodedAll).then(() => {
|
|
302
|
+
for (const job of decoding.values()) {
|
|
303
|
+
void job.then((frame) => {
|
|
304
|
+
try {
|
|
305
|
+
const maybeClose = (frame?.source as { close?: () => void } | undefined)?.close
|
|
306
|
+
if (typeof maybeClose === 'function' && frame !== undefined) maybeClose.call(frame.source)
|
|
307
|
+
} catch { /* already released */ }
|
|
308
|
+
}).catch(() => { /* never rejects anyway */ })
|
|
309
|
+
}
|
|
310
|
+
decoding.clear()
|
|
311
|
+
})
|
|
312
|
+
canvas?.remove()
|
|
313
|
+
img?.remove()
|
|
194
314
|
}
|
|
195
315
|
ctx.onCleanup(dispose)
|
|
196
316
|
|