@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.
- package/README.i18n.yaml +2 -2
- package/README.md +4 -3
- package/README.zh.md +4 -3
- package/icon.svg +3 -0
- package/lib/client.js +522 -194
- package/lib/client.js.map +1 -1
- package/lib/index.js +706 -422
- package/lib/live2d-vendor.js +96 -96
- package/lib/live2d-vendor.js.map +1 -1
- 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/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
|
+
}
|
|
@@ -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
|
}
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -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}
|