@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.
- package/README.i18n.yaml +2 -2
- package/README.md +5 -4
- package/README.zh.md +5 -4
- package/icon.svg +3 -0
- package/lib/client.js +522 -194
- package/lib/client.js.map +1 -1
- package/lib/index.js +710 -426
- package/lib/live2d-vendor.js +96 -96
- package/lib/live2d-vendor.js.map +1 -1
- package/lib/types/announce.d.ts +6 -6
- package/lib/types/announce.d.ts.map +1 -1
- package/lib/types/announce.js +5 -5
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +1 -1
- package/lib/types/client/PetSettingsCard.d.ts +20 -9
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +116 -5
- package/lib/types/client/PetSprite.d.ts +6 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +95 -0
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/gameplay-hud.d.ts +10 -1
- package/lib/types/client/gameplay-hud.d.ts.map +1 -1
- package/lib/types/client/gameplay-hud.js +137 -23
- package/lib/types/client/index.d.ts +8 -5
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +61 -27
- package/lib/types/client/main-session.d.ts +33 -0
- package/lib/types/client/main-session.d.ts.map +1 -0
- package/lib/types/client/main-session.js +34 -0
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +5 -3
- package/lib/types/gameplay.d.ts +79 -5
- package/lib/types/gameplay.d.ts.map +1 -1
- package/lib/types/gameplay.js +212 -17
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +100 -29
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +113 -64
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +3 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +3 -1
- package/lib/types/service.d.ts +8 -3
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +26 -4
- package/package.json +46 -26
- package/src/announce.ts +6 -6
- package/src/client/PetDockEntry.tsx +1 -0
- package/src/client/PetSettingsCard.test.tsx +103 -0
- package/src/client/PetSettingsCard.tsx +129 -26
- package/src/client/PetSprite.tsx +96 -0
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/gameplay-hud.test.tsx +106 -8
- package/src/client/gameplay-hud.tsx +143 -28
- package/src/client/index.test.tsx +37 -4
- package/src/client/index.ts +74 -34
- package/src/client/main-session.ts +43 -0
- package/src/client/settings-form.ts +34 -31
- package/src/event-projection.ts +5 -3
- package/src/gameplay.test.ts +138 -1
- package/src/gameplay.ts +253 -16
- package/src/http.ts +23 -1
- package/src/index.test.ts +107 -11
- package/src/index.ts +162 -73
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +3 -2
- package/src/routes.ts +3 -1
- 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.
|
|
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.
|
|
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
|
-
"
|
|
61
|
-
"
|
|
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.
|
|
69
|
-
"@deepseek-ai/dsh-
|
|
70
|
-
"@deepseek-ai/dsh-api-
|
|
71
|
-
"@deepseek-ai/dsh-api-
|
|
72
|
-
"@deepseek-ai/dsh-
|
|
73
|
-
"@deepseek-ai/dsh-client-
|
|
74
|
-
"@deepseek-ai/dsh-client-
|
|
75
|
-
"@deepseek-ai/dsh-client-ui-
|
|
76
|
-
"@deepseek-ai/dsh-client-ui-
|
|
77
|
-
"@deepseek-ai/dsh-client-ui-
|
|
78
|
-
"@deepseek-ai/dsh-
|
|
79
|
-
"@deepseek-ai/dsh-
|
|
80
|
-
"@deepseek-ai/dsh-
|
|
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-
|
|
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
|
|
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 (
|
|
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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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,
|
|
3
|
-
* 'pet'
|
|
4
|
-
*
|
|
5
|
-
* as
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
|
116
|
-
constructor(scope:
|
|
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
|
-
...
|
|
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:
|
|
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
|
-
|
|
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
|
}
|