@linxin666/dsh-pet 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +73 -38
  3. package/README.zh.md +91 -55
  4. package/assets/whale/pet.json +12 -1
  5. package/cordis.patch.yml +4 -4
  6. package/lib/client.js +296 -347
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +975 -317
  9. package/lib/invariant.js +1 -1
  10. package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
  11. package/lib/types/affinity.d.ts +15 -0
  12. package/lib/types/affinity.d.ts.map +1 -1
  13. package/lib/types/affinity.js +14 -0
  14. package/lib/types/client/PetDockEntry.d.ts +14 -12
  15. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  16. package/lib/types/client/PetDockEntry.js +13 -8
  17. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  18. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PetSettingsCard.js +42 -7
  20. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  21. package/lib/types/client/PetSprite.d.ts.map +1 -0
  22. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  23. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  24. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  25. package/lib/types/client/PluginSettingsCard.js +30 -8
  26. package/lib/types/client/index.d.ts +11 -8
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +32 -14
  29. package/lib/types/client/locales.d.ts +10 -10
  30. package/lib/types/client/locales.js +10 -10
  31. package/lib/types/client/pet-store.d.ts +5 -0
  32. package/lib/types/client/pet-store.d.ts.map +1 -1
  33. package/lib/types/client/pet-store.js +4 -0
  34. package/lib/types/client/settings-form.d.ts +76 -5
  35. package/lib/types/client/settings-form.d.ts.map +1 -1
  36. package/lib/types/client/settings-form.js +97 -20
  37. package/lib/types/client/spritesheet.d.ts +14 -48
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +13 -81
  40. package/lib/types/dsh-home.d.ts +17 -0
  41. package/lib/types/dsh-home.d.ts.map +1 -0
  42. package/lib/types/dsh-home.js +34 -0
  43. package/lib/types/event-projection.d.ts +36 -0
  44. package/lib/types/event-projection.d.ts.map +1 -0
  45. package/lib/types/event-projection.js +98 -0
  46. package/lib/types/index.d.ts +24 -11
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +53 -33
  49. package/lib/types/ledger.d.ts +79 -0
  50. package/lib/types/ledger.d.ts.map +1 -0
  51. package/lib/types/ledger.js +146 -0
  52. package/lib/types/persist.d.ts +15 -4
  53. package/lib/types/persist.d.ts.map +1 -1
  54. package/lib/types/persist.js +39 -8
  55. package/lib/types/registry.d.ts +158 -0
  56. package/lib/types/registry.d.ts.map +1 -0
  57. package/lib/types/registry.js +276 -0
  58. package/lib/types/routes.d.ts +10 -10
  59. package/lib/types/routes.d.ts.map +1 -1
  60. package/lib/types/routes.js +169 -51
  61. package/lib/types/service.d.ts +59 -57
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +111 -211
  64. package/package.json +2 -2
  65. package/src/affinity.ts +33 -0
  66. package/src/client/PetDockEntry.tsx +34 -26
  67. package/src/client/PetSettingsCard.tsx +67 -22
  68. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  69. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  70. package/src/client/PluginSettingsCard.tsx +90 -18
  71. package/src/client/index.ts +38 -16
  72. package/src/client/locales.ts +10 -10
  73. package/src/client/pet-store.ts +9 -0
  74. package/src/client/settings-card.module.css +9 -0
  75. package/src/client/settings-form.ts +153 -19
  76. package/src/client/spritesheet.ts +16 -92
  77. package/src/dsh-home.test.ts +35 -0
  78. package/src/dsh-home.ts +36 -0
  79. package/src/event-projection.ts +128 -0
  80. package/src/index.test.ts +23 -0
  81. package/src/index.ts +89 -45
  82. package/src/ledger.test.ts +69 -0
  83. package/src/ledger.ts +190 -0
  84. package/src/persist.test.ts +52 -3
  85. package/src/persist.ts +49 -11
  86. package/src/registry.test.ts +132 -0
  87. package/src/registry.ts +400 -0
  88. package/src/routes.ts +166 -53
  89. package/src/service.ts +153 -285
  90. package/lib/types/client/WhalePet.d.ts.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-pet",
3
- "description": "鲸鱼娘宠物插件 for the dsh web GUI: a soft healing whale-girl companion that reacts to model activity (idle/waiting/thinking/tool/done), with petting/feeding interactions and an affinity score",
4
- "version": "0.1.14",
3
+ "description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
4
+ "version": "0.1.16",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
package/src/affinity.ts CHANGED
@@ -84,6 +84,39 @@ export function rankOf(points: number): (typeof AFFINITY_RANKS)[number] {
84
84
  return rank
85
85
  }
86
86
 
87
+ /** Read-only affinity snapshot suited for the RPC view shape. */
88
+ export interface PetAffinityView {
89
+ points: number
90
+ rank: string
91
+ rankEmoji: string
92
+ pets: number
93
+ feeds: number
94
+ turns: number
95
+ /** True while the pet interaction is inside its cooldown. */
96
+ petCooldown: boolean
97
+ /** True while the feed is inside its cooldown. */
98
+ feedCooldown: boolean
99
+ }
100
+
101
+ /** Derive the read-only view of one affinity state at a wall-clock instant. */
102
+ export function affinityViewOf(
103
+ state: AffinityState,
104
+ nowMs: number,
105
+ config: AffinityConfig = defaultAffinityConfig,
106
+ ): PetAffinityView {
107
+ const rank = rankOf(state.points)
108
+ return {
109
+ points: state.points,
110
+ rank: rank.name,
111
+ rankEmoji: rank.emoji,
112
+ pets: state.pets,
113
+ feeds: state.feeds,
114
+ turns: state.turns,
115
+ petCooldown: nowMs - state.lastPetAt < config.petCooldownMs,
116
+ feedCooldown: nowMs - state.lastFeedAt < config.feedCooldownMs,
117
+ }
118
+ }
119
+
87
120
  function clamp(points: number): number {
88
121
  return Math.min(AFFINITY_MAX, Math.max(0, points))
89
122
  }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Global floating pet entry. The pet is host-global (its state, display and
3
- * interactions live on `/api/pet/*` endpoints with no session dimension), so
3
+ * interactions live on '/api/pet/*' endpoints with no session dimension), so
4
4
  * it must not ride a session-scoped slot — on the new-conversation screen no
5
5
  * session exists to scope a slot by, and the pet would vanish (issue #48).
6
- * The client half therefore mounts this entry straight onto `document.body`
7
- * (see index.ts): while visible it renders the floating WhalePet (a portal),
8
- * while hidden it renders a fixed-position summon button.
6
+ * The client half therefore mounts this entry straight onto 'document.body'
7
+ * (see index.ts): while visible it renders the floating PetSprite (a
8
+ * portal), while hidden it renders a fixed-position summon button. Which
9
+ * sprite renders is decided by the host snapshot's pet id resolved against
10
+ * the registry list — no per-pet component exists.
9
11
  * @module @linxin666/dsh-pet/client/PetDockEntry
10
12
  */
11
13
 
@@ -13,27 +15,27 @@ import { useEffect, useSyncExternalStore, type ReactElement } from 'react'
13
15
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
14
16
  import type { PetDisplayConfig } from '../persist.ts'
15
17
  import type { PetStoreInstance } from './pet-store.ts'
16
- import { WhalePet } from './WhalePet.tsx'
18
+ import { PetSprite } from './PetSprite.tsx'
17
19
  import { NS } from './locales.ts'
18
20
  import styles from './pet.module.css'
19
21
 
20
22
  /** Injected actions handed to the dock entry component. */
21
23
  export interface PetInjected {
22
- /** The app-wide pet store instance (snapshot + feedback). */
24
+ /** The app-wide pet store instance (snapshot + registry list + feedback). */
23
25
  store: PetStoreInstance
24
- /** Ensure the first snapshot is fetched (called on mount). */
26
+ /** Ensure the first snapshot (and registry list) is fetched (called on mount). */
25
27
  ensure: () => void
26
- /** Pet the whale girl (click). */
28
+ /** Pet the sprite (click). */
27
29
  pet: () => void
28
- /** Feed the whale girl. */
30
+ /** Feed the sprite. */
29
31
  feed: () => void
30
- /** Hide the whale girl. */
32
+ /** Hide the sprite. */
31
33
  hide: () => void
32
- /** Summon the hidden whale girl back. */
34
+ /** Summon the hidden sprite back. */
33
35
  summon: () => void
34
36
  /** Persist a drag position. */
35
37
  dragEnd: (right: number, bottom: number) => void
36
- /** Rename the pet (persisted by the host). */
38
+ /** Rename the selected pet (persisted by the host). */
37
39
  rename: (name: string) => void
38
40
  /** Clear the reaction bubble. */
39
41
  feedbackDone: () => void
@@ -47,7 +49,7 @@ export type PetDockEntryProps =
47
49
  const DEFAULT_DISPLAY: PetDisplayConfig = { visible: true, size: 160, right: 24, bottom: 20 }
48
50
 
49
51
  /**
50
- * Dock entry: while the pet is visible, mount the floating WhalePet (it
52
+ * Dock entry: while the pet is visible, mount the floating PetSprite (it
51
53
  * portals itself onto document.body); while hidden, render the summon
52
54
  * button so the pet can always come back. The store is the plugin-owned
53
55
  * single instance — the slot system provides none because the pet is
@@ -58,6 +60,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
58
60
  const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
59
61
  const snapshot = ui.snapshot
60
62
  const feedback = ui.feedback
63
+ const definition = ui.pets.find(entry => entry.id === snapshot?.pet.id) ?? null
61
64
  const visible = snapshot?.display.visible ?? true
62
65
 
63
66
  useEffect(() => {
@@ -67,18 +70,23 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
67
70
  if (visible) {
68
71
  return (
69
72
  <span data-pet-dock data-testid="pet-dock">
70
- <WhalePet
71
- snapshot={snapshot}
72
- display={snapshot?.display ?? DEFAULT_DISPLAY}
73
- feedback={feedback}
74
- onPet={props.pet}
75
- onFeed={props.feed}
76
- onHide={props.hide}
77
- onDragEnd={props.dragEnd}
78
- onRename={props.rename}
79
- onFeedbackDone={props.feedbackDone}
80
- t={props.t}
81
- />
73
+ {snapshot === null || definition === null
74
+ ? null
75
+ : (
76
+ <PetSprite
77
+ snapshot={snapshot}
78
+ definition={definition}
79
+ display={snapshot.display}
80
+ feedback={feedback}
81
+ onPet={props.pet}
82
+ onFeed={props.feed}
83
+ onHide={props.hide}
84
+ onDragEnd={props.dragEnd}
85
+ onRename={props.rename}
86
+ onFeedbackDone={props.feedbackDone}
87
+ t={props.t}
88
+ />
89
+ )}
82
90
  </span>
83
91
  )
84
92
  }
@@ -96,7 +104,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
96
104
  onClick={props.summon}
97
105
  data-testid="pet-summon"
98
106
  >
99
- {props.t('pet.summon', { name: snapshot?.name ?? '鲸鱼娘' })}
107
+ {props.t('pet.summon', { name: snapshot?.name ?? '' })}
100
108
  </button>
101
109
  )
102
110
  }
@@ -1,13 +1,15 @@
1
1
  /**
2
- * The pet settings card: display layout and name, bound to the `pet` settings
3
- * namespace the host plugin registers. Registered into the
4
- * `settings.plugin.item` slot the plugin-configuration section renders.
2
+ * The pet settings card: pet selection plus display layout, bound to the
3
+ * 'pet' settings namespace the host plugin registers. Registered into the
4
+ * 'web-ui.plugin.item' slot the plugin-configuration section renders. The
5
+ * petId choices come from the registry endpoint ('/api/pet/pets') — the same
6
+ * list the sprite renders from — so the card carries no per-pet knowledge.
5
7
  */
6
8
 
7
9
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
8
10
  import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
9
- import { PluginSettingsCard, ValueField, BooleanField } from './PluginSettingsCard.tsx'
10
- import { CardForm, booleanField, numberField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
11
+ import { PluginSettingsCard, ValueField, BooleanField, ChoiceField } from './PluginSettingsCard.tsx'
12
+ import { CardForm, booleanField, choiceField, numberField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
11
13
 
12
14
  /** The pet's settings fields this card edits (the namespace's full schema). */
13
15
  export interface PetSettings {
@@ -21,8 +23,8 @@ export interface PetSettings {
21
23
  right?: number
22
24
  /** Vertical inset from the viewport bottom edge, px. */
23
25
  bottom?: number
24
- /** User-customizable pet display name. */
25
- name?: string
26
+ /** Selected pet id (a registry entry). */
27
+ petId?: string
26
28
  }
27
29
 
28
30
  /** What the pet settings card renders. */
@@ -37,8 +39,10 @@ export interface PetSettingsCardState extends CardShell {
37
39
  right: CardFieldState
38
40
  /** Bottom inset. */
39
41
  bottom: CardFieldState
40
- /** Pet name. */
41
- name: CardFieldState
42
+ /** Selected pet. */
43
+ petId: CardFieldState
44
+ /** Pet choices (registry ids + display names), loaded from the host. */
45
+ petChoices: readonly { value: string; label: string }[]
42
46
  }
43
47
 
44
48
  /** The registration-side face the card's slot entry injects. */
@@ -49,12 +53,32 @@ export interface PetSettingsCardFace extends CardActions {
49
53
  }
50
54
  }
51
55
 
52
- /** Bridges the `pet` scope onto the card's staged form. */
56
+ /** One registry choice as served by '/api/pet/pets'. */
57
+ interface PetChoice {
58
+ id: string
59
+ displayName: string
60
+ }
61
+
62
+ /** Fetch the registry list (the same data the sprite renders from). */
63
+ async function fetchPetChoices(): Promise<PetChoice[]> {
64
+ const response = await fetch('/api/pet/pets')
65
+ if (!response.ok) throw new Error('pet pets failed: ' + response.status)
66
+ return (await response.json()) as PetChoice[]
67
+ }
68
+
69
+ /** Bridges the 'pet' scope onto the card's staged form. */
53
70
  export class PetSettingsCardController {
54
71
  private readonly form: CardForm<PetSettings>
55
72
  private readonly store: SnapshotStore<PetSettingsCardState>
73
+ // The choice list rides a mutable array shared with the choiceField spec,
74
+ // so loading the registry re-validates and re-formats the petId field
75
+ // without rebuilding the form.
76
+ private readonly petChoices: string[] = []
77
+ private readonly petLabels = new Map<string, string>()
78
+ private loaded = false
79
+ private attempts = 0
56
80
 
57
- /** @param scope - the bound settings scope for the `pet` namespace. */
81
+ /** @param scope - the bound settings scope for the 'pet' namespace. */
58
82
  constructor(scope: SettingsScope<PetSettings>) {
59
83
  this.form = new CardForm(scope, [
60
84
  booleanField('enabled'),
@@ -62,9 +86,27 @@ export class PetSettingsCardController {
62
86
  numberField('size'),
63
87
  numberField('right'),
64
88
  numberField('bottom'),
65
- textField('name'),
89
+ choiceField('petId', this.petChoices),
66
90
  ])
67
91
  this.store = this.form.bind(() => this.projection())
92
+ void this.loadPets()
93
+ }
94
+
95
+ /** Resolve the registry choices once (retried a few times on failure). */
96
+ private async loadPets(): Promise<void> {
97
+ if (this.loaded) return
98
+ try {
99
+ const list = await fetchPetChoices()
100
+ this.petChoices.splice(0, this.petChoices.length, ...list.map(choice => choice.id))
101
+ for (const choice of list) this.petLabels.set(choice.id, choice.displayName)
102
+ this.loaded = true
103
+ this.store.set(this.projection())
104
+ } catch {
105
+ this.attempts += 1
106
+ if (this.attempts < 3) {
107
+ window.setTimeout(() => { void this.loadPets() }, 3000)
108
+ }
109
+ }
68
110
  }
69
111
 
70
112
  private projection(): PetSettingsCardState {
@@ -75,7 +117,8 @@ export class PetSettingsCardController {
75
117
  size: this.form.field('size'),
76
118
  right: this.form.field('right'),
77
119
  bottom: this.form.field('bottom'),
78
- name: this.form.field('name'),
120
+ petId: this.form.field('petId'),
121
+ petChoices: this.petChoices.map(id => ({ value: id, label: this.petLabels.get(id) ?? id })),
79
122
  }
80
123
  }
81
124
 
@@ -130,6 +173,17 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
130
173
  onEdit={(text) => { props.edit('enabled', text) }}
131
174
  onReset={() => { props.resetField('enabled') }}
132
175
  />
176
+ <ChoiceField
177
+ id="settings-pet-pet"
178
+ label={t('settings.pet')}
179
+ hint={t('settings.petHint')}
180
+ inheritLabel={t('settings.inherit')}
181
+ {...fieldProps}
182
+ {...state.petId}
183
+ choices={state.petChoices}
184
+ onEdit={(text) => { props.edit('petId', text) }}
185
+ onReset={() => { props.resetField('petId') }}
186
+ />
133
187
  <BooleanField
134
188
  id="settings-pet-visible"
135
189
  label={t('settings.visible')}
@@ -172,15 +226,6 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
172
226
  onEdit={(text) => { props.edit('bottom', text) }}
173
227
  onReset={() => { props.resetField('bottom') }}
174
228
  />
175
- <ValueField
176
- id="settings-pet-name"
177
- label={t('settings.name')}
178
- hint={t('settings.nameHint')}
179
- {...fieldProps}
180
- {...state.name}
181
- onEdit={(text) => { props.edit('name', text) }}
182
- onReset={() => { props.resetField('name') }}
183
- />
184
229
  </PluginSettingsCard>
185
230
  )
186
231
  }
@@ -1,14 +1,47 @@
1
1
  // @vitest-environment jsdom
2
2
  /**
3
- * WhalePet rename-box keyboard handling. The rename input must treat
3
+ * PetSprite rename-box keyboard handling. The rename input must treat
4
4
  * Enter/Escape keydowns that arrive during IME composition (candidate
5
5
  * selection) as composition input, never as submit/cancel (issue #89).
6
6
  */
7
7
  import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
8
8
  import { cleanup, fireEvent, render, screen } from '@testing-library/react'
9
- import { WhalePet, type WhalePetProps } from './WhalePet.tsx'
9
+ import { PetSprite, type PetSpriteProps } from './PetSprite.tsx'
10
10
  import { t } from './locales.ts'
11
11
  import type { PetStateView } from '../service.ts'
12
+ import type { PetDefinition, PetTrackDef } from '../registry.ts'
13
+ import type { PetAnimation } from '../state.ts'
14
+
15
+ /** A minimal pet definition (geometry + tracks) as served by the host. */
16
+ function petDefinition(): PetDefinition {
17
+ const track = (frames: number[], durations: number[], loop = true, fallback?: PetAnimation): PetTrackDef => ({
18
+ frames,
19
+ durations,
20
+ loop,
21
+ ...(fallback === undefined ? {} : { fallback }),
22
+ })
23
+ return {
24
+ id: 'whale-girl',
25
+ displayName: '鲸鱼娘',
26
+ description: '测试用鲸鱼娘',
27
+ cell: { width: 192, height: 208 },
28
+ columns: 8,
29
+ rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
30
+ tracks: {
31
+ idle: track([0, 1, 2, 3, 4, 5], [400, 400, 400, 400, 400, 400]),
32
+ 'running-right': track([0, 1, 2, 3, 4, 5, 6, 7], [225, 225, 225, 225, 225, 225, 225, 225]),
33
+ 'running-left': track([0, 1, 2, 3, 4, 5, 6, 7], [225, 225, 225, 225, 225, 225, 225, 225]),
34
+ waving: track([0, 1, 2, 3], [350, 350, 350, 350]),
35
+ jumping: track([0, 1, 2, 3, 4], [300, 300, 300, 300, 300], false, 'idle'),
36
+ failed: track([0, 1, 2, 3, 4, 5, 6, 7], [450, 450, 450, 450, 450, 450, 450, 450], false, 'idle'),
37
+ waiting: track([0, 1, 2, 3, 4, 5], [450, 450, 450, 450, 450, 450]),
38
+ running: track([0, 1, 2, 3, 4, 5], [250, 250, 250, 250, 250, 250]),
39
+ review: track([0, 1, 2, 3, 4, 5], [550, 550, 550, 550, 550, 550]),
40
+ },
41
+ atlasUrl: '/pet/whale-girl/spritesheet.webp',
42
+ manifestUrl: '/pet/whale-girl/pet.json',
43
+ }
44
+ }
12
45
 
13
46
  /** Snapshot fixture: idle whale girl named 泡泡. */
14
47
  const snapshot: PetStateView = {
@@ -26,6 +59,7 @@ const snapshot: PetStateView = {
26
59
  feedCooldown: false,
27
60
  },
28
61
  display: { visible: true, size: 160, right: 24, bottom: 20 },
62
+ pet: { id: 'whale-girl', displayName: '鲸鱼娘', description: '测试用鲸鱼娘' },
29
63
  name: '泡泡',
30
64
  treats: { stocked: 3, max: 5 },
31
65
  }
@@ -55,10 +89,11 @@ afterEach(() => {
55
89
  })
56
90
 
57
91
  /** Render the pet with mocked callbacks; returns the rename spy. */
58
- function renderPet(overrides: Partial<WhalePetProps> = {}): { onRename: ReturnType<typeof vi.fn> } {
92
+ function renderPet(overrides: Partial<PetSpriteProps> = {}): { onRename: ReturnType<typeof vi.fn> } {
59
93
  const onRename = vi.fn()
60
- const props: WhalePetProps = {
94
+ const props: PetSpriteProps = {
61
95
  snapshot,
96
+ definition: petDefinition(),
62
97
  display: snapshot.display,
63
98
  feedback: null,
64
99
  onPet: vi.fn(),
@@ -70,13 +105,13 @@ function renderPet(overrides: Partial<WhalePetProps> = {}): { onRename: ReturnTy
70
105
  t,
71
106
  ...overrides,
72
107
  }
73
- render(<WhalePet {...props} />)
108
+ render(<PetSprite {...props} />)
74
109
  return { onRename }
75
110
  }
76
111
 
77
112
  /** Hover the sprite to open the panel, then click the rename button. */
78
113
  function openRename(): HTMLInputElement {
79
- fireEvent.pointerOver(screen.getByRole('button', { name: 'whale girl' }))
114
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
80
115
  fireEvent.click(screen.getByText('改名'))
81
116
  return screen.getByPlaceholderText('输入新名字') as HTMLInputElement
82
117
  }
@@ -100,7 +135,7 @@ function fireComposingKeydown(target: Element, key: string): void {
100
135
  fireEvent.compositionEnd(target)
101
136
  }
102
137
 
103
- describe('WhalePet rename input', () => {
138
+ describe('PetSprite rename input', () => {
104
139
  it('submits the draft on Enter outside composition', () => {
105
140
  const { onRename } = renderPet()
106
141
  const input = openRename()
@@ -138,7 +173,7 @@ describe('WhalePet rename input', () => {
138
173
  })
139
174
  })
140
175
 
141
- describe('WhalePet status bubble', () => {
176
+ describe('PetSprite status bubble', () => {
142
177
  const workingSnapshot: PetStateView = {
143
178
  ...snapshot,
144
179
  animation: 'running',
@@ -160,3 +195,16 @@ describe('WhalePet status bubble', () => {
160
195
  expect(screen.queryByText('正在思考')).toBeNull()
161
196
  })
162
197
  })
198
+
199
+ describe('PetSprite definition-driven render', () => {
200
+ it('labels the sprite with the pet display name', () => {
201
+ renderPet()
202
+ expect(screen.queryByRole('button', { name: '鲸鱼娘' })).not.toBeNull()
203
+ })
204
+
205
+ it('shows the renamed snapshot name in the hover panel', () => {
206
+ renderPet()
207
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
208
+ expect(screen.queryByText('泡泡')).not.toBeNull()
209
+ })
210
+ })