@linxin666/dsh-pet 0.3.23 → 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 (76) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -4
  3. package/README.zh.md +5 -4
  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 +710 -426
  8. package/lib/live2d-vendor.js +96 -96
  9. package/lib/live2d-vendor.js.map +1 -1
  10. package/lib/types/announce.d.ts +6 -6
  11. package/lib/types/announce.d.ts.map +1 -1
  12. package/lib/types/announce.js +5 -5
  13. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  14. package/lib/types/client/PetDockEntry.js +1 -1
  15. package/lib/types/client/PetSettingsCard.d.ts +20 -9
  16. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/PetSettingsCard.js +116 -5
  18. package/lib/types/client/PetSprite.d.ts +6 -0
  19. package/lib/types/client/PetSprite.d.ts.map +1 -1
  20. package/lib/types/client/PetSprite.js +95 -0
  21. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  22. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  23. package/lib/types/client/PluginSettingsCard.js +3 -2
  24. package/lib/types/client/gameplay-hud.d.ts +10 -1
  25. package/lib/types/client/gameplay-hud.d.ts.map +1 -1
  26. package/lib/types/client/gameplay-hud.js +137 -23
  27. package/lib/types/client/index.d.ts +8 -5
  28. package/lib/types/client/index.d.ts.map +1 -1
  29. package/lib/types/client/index.js +61 -27
  30. package/lib/types/client/main-session.d.ts +33 -0
  31. package/lib/types/client/main-session.d.ts.map +1 -0
  32. package/lib/types/client/main-session.js +34 -0
  33. package/lib/types/client/settings-form.d.ts +17 -17
  34. package/lib/types/client/settings-form.d.ts.map +1 -1
  35. package/lib/types/client/settings-form.js +30 -27
  36. package/lib/types/event-projection.d.ts.map +1 -1
  37. package/lib/types/event-projection.js +5 -3
  38. package/lib/types/gameplay.d.ts +79 -5
  39. package/lib/types/gameplay.d.ts.map +1 -1
  40. package/lib/types/gameplay.js +212 -17
  41. package/lib/types/http.d.ts +18 -1
  42. package/lib/types/http.d.ts.map +1 -1
  43. package/lib/types/http.js +22 -1
  44. package/lib/types/index.d.ts +100 -29
  45. package/lib/types/index.d.ts.map +1 -1
  46. package/lib/types/index.js +113 -64
  47. package/lib/types/persist.d.ts.map +1 -1
  48. package/lib/types/persist.js +3 -1
  49. package/lib/types/routes.d.ts.map +1 -1
  50. package/lib/types/routes.js +3 -1
  51. package/lib/types/service.d.ts +8 -3
  52. package/lib/types/service.d.ts.map +1 -1
  53. package/lib/types/service.js +26 -4
  54. package/package.json +46 -26
  55. package/src/announce.ts +6 -6
  56. package/src/client/PetDockEntry.tsx +1 -0
  57. package/src/client/PetSettingsCard.test.tsx +103 -0
  58. package/src/client/PetSettingsCard.tsx +129 -26
  59. package/src/client/PetSprite.tsx +96 -0
  60. package/src/client/PluginSettingsCard.tsx +7 -2
  61. package/src/client/gameplay-hud.test.tsx +106 -8
  62. package/src/client/gameplay-hud.tsx +143 -28
  63. package/src/client/index.test.tsx +37 -4
  64. package/src/client/index.ts +74 -34
  65. package/src/client/main-session.ts +43 -0
  66. package/src/client/settings-form.ts +34 -31
  67. package/src/event-projection.ts +5 -3
  68. package/src/gameplay.test.ts +138 -1
  69. package/src/gameplay.ts +253 -16
  70. package/src/http.ts +23 -1
  71. package/src/index.test.ts +107 -11
  72. package/src/index.ts +162 -73
  73. package/src/persist.ts +3 -1
  74. package/src/registry.test.ts +3 -2
  75. package/src/routes.ts +3 -1
  76. 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.23",
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
+ }
package/src/announce.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The pet announcement-bubble contract (dsh-usage linkage): a sibling plugin
2
+ * The pet announcement-bubble contract: a sibling plugin
3
3
  * pushes one structured announcement through `pet.announce(...)`, the host
4
4
  * validates it into a bounded payload, and the browser half renders it as a
5
5
  * dedicated, specially styled bubble above the session bubble stack.
@@ -11,7 +11,7 @@
11
11
 
12
12
  /** One plugin-authored announcement bubble. */
13
13
  export interface PetAnnouncement {
14
- /** Authoring plugin's source tag (`dsh-usage` for the usage statistics). */
14
+ /** Authoring plugin's source tag (its own package short name). */
15
15
  source: string
16
16
  /** Bubble content kind: a spend estimate, an account balance, or a plan-quota status. */
17
17
  kind: 'balance' | 'cost' | 'plan'
@@ -37,10 +37,10 @@ export interface PetAnnouncement {
37
37
  export const ANNOUNCE_DEFAULT_TTL_MS = 10_000
38
38
 
39
39
  /**
40
- * Hard TTL ceiling. A repeating announcer (dsh-usage) declares its poll
41
- * cadence as the TTL, so an `always`-mode bubble stays continuous across
42
- * polls; the ceiling means a source that dies unmounts its bubble within at
43
- * most one missed refresh cycle rather than lingering forever.
40
+ * Hard TTL ceiling. A repeating announcer declares its poll cadence as
41
+ * the TTL, so an always-on bubble stays continuous across polls; the
42
+ * ceiling means a source that dies unmounts its bubble within at most one
43
+ * missed refresh cycle rather than lingering forever.
44
44
  */
45
45
  export const ANNOUNCE_MAX_TTL_MS = 7_200_000
46
46
 
@@ -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
  }