@linxin666/dsh-pet 0.3.24 → 0.3.25

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 (72) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -3
  3. package/README.zh.md +4 -3
  4. package/icon.svg +3 -0
  5. package/lib/client.js +522 -194
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +706 -422
  8. package/lib/live2d-vendor.js +96 -96
  9. package/lib/live2d-vendor.js.map +1 -1
  10. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  11. package/lib/types/client/PetDockEntry.js +1 -1
  12. package/lib/types/client/PetSettingsCard.d.ts +20 -9
  13. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PetSettingsCard.js +116 -5
  15. package/lib/types/client/PetSprite.d.ts +6 -0
  16. package/lib/types/client/PetSprite.d.ts.map +1 -1
  17. package/lib/types/client/PetSprite.js +95 -0
  18. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  19. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PluginSettingsCard.js +3 -2
  21. package/lib/types/client/gameplay-hud.d.ts +10 -1
  22. package/lib/types/client/gameplay-hud.d.ts.map +1 -1
  23. package/lib/types/client/gameplay-hud.js +137 -23
  24. package/lib/types/client/index.d.ts +8 -5
  25. package/lib/types/client/index.d.ts.map +1 -1
  26. package/lib/types/client/index.js +61 -27
  27. package/lib/types/client/main-session.d.ts +33 -0
  28. package/lib/types/client/main-session.d.ts.map +1 -0
  29. package/lib/types/client/main-session.js +34 -0
  30. package/lib/types/client/settings-form.d.ts +17 -17
  31. package/lib/types/client/settings-form.d.ts.map +1 -1
  32. package/lib/types/client/settings-form.js +30 -27
  33. package/lib/types/event-projection.d.ts.map +1 -1
  34. package/lib/types/event-projection.js +5 -3
  35. package/lib/types/gameplay.d.ts +79 -5
  36. package/lib/types/gameplay.d.ts.map +1 -1
  37. package/lib/types/gameplay.js +212 -17
  38. package/lib/types/http.d.ts +18 -1
  39. package/lib/types/http.d.ts.map +1 -1
  40. package/lib/types/http.js +22 -1
  41. package/lib/types/index.d.ts +100 -29
  42. package/lib/types/index.d.ts.map +1 -1
  43. package/lib/types/index.js +113 -64
  44. package/lib/types/persist.d.ts.map +1 -1
  45. package/lib/types/persist.js +3 -1
  46. package/lib/types/routes.d.ts.map +1 -1
  47. package/lib/types/routes.js +3 -1
  48. package/lib/types/service.d.ts +8 -3
  49. package/lib/types/service.d.ts.map +1 -1
  50. package/lib/types/service.js +26 -4
  51. package/package.json +46 -26
  52. package/src/client/PetDockEntry.tsx +1 -0
  53. package/src/client/PetSettingsCard.test.tsx +103 -0
  54. package/src/client/PetSettingsCard.tsx +129 -26
  55. package/src/client/PetSprite.tsx +96 -0
  56. package/src/client/PluginSettingsCard.tsx +7 -2
  57. package/src/client/gameplay-hud.test.tsx +106 -8
  58. package/src/client/gameplay-hud.tsx +143 -28
  59. package/src/client/index.test.tsx +37 -4
  60. package/src/client/index.ts +74 -34
  61. package/src/client/main-session.ts +43 -0
  62. package/src/client/settings-form.ts +34 -31
  63. package/src/event-projection.ts +5 -3
  64. package/src/gameplay.test.ts +138 -1
  65. package/src/gameplay.ts +253 -16
  66. package/src/http.ts +23 -1
  67. package/src/index.test.ts +107 -11
  68. package/src/index.ts +162 -73
  69. package/src/persist.ts +3 -1
  70. package/src/registry.test.ts +3 -2
  71. package/src/routes.ts +3 -1
  72. package/src/service.ts +27 -5
package/package.json CHANGED
@@ -1,11 +1,23 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-pet",
3
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.3.24",
4
+ "version": "0.3.25",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "icon": "icon.svg",
5
9
  "type": "module",
10
+ "packageManager": "pnpm@11.7.0",
6
11
  "engines": {
7
12
  "node": "^22.19.0 || >=24.0.0"
8
13
  },
14
+ "scripts": {
15
+ "build": "tsc -b && tsdown",
16
+ "test": "vitest run",
17
+ "pet:cli": "node scripts/dsh-pet.cjs",
18
+ "typecheck": "tsc -b --pretty false && tsc -p tsconfig.test.json --pretty false",
19
+ "prepare": "tsdown --config tsdown.prepare.config.ts"
20
+ },
9
21
  "main": "lib/index.js",
10
22
  "types": "lib/types/index.d.ts",
11
23
  "exports": {
@@ -36,12 +48,13 @@
36
48
  "assets/ouo-neko",
37
49
  "assets/blue-throated-bee-eater",
38
50
  "assets/decorations",
39
- "contracts"
51
+ "contracts",
52
+ "icon.svg"
40
53
  ],
41
54
  "license": "Apache-2.0",
42
55
  "dsh": {
43
56
  "engines": {
44
- "dsh": ">=0.1.5-rc.1"
57
+ "dsh": ">=0.1.7-rc.1"
45
58
  },
46
59
  "bundle": {
47
60
  "patch": "./cordis.patch.yml"
@@ -51,39 +64,51 @@
51
64
  "@deepseek-ai/dsh-client-connection",
52
65
  "@deepseek-ai/dsh-client-ui-settings",
53
66
  "@deepseek-ai/dsh-client-ui-conversation",
54
- "@deepseek-ai/dsh-api-session-controller"
67
+ "@deepseek-ai/dsh-api-session-controller",
68
+ "@deepseek-ai/dsh-client-ui-workspace"
55
69
  ],
56
70
  "platform": "web"
57
71
  }
58
72
  },
59
73
  "dependencies": {
60
- "clsx": "^2.1.1",
61
- "schemastery": "^3.18.0"
74
+ "@deepseek-ai/schemastery": "^3.18.4",
75
+ "clsx": "^2.1.1"
62
76
  },
63
77
  "peerDependencies": {
78
+ "@deepseek-ai/dsh": ">=0.1.7-rc.1",
64
79
  "react": "^18.2.0",
65
80
  "react-dom": "^18.2.0"
66
81
  },
67
82
  "devDependencies": {
68
- "@deepseek-ai/cordis": "^4.0.2",
69
- "@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.1",
70
- "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.1",
71
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.5-rc.1",
72
- "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
73
- "@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
74
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.1",
75
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
76
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
77
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
78
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
79
- "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
80
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
83
+ "@deepseek-ai/cordis": "^4.0.4",
84
+ "@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
85
+ "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.1",
86
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.1",
87
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.1",
88
+ "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
89
+ "@deepseek-ai/dsh-client-store": "^0.1.7-rc.1",
90
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.7-rc.1",
91
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1",
92
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.1",
93
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
94
+ "@deepseek-ai/dsh-client-ui-workspace": "^0.1.7-rc.1",
95
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
96
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
97
+ "@deepseek-ai/dsh-scope": "^0.1.7-rc.1",
98
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.1",
99
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
100
+ "@deepseek-ai/dsh-subagent": "^0.1.7-rc.1",
101
+ "@deepseek-ai/dsh-timeout": "^0.1.7-rc.1",
102
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.1",
103
+ "@deepseek-ai/dsh-util-time": "^0.1.7-rc.1",
104
+ "@deepseek-ai/dsh-util-values": "^0.1.7-rc.1",
81
105
  "@testing-library/dom": "^10.4.1",
82
106
  "@testing-library/react": "^16.3.2",
83
107
  "@types/node": "^22.20.0",
84
108
  "@types/react": "~18.3.1",
85
109
  "@types/react-dom": "~18.3.0",
86
110
  "jsdom": "29.1.1",
111
+ "lightningcss": "^1.32.0",
87
112
  "pixi.js": "^8.19.0",
88
113
  "react": "^18.2.0",
89
114
  "react-dom": "^18.2.0",
@@ -95,11 +120,6 @@
95
120
  },
96
121
  "repository": {
97
122
  "type": "git",
98
- "url": "https://github.com/zhu1090093659/dsh-web.git"
99
- },
100
- "scripts": {
101
- "build": "tsc -b && tsdown",
102
- "test": "vitest run",
103
- "typecheck": "tsc -b --pretty false && tsc -p tsconfig.test.json --pretty false"
123
+ "url": "https://github.com/zhu1090093659/dsh-pet.git"
104
124
  }
105
- }
125
+ }
@@ -120,6 +120,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
120
120
  onFeedbackDone={props.feedbackDone}
121
121
  portalTarget={props.portalTarget}
122
122
  dragDisabled={snapshot.gameplay?.mode === 'work'}
123
+ {...(aux === null ? {} : { bus: aux.bus })}
123
124
  {...(gameplay === undefined || aux === null
124
125
  ? {}
125
126
  : {
@@ -0,0 +1,103 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
4
+ import { PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
5
+
6
+ vi.mock('@deepseek-ai/dsh-client-store', () => ({ // test-standards-allow: the SDK client bundle is a browser module factory and cannot be imported by Vitest
7
+ createSnapshotStore: (initial: unknown) => {
8
+ let value = initial
9
+ const listeners = new Set<() => void>()
10
+ return {
11
+ getSnapshot: () => value,
12
+ set: (next: unknown) => { value = next; for (const listener of listeners) listener() },
13
+ subscribe: (listener: () => void) => {
14
+ listeners.add(listener)
15
+ return () => { listeners.delete(listener) }
16
+ },
17
+ }
18
+ },
19
+ }))
20
+
21
+ function unavailableForm() {
22
+ const mutate = vi.fn(async () => false)
23
+ const scope = {
24
+ getSnapshot: () => ({ status: 'unavailable', writable: true, value: undefined, base: undefined, user: undefined }),
25
+ subscribe: () => () => {},
26
+ mutate,
27
+ } as unknown as ConfigForm<PetSettings>
28
+ return { scope, mutate }
29
+ }
30
+
31
+ function response(value: unknown, ok = true): Response {
32
+ return { ok, status: ok ? 200 : 500, json: async () => value } as Response
33
+ }
34
+
35
+ afterEach(() => {
36
+ vi.useRealTimers()
37
+ vi.unstubAllGlobals()
38
+ })
39
+
40
+ describe('pet selection without a Host settings form', () => {
41
+ it('user saves the selected pet through its persisted API and confirms the read-back', async () => {
42
+ // Given the aggregate shell exposes no pet configuration form
43
+ vi.useFakeTimers()
44
+ const { scope, mutate } = unavailableForm()
45
+ let selected = 'whale-girl'
46
+ const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
47
+ const path = String(input)
48
+ if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }, { id: 'jyn', displayName: '女仆鲸鱼娘' }])
49
+ if (path === '/api/pet/diagnostics') return response({ diagnostics: [] })
50
+ if (path === '/api/pet/state') return response({ pet: { id: selected } })
51
+ if (path === '/api/pet/set-pet') {
52
+ selected = JSON.parse(String(init?.body)).petId as string
53
+ return response({ ok: true, petId: selected })
54
+ }
55
+ throw new Error('unexpected request: ' + path)
56
+ })
57
+ vi.stubGlobal('fetch', fetch)
58
+ const controller = new PetSettingsCardController(scope)
59
+ await vi.advanceTimersByTimeAsync(0)
60
+ const face = controller.inject()
61
+ const state = () => face.hooks.petSettingsCard.getSnapshot()
62
+
63
+ // When the user chooses the maid whale and clicks Save
64
+ expect(state()).toMatchObject({ petSelectionFallback: true, petId: { text: 'whale-girl' }, dirty: false })
65
+ face.edit('petId', '')
66
+ expect(state()).toMatchObject({ dirty: false, petId: { text: 'whale-girl' } })
67
+ face.edit('petId', 'jyn')
68
+ expect(state()).toMatchObject({ dirty: true, petId: { text: 'jyn' } })
69
+ face.save()
70
+ await vi.waitFor(() => { expect(state().saving).toBe(false) })
71
+
72
+ // Then the pet service holds it, the draft clears, and the absent Host form is untouched
73
+ expect(selected).toBe('jyn')
74
+ expect(state()).toMatchObject({ dirty: false, failed: false, petId: { text: 'jyn' } })
75
+ expect(mutate).not.toHaveBeenCalled()
76
+ expect(fetch).toHaveBeenCalledWith('/api/pet/set-pet', expect.objectContaining({ body: JSON.stringify({ petId: 'jyn' }) }))
77
+ controller.dispose()
78
+ })
79
+
80
+ it('user keeps the draft when the pet API refuses the selection', async () => {
81
+ // Given the Host has no pet form and the pet API still serves its selection
82
+ vi.useFakeTimers()
83
+ const { scope } = unavailableForm()
84
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
85
+ const path = String(input)
86
+ if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }, { id: 'jyn', displayName: '女仆鲸鱼娘' }])
87
+ if (path === '/api/pet/diagnostics') return response({ diagnostics: [] })
88
+ if (path === '/api/pet/state') return response({ pet: { id: 'whale-girl' } })
89
+ if (path === '/api/pet/set-pet') return response({ ok: false, error: 'unknown-pet' })
90
+ throw new Error('unexpected request: ' + path)
91
+ }))
92
+ const controller = new PetSettingsCardController(scope)
93
+ await vi.advanceTimersByTimeAsync(0)
94
+ const face = controller.inject()
95
+ // When the user saves a selection the pet API refuses
96
+ face.edit('petId', 'jyn')
97
+ face.save()
98
+ await vi.waitFor(() => { expect(face.hooks.petSettingsCard.getSnapshot().saving).toBe(false) })
99
+ // Then the choice remains staged and the failure is visible
100
+ expect(face.hooks.petSettingsCard.getSnapshot()).toMatchObject({ dirty: true, failed: true, petId: { text: 'jyn' } })
101
+ controller.dispose()
102
+ })
103
+ })
@@ -1,15 +1,17 @@
1
1
  /**
2
- * The pet settings card: pet selection plus display layout, bound to the
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.
2
+ * The pet settings card: pet selection plus display layout, staged over the
3
+ * 'pet' profile entry's own configuration (a plugin's settings ARE its Cordis
4
+ * Config since 0.1.7, so the Host serves one form per profile entry).
5
+ * Rendered as an always-open first-level settings page; the section wrapper
6
+ * below mounts it as the content of the top-level 'settings.section' nav
7
+ * entry. The petId choices come from the registry endpoint ('/api/pet/pets') —
8
+ * the same list the sprite renders from — so the card carries no per-pet
9
+ * knowledge.
8
10
  */
9
11
 
10
12
  import type { ReactNode } from 'react'
11
13
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
12
- import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
14
+ import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
13
15
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
14
16
  // Type-only: pulls the settings-surface SlotMap merge (the 'settings.section' entry).
15
17
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
@@ -39,6 +41,8 @@ export interface PetSettings {
39
41
 
40
42
  /** What the pet settings card renders. */
41
43
  export interface PetSettingsCardState extends CardShell {
44
+ /** The aggregate shell has no Host settings form; pet selection uses its own persisted API. */
45
+ petSelectionFallback: boolean
42
46
  /** Plugin master switch. */
43
47
  enabled: CardFieldState
44
48
  /** Master switch. */
@@ -96,6 +100,15 @@ async function fetchPetDiagnostics(): Promise<PetDiagnosticView[]> {
96
100
  return body.diagnostics ?? []
97
101
  }
98
102
 
103
+ /** Read the selection from the same persisted state the pet renders. */
104
+ async function fetchSelectedPetId(): Promise<string> {
105
+ const response = await fetch('/api/pet/state')
106
+ if (!response.ok) throw new Error('pet state failed: ' + response.status)
107
+ const body = (await response.json()) as { pet?: { id?: unknown } }
108
+ if (typeof body.pet?.id !== 'string') throw new Error('pet state has no selected pet')
109
+ return body.pet.id
110
+ }
111
+
99
112
  /** Bridges the 'pet' scope onto the card's staged form. */
100
113
  export class PetSettingsCardController {
101
114
  private readonly form: CardForm<PetSettings>
@@ -106,14 +119,18 @@ export class PetSettingsCardController {
106
119
  private readonly petChoices: string[] = []
107
120
  private readonly petLabels = new Map<string, string>()
108
121
  private diagnostics: PetDiagnosticView[] = []
122
+ private selectedPetId: string | undefined
123
+ private stagedPetId: string | undefined
124
+ private savingPet = false
125
+ private petSaveFailed = false
109
126
  private loaded = false
110
127
  private attempts = 0
111
128
  private disposed = false
112
129
  /** Pending deferred-load or retry timer; cancelled by dispose(). */
113
130
  private pendingTimer: number | undefined
114
131
 
115
- /** @param scope - the bound settings scope for the 'pet' namespace. */
116
- constructor(scope: SettingsScope<PetSettings>) {
132
+ /** @param scope - the bound configuration form for the 'pet' entry. */
133
+ constructor(scope: ConfigForm<PetSettings>) {
117
134
  this.form = new CardForm(scope, [
118
135
  booleanField('enabled'),
119
136
  booleanField('decorationEnabled'),
@@ -134,6 +151,7 @@ export class PetSettingsCardController {
134
151
  if (this.disposed) return
135
152
  void this.loadPets()
136
153
  void this.loadDiagnostics()
154
+ void this.loadSelectedPet()
137
155
  }, 0)
138
156
  }
139
157
 
@@ -171,9 +189,65 @@ export class PetSettingsCardController {
171
189
  }
172
190
  }
173
191
 
192
+ private async loadSelectedPet(): Promise<void> {
193
+ try {
194
+ const petId = await fetchSelectedPetId()
195
+ if (this.disposed) return
196
+ this.selectedPetId = petId
197
+ this.store.set(this.projection())
198
+ } catch {
199
+ // The Host form remains the authority when it is available. An absent
200
+ // pet API leaves the fallback unavailable rather than inventing state.
201
+ }
202
+ }
203
+
204
+ private fallback(): boolean {
205
+ const shell = this.form.shell()
206
+ return shell.available && !shell.exposed && this.selectedPetId !== undefined
207
+ }
208
+
209
+ private async savePetSelection(): Promise<void> {
210
+ const petId = this.stagedPetId
211
+ if (petId === undefined || this.savingPet || !this.petChoices.includes(petId)) return
212
+ this.savingPet = true
213
+ this.petSaveFailed = false
214
+ this.store.set(this.projection())
215
+ try {
216
+ const response = await fetch('/api/pet/set-pet', {
217
+ method: 'POST',
218
+ headers: { 'content-type': 'application/json' },
219
+ body: JSON.stringify({ petId }),
220
+ })
221
+ const result = (await response.json()) as { ok?: boolean; petId?: string }
222
+ if (!response.ok || result.ok !== true || result.petId !== petId || await fetchSelectedPetId() !== petId) {
223
+ throw new Error('pet selection was not persisted')
224
+ }
225
+ this.selectedPetId = petId
226
+ if (this.stagedPetId === petId) this.stagedPetId = undefined
227
+ } catch {
228
+ this.petSaveFailed = true
229
+ } finally {
230
+ this.savingPet = false
231
+ if (!this.disposed) this.store.set(this.projection())
232
+ }
233
+ }
234
+
174
235
  private projection(): PetSettingsCardState {
236
+ const fallback = this.fallback()
237
+ const shell = this.form.shell()
238
+ const configuredPet = this.form.field('petId')
175
239
  return {
176
- ...this.form.shell(),
240
+ ...shell,
241
+ ...(fallback ? {
242
+ exposed: true,
243
+ writable: true,
244
+ dirty: this.stagedPetId !== undefined && this.stagedPetId !== this.selectedPetId,
245
+ invalid: this.stagedPetId !== undefined && !this.petChoices.includes(this.stagedPetId),
246
+ saving: this.savingPet,
247
+ failed: this.petSaveFailed,
248
+ failedReason: undefined,
249
+ } : {}),
250
+ petSelectionFallback: fallback,
177
251
  enabled: this.form.field('enabled'),
178
252
  decorationEnabled: this.form.field('decorationEnabled'),
179
253
  visible: this.form.field('visible'),
@@ -181,7 +255,11 @@ export class PetSettingsCardController {
181
255
  right: this.form.field('right'),
182
256
  bottom: this.form.field('bottom'),
183
257
  bubbleScale: this.form.field('bubbleScale'),
184
- petId: this.form.field('petId'),
258
+ petId: fallback
259
+ ? { text: this.stagedPetId ?? this.selectedPetId ?? '', overridden: false, invalid: this.stagedPetId !== undefined && !this.petChoices.includes(this.stagedPetId) }
260
+ : configuredPet.text === '' && this.selectedPetId !== undefined
261
+ ? { ...configuredPet, text: this.selectedPetId }
262
+ : configuredPet,
185
263
  petChoices: this.petChoices.map(id => ({ value: id, label: this.petLabels.get(id) ?? id })),
186
264
  petDiagnostics: this.diagnostics,
187
265
  }
@@ -192,7 +270,31 @@ export class PetSettingsCardController {
192
270
  * @returns the card's snapshot and its form actions.
193
271
  */
194
272
  inject(): PetSettingsCardFace {
195
- return { hooks: { petSettingsCard: this.store }, ...this.form.actions() }
273
+ const actions = this.form.actions()
274
+ return {
275
+ hooks: { petSettingsCard: this.store },
276
+ edit: (field, value) => {
277
+ if (!this.fallback()) return actions.edit(field, value)
278
+ if (field !== 'petId') return
279
+ this.stagedPetId = value === '' ? undefined : value
280
+ this.petSaveFailed = false
281
+ this.store.set(this.projection())
282
+ },
283
+ resetField: (field) => {
284
+ if (!this.fallback()) return actions.resetField(field)
285
+ if (field !== 'petId') return
286
+ this.stagedPetId = undefined
287
+ this.petSaveFailed = false
288
+ this.store.set(this.projection())
289
+ },
290
+ save: () => { if (this.fallback()) void this.savePetSelection(); else actions.save() },
291
+ discard: () => {
292
+ if (!this.fallback()) return actions.discard()
293
+ this.stagedPetId = undefined
294
+ this.petSaveFailed = false
295
+ this.store.set(this.projection())
296
+ },
297
+ }
196
298
  }
197
299
 
198
300
  /**
@@ -235,12 +337,13 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
235
337
  t={t}
236
338
  titleKey="settings.title"
237
339
  descriptionKey="settings.description"
340
+ descriptionNode={state.petSelectionFallback ? t('settings.petHint') : undefined}
238
341
  state={state}
239
342
  onSave={props.save}
240
343
  onDiscard={props.discard}
241
344
  alwaysOpen
242
345
  >
243
- <BooleanField
346
+ {state.petSelectionFallback ? null : <BooleanField
244
347
  id="settings-pet-enabled"
245
348
  label={t('settings.enabled')}
246
349
  hint={t('settings.enabledHint')}
@@ -251,8 +354,8 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
251
354
  {...state.enabled}
252
355
  onEdit={(text) => { props.edit('enabled', text) }}
253
356
  onReset={() => { props.resetField('enabled') }}
254
- />
255
- <BooleanField
357
+ />}
358
+ {state.petSelectionFallback ? null : <BooleanField
256
359
  id="settings-pet-decoration"
257
360
  label={t('settings.decoration')}
258
361
  hint={t('settings.decorationHint')}
@@ -263,7 +366,7 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
263
366
  {...state.decorationEnabled}
264
367
  onEdit={(text) => { props.edit('decorationEnabled', text) }}
265
368
  onReset={() => { props.resetField('decorationEnabled') }}
266
- />
369
+ />}
267
370
  <ChoiceField
268
371
  id="settings-pet-pet"
269
372
  label={t('settings.pet')}
@@ -285,7 +388,7 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
285
388
  </ul>
286
389
  </li>
287
390
  )}
288
- <BooleanField
391
+ {state.petSelectionFallback ? null : <BooleanField
289
392
  id="settings-pet-visible"
290
393
  label={t('settings.visible')}
291
394
  hint={t('settings.visibleHint')}
@@ -296,8 +399,8 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
296
399
  {...state.visible}
297
400
  onEdit={(text) => { props.edit('visible', text) }}
298
401
  onReset={() => { props.resetField('visible') }}
299
- />
300
- <ValueField
402
+ />}
403
+ {state.petSelectionFallback ? null : <ValueField
301
404
  id="settings-pet-size"
302
405
  label={t('settings.size')}
303
406
  hint={t('settings.sizeHint')}
@@ -306,8 +409,8 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
306
409
  {...state.size}
307
410
  onEdit={(text) => { props.edit('size', text) }}
308
411
  onReset={() => { props.resetField('size') }}
309
- />
310
- <ValueField
412
+ />}
413
+ {state.petSelectionFallback ? null : <ValueField
311
414
  id="settings-pet-right"
312
415
  label={t('settings.right')}
313
416
  hint={t('settings.rightHint')}
@@ -316,8 +419,8 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
316
419
  {...state.right}
317
420
  onEdit={(text) => { props.edit('right', text) }}
318
421
  onReset={() => { props.resetField('right') }}
319
- />
320
- <ValueField
422
+ />}
423
+ {state.petSelectionFallback ? null : <ValueField
321
424
  id="settings-pet-bottom"
322
425
  label={t('settings.bottom')}
323
426
  hint={t('settings.bottomHint')}
@@ -326,8 +429,8 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
326
429
  {...state.bottom}
327
430
  onEdit={(text) => { props.edit('bottom', text) }}
328
431
  onReset={() => { props.resetField('bottom') }}
329
- />
330
- <ValueField
432
+ />}
433
+ {state.petSelectionFallback ? null : <ValueField
331
434
  id="settings-pet-bubble-scale"
332
435
  label={t('settings.bubbleScale')}
333
436
  hint={t('settings.bubbleScaleHint')}
@@ -336,7 +439,7 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
336
439
  {...state.bubbleScale}
337
440
  onEdit={(text) => { props.edit('bubbleScale', text) }}
338
441
  onReset={() => { props.resetField('bubbleScale') }}
339
- />
442
+ />}
340
443
  </PluginSettingsCard>
341
444
  )
342
445
  }
@@ -18,6 +18,8 @@ import { bubbleScaleFor, type PetDisplayConfig } from '../persist.ts'
18
18
  import type { PetStateView } from '../service.ts'
19
19
  import { announcementFresh, type PetAnnouncement } from '../announce.ts'
20
20
  import type { PetDefinition } from '../registry.ts'
21
+ import type { GameplayBus } from './gameplay-hud.ts'
22
+ import type { PetRoamDirection } from '../gameplay.ts'
21
23
  import type { DecorationView } from '../contracts/status-decoration.ts'
22
24
  import type { PetFeedback } from './pet-store.ts'
23
25
  import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
@@ -79,6 +81,11 @@ export interface PetSpriteProps {
79
81
  onGameplayMenu?: () => void
80
82
  /** Disable the drag gesture (gameplay work mode blocks dragging). */
81
83
  dragDisabled?: boolean
84
+ /**
85
+ * Per-pet coordination bus. The chrome registers `walk` here so the gameplay
86
+ * HUD can roam the pet (the same two-way shape the HUD's `tap` uses).
87
+ */
88
+ bus?: GameplayBus
82
89
  /**
83
90
  * DOM node the floating chrome portals into. Defaults to document.body
84
91
  * (the legacy behavior); the plugin apply passes its owning root (the
@@ -482,6 +489,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
482
489
 
483
490
  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
484
491
  if (props.dragDisabled === true) return
492
+ endWalk(false)
485
493
  e.preventDefault()
486
494
  ;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
487
495
  const current = dragPos ?? { right: display.right, bottom: display.bottom }
@@ -512,6 +520,93 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
512
520
  const pos = dragPos ?? { right: display.right, bottom: display.bottom }
513
521
  const spriteWidth = Math.round(cell.width * spriteScale)
514
522
  const spriteHeight = Math.round(cell.height * spriteScale)
523
+
524
+ // --- roaming ---------------------------------------------------------
525
+ // The gameplay HUD rolls when the pet may wander; this side owns the motion
526
+ // (clamped to the viewport), the walk art's facing and the persisted resting
527
+ // spot -- the walk ends on the same write a drag ends with. The walk art
528
+ // faces left, so a rightward walk mirrors the sprite (a vertical walk keeps
529
+ // the side view); every other track stays unmirrored.
530
+ const [facingRight, setFacingRight] = useState(false)
531
+ const walkRafRef = useRef(0)
532
+ const walkTargetRef = useRef<{ right: number; bottom: number } | null>(null)
533
+ const dragPosRef = useRef(dragPos)
534
+ dragPosRef.current = dragPos
535
+
536
+ /** Stop an active walk; `persist` lands the pet on the spot it reached. */
537
+ const endWalk = (persist: boolean): void => {
538
+ if (walkRafRef.current === 0) return
539
+ window.cancelAnimationFrame(walkRafRef.current)
540
+ walkRafRef.current = 0
541
+ setFacingRight(false)
542
+ const settled = walkTargetRef.current
543
+ walkTargetRef.current = null
544
+ if (persist && settled !== null) props.onDragEnd(settled.right, settled.bottom)
545
+ }
546
+
547
+ useEffect(() => {
548
+ const bus = props.bus
549
+ if (bus === undefined) return undefined
550
+ bus.walk = (direction: PetRoamDirection, distance: number, speed: number): number => {
551
+ if (dragRef.current !== null || walkRafRef.current !== 0) return 0
552
+ const current = dragPosRef.current ?? { right: display.right, bottom: display.bottom }
553
+ // Keep the whole sprite on screen with an 8px breathing margin. `right`
554
+ // and `bottom` are CSS offsets: right grows leftwards, bottom upwards.
555
+ const margin = 8
556
+ const maxRight = Math.max(margin, window.innerWidth - spriteWidth - margin)
557
+ const maxBottom = Math.max(margin, window.innerHeight - spriteHeight - margin)
558
+ const wanted = { ...current }
559
+ if (direction === 'left') wanted.right = current.right + distance
560
+ else if (direction === 'right') wanted.right = current.right - distance
561
+ else if (direction === 'up') wanted.bottom = current.bottom + distance
562
+ else wanted.bottom = current.bottom - distance
563
+ const target = {
564
+ right: Math.max(margin, clampOffset(wanted.right, maxRight)),
565
+ bottom: Math.max(margin, clampOffset(wanted.bottom, maxBottom)),
566
+ }
567
+ const travelled = direction === 'left' ? target.right - current.right
568
+ : direction === 'right' ? current.right - target.right
569
+ : direction === 'up' ? target.bottom - current.bottom
570
+ : current.bottom - target.bottom
571
+ if (travelled < 1) return 0
572
+ const duration = Math.max(150, (travelled / Math.max(1, speed)) * 1000)
573
+ const startedAt = performance.now()
574
+ // The crawl art faces left, so only a rightward walk mirrors it; a
575
+ // vertical walk keeps the side view (there is no front/back art).
576
+ setFacingRight(direction === 'right')
577
+ walkTargetRef.current = current
578
+ const step = (now: number): void => {
579
+ const t = Math.min(1, (now - startedAt) / duration)
580
+ const next = {
581
+ right: current.right + (target.right - current.right) * t,
582
+ bottom: current.bottom + (target.bottom - current.bottom) * t,
583
+ }
584
+ walkTargetRef.current = next
585
+ setDragPos(next)
586
+ if (t < 1) {
587
+ walkRafRef.current = window.requestAnimationFrame(step)
588
+ return
589
+ }
590
+ walkRafRef.current = 0
591
+ walkTargetRef.current = null
592
+ setFacingRight(false)
593
+ props.onDragEnd(next.right, next.bottom)
594
+ }
595
+ walkRafRef.current = window.requestAnimationFrame(step)
596
+ return travelled
597
+ }
598
+ return () => {
599
+ bus.walk = undefined
600
+ if (walkRafRef.current !== 0) {
601
+ window.cancelAnimationFrame(walkRafRef.current)
602
+ walkRafRef.current = 0
603
+ }
604
+ // A cancelled walk must not leave the sprite mirrored.
605
+ setFacingRight(false)
606
+ walkTargetRef.current = null
607
+ }
608
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one registration per sprite
609
+ }, [props.bus, definition.id, display.right, display.bottom, spriteWidth])
515
610
  // Bubble typography follows the sprite's own scale (#1549), bounded so a
516
611
  // shrunk pet never carries unreadably small text.
517
612
  const bubbleScale = bubbleScaleFor(display)
@@ -630,6 +725,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
630
725
  }
631
726
  : {}),
632
727
  cursor: dragRef.current === null ? 'grab' : 'grabbing',
728
+ ...(facingRight ? { transform: 'scaleX(-1)' } : {}),
633
729
  }}
634
730
  onPointerDown={onPointerDown}
635
731
  onPointerMove={onPointerMove}