@linxin666/dsh-pet 0.1.15 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +73 -38
- package/README.zh.md +91 -55
- package/assets/whale/pet.json +12 -1
- package/cordis.patch.yml +4 -4
- package/lib/client.js +270 -334
- package/lib/client.js.map +1 -1
- package/lib/index.js +641 -87
- package/lib/types/client/PetDockEntry.d.ts +14 -12
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +13 -8
- package/lib/types/client/PetSettingsCard.d.ts +22 -9
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +42 -7
- package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
- package/lib/types/client/PetSprite.d.ts.map +1 -0
- package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -1
- package/lib/types/client/index.d.ts +11 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +30 -12
- package/lib/types/client/locales.d.ts +10 -10
- package/lib/types/client/locales.js +10 -10
- package/lib/types/client/pet-store.d.ts +5 -0
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/settings-form.d.ts +62 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +61 -12
- package/lib/types/client/spritesheet.d.ts +14 -48
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +13 -81
- package/lib/types/index.d.ts +24 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +53 -33
- package/lib/types/ledger.d.ts +4 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +11 -4
- package/lib/types/persist.d.ts +10 -3
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +32 -5
- package/lib/types/registry.d.ts +158 -0
- package/lib/types/registry.d.ts.map +1 -0
- package/lib/types/registry.js +276 -0
- package/lib/types/routes.d.ts +10 -10
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +169 -51
- package/lib/types/service.d.ts +46 -13
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +73 -16
- package/package.json +2 -2
- package/src/client/PetDockEntry.tsx +34 -26
- package/src/client/PetSettingsCard.tsx +67 -22
- package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
- package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
- package/src/client/PluginSettingsCard.tsx +7 -1
- package/src/client/index.ts +36 -14
- package/src/client/locales.ts +10 -10
- package/src/client/pet-store.ts +9 -0
- package/src/client/settings-card.module.css +3 -0
- package/src/client/settings-form.ts +114 -12
- package/src/client/spritesheet.ts +16 -92
- package/src/index.test.ts +23 -0
- package/src/index.ts +89 -45
- package/src/ledger.test.ts +5 -3
- package/src/ledger.ts +11 -4
- package/src/persist.test.ts +52 -3
- package/src/persist.ts +42 -8
- package/src/registry.test.ts +132 -0
- package/src/registry.ts +400 -0
- package/src/routes.ts +166 -53
- package/src/service.ts +102 -20
- package/lib/types/client/WhalePet.d.ts.map +0 -1
|
@@ -138,7 +138,13 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
138
138
|
{!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
|
|
139
139
|
{props.children}
|
|
140
140
|
<div className={css.footer}>
|
|
141
|
-
{state.failed
|
|
141
|
+
{state.failed
|
|
142
|
+
? (
|
|
143
|
+
<p className={css.failed} role="status">
|
|
144
|
+
{props.t('settings.saveFailed')}{state.failedReason ? ' - ' + state.failedReason : ''}
|
|
145
|
+
</p>
|
|
146
|
+
)
|
|
147
|
+
: null}
|
|
142
148
|
<button
|
|
143
149
|
type="button"
|
|
144
150
|
className={css.discard}
|
package/src/client/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-pet browser half — mounts the
|
|
3
|
-
* and drives it from the host's same-origin
|
|
4
|
-
* the host snapshot (~2 s),
|
|
5
|
-
* The pet is host-global (no
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* dsh-pet browser half — mounts the selected pet as a global floating
|
|
3
|
+
* surface and drives it from the host's same-origin '/api/pet/*' JSON
|
|
4
|
+
* endpoints: fetch the registry list once, poll the host snapshot (~2 s),
|
|
5
|
+
* forward interactions, persist drag positions. The pet is host-global (no
|
|
6
|
+
* session dimension), so it mounts directly onto 'document.body' via a
|
|
7
|
+
* single React root rather than a session-scoped slot — on the
|
|
8
|
+
* new-conversation screen no session exists, and a dock-mounted pet would
|
|
9
|
+
* vanish there (issue #48). When the pet is hidden the entry becomes a
|
|
9
10
|
* fixed-position summon button.
|
|
10
11
|
* @module @linxin666/dsh-pet/client
|
|
11
12
|
*/
|
|
@@ -20,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
20
21
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
21
22
|
import type { PetInteractResult, PetStateView } from '../service.ts'
|
|
22
23
|
import type { PetInteraction } from '../affinity.ts'
|
|
24
|
+
import type { PetDefinition } from '../registry.ts'
|
|
23
25
|
import { createElement } from 'react'
|
|
24
26
|
import { createRoot } from 'react-dom/client'
|
|
25
27
|
import { createPetStore, type PetStoreInstance } from './pet-store.ts'
|
|
@@ -30,10 +32,12 @@ import { NS, en, zh, t } from './locales.ts'
|
|
|
30
32
|
/** The host pet API as the browser sees it (same-origin JSON endpoints). */
|
|
31
33
|
interface PetHttpApi {
|
|
32
34
|
state(): Promise<PetStateView>
|
|
35
|
+
pets(): Promise<PetDefinition[]>
|
|
33
36
|
interact(kind: PetInteraction): Promise<PetInteractResult>
|
|
34
37
|
setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }>
|
|
35
38
|
setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }>
|
|
36
39
|
setName(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }>
|
|
40
|
+
setPet(petId: string): Promise<{ ok: true; petId: string } | { ok: false; error: string }>
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/** Same-origin JSON fetch helper (GET without body, POST with JSON body). */
|
|
@@ -46,7 +50,7 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
|
|
|
46
50
|
body: JSON.stringify(body),
|
|
47
51
|
})
|
|
48
52
|
if (!response.ok) {
|
|
49
|
-
throw new Error(
|
|
53
|
+
throw new Error('pet ' + path + ' failed: ' + response.status)
|
|
50
54
|
}
|
|
51
55
|
return (await response.json()) as T
|
|
52
56
|
}
|
|
@@ -54,10 +58,12 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
|
|
|
54
58
|
/** The live host API instance (always defined; failures surface per call). */
|
|
55
59
|
const petApi: PetHttpApi = {
|
|
56
60
|
state: () => petFetch('/api/pet/state'),
|
|
61
|
+
pets: () => petFetch('/api/pet/pets'),
|
|
57
62
|
interact: (kind) => petFetch('/api/pet/interact', { kind }),
|
|
58
63
|
setVisible: (visible) => petFetch('/api/pet/set-visible', { visible }),
|
|
59
64
|
setConfig: (patch) => petFetch('/api/pet/set-config', patch),
|
|
60
65
|
setName: (name) => petFetch('/api/pet/set-name', { name }),
|
|
66
|
+
setPet: (petId) => petFetch('/api/pet/set-pet', { petId }),
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
/** Poll interval for the host snapshot. */
|
|
@@ -71,14 +77,16 @@ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote
|
|
|
71
77
|
|
|
72
78
|
/** Re-exported for consumers that type against the injected face. */
|
|
73
79
|
export type { PetInjected, PetDockEntryProps } from './PetDockEntry.tsx'
|
|
80
|
+
export type { PetSpriteProps } from './PetSprite.tsx'
|
|
74
81
|
export type { PetUiState, PetFeedback } from './pet-store.ts'
|
|
75
82
|
export type { PetSettingsCardFace, PetSettingsCardState } from './PetSettingsCard.tsx'
|
|
83
|
+
export type { PetDefinition } from '../registry.ts'
|
|
76
84
|
|
|
77
85
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
78
86
|
interface SlotMap {
|
|
79
87
|
/**
|
|
80
88
|
* The child slot the Web UI plugin group declares; this card registers
|
|
81
|
-
* into the group instead of the top-level
|
|
89
|
+
* into the group instead of the top-level 'settings.plugin.item' list.
|
|
82
90
|
* Spelled here with the same shape so this package can register without
|
|
83
91
|
* depending on the sibling UI package.
|
|
84
92
|
*/
|
|
@@ -103,7 +111,6 @@ declare module '@deepseek-ai/cordis' {
|
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
|
|
107
114
|
/**
|
|
108
115
|
* Client plugin body: register dictionaries, mount the global pet entry and
|
|
109
116
|
* poll loop while the plugin is enabled, and seat the settings card in the
|
|
@@ -122,8 +129,9 @@ export function apply(ctx: ClientContext): void {
|
|
|
122
129
|
: snapshot.status === 'unavailable'
|
|
123
130
|
}
|
|
124
131
|
|
|
125
|
-
// Plugin configuration card: one staged form over the
|
|
126
|
-
// namespace, contributed to the Web UI plugin group.
|
|
132
|
+
// Plugin configuration card: one staged form over the 'pet' settings
|
|
133
|
+
// namespace, contributed to the Web UI plugin group. The controller loads
|
|
134
|
+
// the petId choices from the registry endpoint itself.
|
|
127
135
|
const petSettings = new PetSettingsCardController(settingsScope)
|
|
128
136
|
ctx.slots.inject('web-ui.plugin.item', () => ctx.slots.register({
|
|
129
137
|
name: 'web-ui.plugin.item',
|
|
@@ -145,10 +153,24 @@ export function apply(ctx: ClientContext): void {
|
|
|
145
153
|
// it stateless on the new-conversation screen (no session to scope by).
|
|
146
154
|
const petStore: PetStoreInstance = createPetStore().create()
|
|
147
155
|
const setSnapshot = petStore.actions.setSnapshot
|
|
156
|
+
const setPets = petStore.actions.setPets
|
|
148
157
|
const setState = petStore.actions.setState
|
|
149
158
|
const setFeedback = petStore.actions.setFeedback
|
|
150
159
|
|
|
160
|
+
// The registry list is fetched lazily with retries baked into the poll
|
|
161
|
+
// cycle: until it lands, the dock entry renders nothing and every 2s
|
|
162
|
+
// tick tries again. After it lands, one list feeds both the sprite and
|
|
163
|
+
// the settings card's choices.
|
|
164
|
+
let petsLoaded = false
|
|
151
165
|
const pollNow = (): void => {
|
|
166
|
+
if (!petsLoaded) {
|
|
167
|
+
petApi.pets().then((list) => {
|
|
168
|
+
petsLoaded = true
|
|
169
|
+
setPets(list)
|
|
170
|
+
}, () => {
|
|
171
|
+
// Retry on the next poll tick.
|
|
172
|
+
})
|
|
173
|
+
}
|
|
152
174
|
petApi.state().then((snapshot) => {
|
|
153
175
|
setSnapshot(snapshot)
|
|
154
176
|
}, () => {
|
|
@@ -253,8 +275,8 @@ export function apply(ctx: ClientContext): void {
|
|
|
253
275
|
// for a global floating surface — the dock is session-scoped, so a pet
|
|
254
276
|
// mounted there would vanish on the new-conversation screen (issue #48).
|
|
255
277
|
// The entry therefore mounts straight onto document.body via a single
|
|
256
|
-
// React root for the page lifetime:
|
|
257
|
-
// visible, and the hidden-state summon button is fixed-positioned.
|
|
278
|
+
// React root for the page lifetime: PetSprite portals itself to body
|
|
279
|
+
// when visible, and the hidden-state summon button is fixed-positioned.
|
|
258
280
|
const container = document.createElement('div')
|
|
259
281
|
container.dataset.dshPetRoot = ''
|
|
260
282
|
document.body.appendChild(container)
|
package/src/client/locales.ts
CHANGED
|
@@ -17,11 +17,13 @@ export const zh = {
|
|
|
17
17
|
'pet.rank': '亲密度 {rank}',
|
|
18
18
|
'pet.points': '{points} 点',
|
|
19
19
|
'pet.treats': '小鱼干 ×{n}',
|
|
20
|
-
'pet.state.loading': '
|
|
21
|
-
'pet.state.error': '
|
|
20
|
+
'pet.state.loading': '宠物正在赶来…',
|
|
21
|
+
'pet.state.error': '宠物迷路了(连接失败)',
|
|
22
22
|
// 插件设置卡片(settings.plugin.item 席位)。
|
|
23
23
|
'settings.title': '宠物',
|
|
24
|
-
'settings.description': '
|
|
24
|
+
'settings.description': '选择宠物并调整它的显示布局。',
|
|
25
|
+
'settings.pet': '宠物',
|
|
26
|
+
'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
|
|
25
27
|
'settings.enabled': '启用宠物',
|
|
26
28
|
'settings.enabledHint': '关闭后隐藏宠物并停止轮询,可在设置里重新启用。',
|
|
27
29
|
'settings.visible': '显示宠物',
|
|
@@ -32,8 +34,6 @@ export const zh = {
|
|
|
32
34
|
'settings.rightHint': '距视口右边缘的水平内缩距离。',
|
|
33
35
|
'settings.bottom': '距底部(px)',
|
|
34
36
|
'settings.bottomHint': '距视口底边的垂直内缩距离。',
|
|
35
|
-
'settings.name': '名字',
|
|
36
|
-
'settings.nameHint': '宠物显示名,1–20 个字符。',
|
|
37
37
|
'settings.inherit': '继承',
|
|
38
38
|
'settings.on': '开',
|
|
39
39
|
'settings.off': '关',
|
|
@@ -62,11 +62,13 @@ export const en = {
|
|
|
62
62
|
'pet.rank': 'Affinity {rank}',
|
|
63
63
|
'pet.points': '{points} pts',
|
|
64
64
|
'pet.treats': 'Treats ×{n}',
|
|
65
|
-
'pet.state.loading': 'The
|
|
66
|
-
'pet.state.error': 'The
|
|
65
|
+
'pet.state.loading': 'The pet is on its way…',
|
|
66
|
+
'pet.state.error': 'The pet is lost (connection failed)',
|
|
67
67
|
// Plugin settings card (the `settings.plugin.item` seat).
|
|
68
68
|
'settings.title': 'Pet',
|
|
69
|
-
'settings.description': '
|
|
69
|
+
'settings.description': 'Pick a pet and tune its display layout.',
|
|
70
|
+
'settings.pet': 'Pet',
|
|
71
|
+
'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
|
|
70
72
|
'settings.enabled': 'Enable the pet',
|
|
71
73
|
'settings.enabledHint': 'When off, the pet hides and polling stops; re-enable it here.',
|
|
72
74
|
'settings.visible': 'Show the pet',
|
|
@@ -77,8 +79,6 @@ export const en = {
|
|
|
77
79
|
'settings.rightHint': 'Horizontal inset from the viewport right edge.',
|
|
78
80
|
'settings.bottom': 'Bottom inset (px)',
|
|
79
81
|
'settings.bottomHint': 'Vertical inset from the viewport bottom edge.',
|
|
80
|
-
'settings.name': 'Name',
|
|
81
|
-
'settings.nameHint': 'The pet\u2019s display name, 1\u201320 characters.',
|
|
82
82
|
'settings.inherit': 'Inherit',
|
|
83
83
|
'settings.on': 'On',
|
|
84
84
|
'settings.off': 'Off',
|
package/src/client/pet-store.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
10
10
|
import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-runtime/client'
|
|
11
11
|
import type { PetStateView } from '../service.ts'
|
|
12
12
|
import type { PetInteraction } from '../affinity.ts'
|
|
13
|
+
import type { PetDefinition } from '../registry.ts'
|
|
13
14
|
|
|
14
15
|
/** One transient reaction bubble on the pet. */
|
|
15
16
|
export interface PetFeedback {
|
|
@@ -25,6 +26,8 @@ export interface PetFeedback {
|
|
|
25
26
|
export interface PetUiState {
|
|
26
27
|
/** Latest host snapshot; null before the first successful fetch. */
|
|
27
28
|
snapshot: PetStateView | null
|
|
29
|
+
/** The registry list the host serves (atlas URLs + geometry + tracks). */
|
|
30
|
+
pets: PetDefinition[]
|
|
28
31
|
/** Fetch lifecycle. */
|
|
29
32
|
state: 'loading' | 'ready' | 'error'
|
|
30
33
|
/** Transport error message (for the debug surface), when any. */
|
|
@@ -37,6 +40,8 @@ export interface PetUiState {
|
|
|
37
40
|
export type PetUiActions = {
|
|
38
41
|
/** Replace the host snapshot (poll result). */
|
|
39
42
|
setSnapshot: (draft: PetUiState, snapshot: PetStateView) => void
|
|
43
|
+
/** Replace the registry list. */
|
|
44
|
+
setPets: (draft: PetUiState, pets: PetDefinition[]) => void
|
|
40
45
|
/** Mark the fetch lifecycle. */
|
|
41
46
|
setState: (draft: PetUiState, state: PetUiState['state'], error: string | null) => void
|
|
42
47
|
/** Show a reaction bubble. */
|
|
@@ -48,6 +53,7 @@ export function createPetStore(): EngineStoreHandle<PetUiState, PetUiActions> {
|
|
|
48
53
|
return defineStore({
|
|
49
54
|
init: (): PetUiState => ({
|
|
50
55
|
snapshot: null,
|
|
56
|
+
pets: [],
|
|
51
57
|
state: 'loading',
|
|
52
58
|
error: null,
|
|
53
59
|
feedback: null,
|
|
@@ -58,6 +64,9 @@ export function createPetStore(): EngineStoreHandle<PetUiState, PetUiActions> {
|
|
|
58
64
|
draft.state = 'ready'
|
|
59
65
|
draft.error = null
|
|
60
66
|
},
|
|
67
|
+
setPets: (draft, pets) => {
|
|
68
|
+
draft.pets = pets
|
|
69
|
+
},
|
|
61
70
|
setState: (draft, state, error) => {
|
|
62
71
|
draft.state = state
|
|
63
72
|
draft.error = error
|
|
@@ -20,6 +20,14 @@ export type FieldWrite =
|
|
|
20
20
|
export interface FieldSpec {
|
|
21
21
|
/** Field name inside the namespace section. */
|
|
22
22
|
field: string
|
|
23
|
+
/**
|
|
24
|
+
* Whether the Host treats this field as a secret and redacts its value from
|
|
25
|
+
* the read-back (role('secret') in the section schema). Redacted secrets are
|
|
26
|
+
* never compared against the draft on save; the field lands when the scope
|
|
27
|
+
* reports the write succeeded (its secret-set marker under the bridge), so
|
|
28
|
+
* a successful secret save is not misreported as failed.
|
|
29
|
+
*/
|
|
30
|
+
secret?: boolean
|
|
23
31
|
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
24
32
|
format: (value: unknown) => string
|
|
25
33
|
/**
|
|
@@ -61,6 +69,12 @@ export interface CardShell {
|
|
|
61
69
|
saving: boolean
|
|
62
70
|
/** Whether the last save did not land as staged; cleared by the next edit or save. */
|
|
63
71
|
failed: boolean
|
|
72
|
+
/**
|
|
73
|
+
* The rejection code/message the Host returned for the last failed save,
|
|
74
|
+
* surfaced next to the generic failure text. Undefined while no save has
|
|
75
|
+
* failed (or the failure carried no server reason).
|
|
76
|
+
*/
|
|
77
|
+
failedReason?: string
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
/** The write actions the card's slot entry injects. */
|
|
@@ -87,10 +101,52 @@ interface StagedEdit {
|
|
|
87
101
|
interface PlannedWrite {
|
|
88
102
|
/** Field this entry writes. */
|
|
89
103
|
field: string
|
|
104
|
+
/** The durable write this entry performs, described for a batched scope. */
|
|
105
|
+
op: BatchedWrite
|
|
90
106
|
/** Perform the write and report whether the Host holds the staged value afterwards. */
|
|
91
107
|
run: (() => Promise<boolean>) | undefined
|
|
92
108
|
}
|
|
93
109
|
|
|
110
|
+
/** One durable write a batched settings scope performs. */
|
|
111
|
+
export interface BatchedWrite {
|
|
112
|
+
/** Field this entry writes. */
|
|
113
|
+
field: string
|
|
114
|
+
/** set stores a value; unset drops the leaf. */
|
|
115
|
+
op: 'set' | 'unset'
|
|
116
|
+
/** Value for op set (absent for unset). */
|
|
117
|
+
value?: unknown
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Per-field outcome of one batched scope write. */
|
|
121
|
+
export interface BatchedFieldResult {
|
|
122
|
+
/** Field this entry writes. */
|
|
123
|
+
field: string
|
|
124
|
+
/** Whether the Host accepted this field's write (per the read-back view). */
|
|
125
|
+
landed: boolean
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Result of a batched scope write. The bridge scope posts every planned write
|
|
130
|
+
* in one /mutate so the Host validate hook judges baseURL+model together; a
|
|
131
|
+
* batched refusal fails the whole save rather than per-field.
|
|
132
|
+
*/
|
|
133
|
+
export interface BatchResult {
|
|
134
|
+
/** Whether the whole mutate was accepted. */
|
|
135
|
+
ok: boolean
|
|
136
|
+
/** Per-field success, in the request order (always present when ok). */
|
|
137
|
+
fields: BatchedFieldResult[]
|
|
138
|
+
/** Host rejection code (mutate refused). */
|
|
139
|
+
code?: string
|
|
140
|
+
/** Host rejection message (mutate refused). */
|
|
141
|
+
message?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The optional batch surface the bridge scope adds over the SettingsScope contract. */
|
|
145
|
+
interface BatchedSettingsScope {
|
|
146
|
+
/** Write every operation in one scope mutation, reporting per-field success. */
|
|
147
|
+
mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
|
|
148
|
+
}
|
|
149
|
+
|
|
94
150
|
/** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
|
|
95
151
|
export interface NumberConstraints {
|
|
96
152
|
/** The accepted value must be a whole number. */
|
|
@@ -129,6 +185,16 @@ export function textField(field: string): FieldSpec {
|
|
|
129
185
|
}
|
|
130
186
|
}
|
|
131
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A free-text field the Host treats as a secret and redacts from the read-back
|
|
190
|
+
* (role('secret') in the section schema). The card still edits it like text,
|
|
191
|
+
* but a save never compares the redacted value back and relies on the scope
|
|
192
|
+
* reporting the write landed.
|
|
193
|
+
*/
|
|
194
|
+
export function secretField(field: string): FieldSpec {
|
|
195
|
+
return { ...textField(field), secret: true }
|
|
196
|
+
}
|
|
197
|
+
|
|
132
198
|
/** A boolean field, edited through true/false draft text. */
|
|
133
199
|
export function booleanField(field: string): FieldSpec {
|
|
134
200
|
return {
|
|
@@ -170,6 +236,7 @@ export class CardForm<T> {
|
|
|
170
236
|
private readonly listeners = new Set<() => void>()
|
|
171
237
|
private saving = false
|
|
172
238
|
private failed = false
|
|
239
|
+
private failedReason: string | undefined
|
|
173
240
|
|
|
174
241
|
/** @param scope - the bound settings scope for this card's namespace. */
|
|
175
242
|
constructor(
|
|
@@ -199,6 +266,7 @@ export class CardForm<T> {
|
|
|
199
266
|
invalid: plan.some(item => item.run === undefined),
|
|
200
267
|
saving: this.saving,
|
|
201
268
|
failed: this.failed,
|
|
269
|
+
...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
|
|
202
270
|
}
|
|
203
271
|
}
|
|
204
272
|
|
|
@@ -229,6 +297,7 @@ export class CardForm<T> {
|
|
|
229
297
|
if (this.staged.size === 0 && !this.failed) return
|
|
230
298
|
this.staged.clear()
|
|
231
299
|
this.failed = false
|
|
300
|
+
this.failedReason = undefined
|
|
232
301
|
this.publish()
|
|
233
302
|
},
|
|
234
303
|
}
|
|
@@ -236,30 +305,57 @@ export class CardForm<T> {
|
|
|
236
305
|
|
|
237
306
|
/**
|
|
238
307
|
* Write every staged edit, then re-seed from what the Host accepted.
|
|
308
|
+
*
|
|
309
|
+
* When the scope carries the optional batch surface (the dsh-web-ui
|
|
310
|
+
* bridge scope), every planned write rides one mutation so cross-field
|
|
311
|
+
* validate hooks (baseURL+model) judge the batch as a unit instead of
|
|
312
|
+
* deadlocking on per-field writes. Otherwise the per-field loop runs.
|
|
313
|
+
* A field lands only when the Host reports it held the staged value; a
|
|
314
|
+
* landed field's draft is dropped, a failed one stays staged for the user.
|
|
239
315
|
* @returns settlement after every write and the read-back.
|
|
240
316
|
*/
|
|
241
317
|
async save(): Promise<void> {
|
|
242
318
|
const plan = this.plan()
|
|
243
|
-
const
|
|
244
|
-
if (plan.length === 0 || this.saving ||
|
|
319
|
+
const valid = plan.filter(item => item.run !== undefined)
|
|
320
|
+
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
321
|
+
const plannedWrites = valid.map(item => item.op)
|
|
245
322
|
// Snapshot the fields this save writes, so edits staged while it is in
|
|
246
323
|
// flight survive: only the staged keys this save actually wrote are cleared.
|
|
247
324
|
const fields = new Set(plan.map(item => item.field))
|
|
248
325
|
this.saving = true
|
|
249
326
|
this.failed = false
|
|
327
|
+
this.failedReason = undefined
|
|
250
328
|
this.publish()
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
329
|
+
const landed = new Set<string>()
|
|
330
|
+
const batch = this.batchedScope()
|
|
331
|
+
if (batch !== undefined) {
|
|
332
|
+
const result = await batch.mutate(plannedWrites)
|
|
333
|
+
if (result.ok) {
|
|
334
|
+
for (const field of result.fields) {
|
|
335
|
+
if (field.landed) landed.add(field.field)
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
this.failedReason = result.message
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
for (const item of valid) {
|
|
342
|
+
if (await item.run!()) landed.add(item.field)
|
|
343
|
+
}
|
|
254
344
|
}
|
|
255
|
-
|
|
256
|
-
|
|
345
|
+
for (const field of fields) {
|
|
346
|
+
if (landed.has(field)) this.staged.delete(field)
|
|
257
347
|
}
|
|
258
348
|
this.saving = false
|
|
259
|
-
this.failed =
|
|
349
|
+
this.failed = landed.size !== fields.size
|
|
260
350
|
this.publish()
|
|
261
351
|
}
|
|
262
352
|
|
|
353
|
+
/** The scope's batch surface when it supports one; undefined conservatively otherwise. */
|
|
354
|
+
private batchedScope(): BatchedSettingsScope | undefined {
|
|
355
|
+
const candidate = this.scope as unknown as BatchedSettingsScope | undefined
|
|
356
|
+
return typeof candidate?.mutate === 'function' ? candidate : undefined
|
|
357
|
+
}
|
|
358
|
+
|
|
263
359
|
/**
|
|
264
360
|
* Every staged edit a save would write. An entry whose draft is not a value
|
|
265
361
|
* its field accepts carries no write: the form is still dirty, and the save
|
|
@@ -272,14 +368,14 @@ export class CardForm<T> {
|
|
|
272
368
|
for (const [field, staged] of this.staged) {
|
|
273
369
|
const spec = this.specOf(field)
|
|
274
370
|
if (staged.clear) {
|
|
275
|
-
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
|
|
371
|
+
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
|
|
276
372
|
continue
|
|
277
373
|
}
|
|
278
374
|
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
279
375
|
const write = spec.parse(staged.text)
|
|
280
|
-
if (write === undefined) plan.push({ field, run: undefined })
|
|
281
|
-
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
|
282
|
-
else plan.push({ field, run: () => this.store(field, write.value) })
|
|
376
|
+
if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
|
|
377
|
+
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
|
|
378
|
+
else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
|
|
283
379
|
}
|
|
284
380
|
return plan
|
|
285
381
|
}
|
|
@@ -291,12 +387,18 @@ export class CardForm<T> {
|
|
|
291
387
|
|
|
292
388
|
private async store(field: string, value: unknown): Promise<boolean> {
|
|
293
389
|
await this.scope.set(field, value)
|
|
390
|
+
// A redacted secret never appears in the user layer read-back; judging it
|
|
391
|
+
// by value would misreport a successful secret save as failed. The bridge
|
|
392
|
+
// reports secret writes through its secret-set markers (batch path); on
|
|
393
|
+
// the per-field path the scope resolved, so the write is landed.
|
|
394
|
+
if (this.specOf(field).secret) return true
|
|
294
395
|
return this.userLayer()?.[field] === value
|
|
295
396
|
}
|
|
296
397
|
|
|
297
398
|
private stage(field: string, edit: StagedEdit): void {
|
|
298
399
|
this.staged.set(field, edit)
|
|
299
400
|
this.failed = false
|
|
401
|
+
this.failedReason = undefined
|
|
300
402
|
this.publish()
|
|
301
403
|
}
|
|
302
404
|
|
|
@@ -1,57 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 5 failed, 6 waiting, 7 running, 8 review
|
|
8
|
-
*
|
|
9
|
-
* Frame counts and per-frame durations are per-track definitions below; the
|
|
10
|
-
* whale-girl atlas is produced by the hatch-pet pipeline, so calibrate
|
|
11
|
-
* `TRACKS` against the actual run (`pet_request.json` frame counts) when the
|
|
12
|
-
* asset lands. Tracks that do not loop hand off to `fallback`.
|
|
2
|
+
* Spritesheet geometry helpers — parameterized by the pet definition the
|
|
3
|
+
* host serves over '/api/pet/pets', so the browser half renders any registry
|
|
4
|
+
* entry without per-pet code. The per-track tables (frames, durations, loop,
|
|
5
|
+
* fallback) also come from the registry; these helpers only place frames,
|
|
6
|
+
* guard track lengths, and map the fixed 9-row animation contract.
|
|
13
7
|
* @module @linxin666/dsh-pet/client/spritesheet
|
|
14
8
|
*/
|
|
15
9
|
|
|
16
10
|
import type { PetAnimation } from '../state.ts'
|
|
11
|
+
import type { PetCell, PetTrackDef } from '../registry.ts'
|
|
17
12
|
|
|
18
|
-
/**
|
|
19
|
-
export
|
|
20
|
-
export const FRAME_HEIGHT = 208
|
|
21
|
-
/** Columns per row (max frames per track). */
|
|
22
|
-
export const FRAME_COLUMNS = 8
|
|
13
|
+
/** Animation track shape the frame loop consumes. */
|
|
14
|
+
export type TrackDef = PetTrackDef
|
|
23
15
|
|
|
24
|
-
/**
|
|
25
|
-
export interface TrackDef {
|
|
26
|
-
/** Frame indices (columns) played in order; must be < FRAME_COLUMNS. */
|
|
27
|
-
frames: readonly number[]
|
|
28
|
-
/** Per-frame duration in ms; same length as frames. */
|
|
29
|
-
durations: readonly number[]
|
|
30
|
-
/** Whether the track loops; a non-looping track hands off to fallback. */
|
|
31
|
-
loop: boolean
|
|
32
|
-
/** Track to play after a non-looping track finishes. */
|
|
33
|
-
fallback?: PetAnimation
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Track definitions for the whale-girl. Durations are tuned for a soft,
|
|
38
|
-
* slow-healing feel (roughly 2.5× the earlier fast draft — the pet should
|
|
39
|
-
* breathe, not race); calibrate frame counts against the hatch-pet run when
|
|
40
|
-
* the asset lands (rows may carry 4–8 frames).
|
|
41
|
-
*/
|
|
42
|
-
export const TRACKS: Record<PetAnimation, TrackDef> = {
|
|
43
|
-
idle: { frames: [0, 1, 2, 3, 4, 5], durations: [400, 400, 500, 400, 400, 500], loop: true },
|
|
44
|
-
'running-right': { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [225, 225, 225, 225, 225, 225, 225, 225], loop: true },
|
|
45
|
-
'running-left': { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [225, 225, 225, 225, 225, 225, 225, 225], loop: true },
|
|
46
|
-
waving: { frames: [0, 1, 2, 3], durations: [350, 350, 350, 350], loop: true },
|
|
47
|
-
jumping: { frames: [0, 1, 2, 3, 4], durations: [300, 300, 300, 350, 350], loop: false, fallback: 'idle' },
|
|
48
|
-
failed: { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [450, 450, 450, 500, 550, 600, 450, 450], loop: false, fallback: 'idle' },
|
|
49
|
-
waiting: { frames: [0, 1, 2, 3, 4, 5], durations: [450, 450, 500, 450, 450, 500], loop: true },
|
|
50
|
-
running: { frames: [0, 1, 2, 3, 4, 5], durations: [250, 250, 250, 250, 250, 250], loop: true },
|
|
51
|
-
review: { frames: [0, 1, 2, 3, 4, 5], durations: [550, 550, 550, 550, 550, 550], loop: true },
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Row index of one animation track (mirrors state.ts rowOf). */
|
|
16
|
+
/** Row index of one animation track (the fixed 9-row contract). */
|
|
55
17
|
export function rowOfTrack(animation: PetAnimation): number {
|
|
56
18
|
const rows: Record<PetAnimation, number> = {
|
|
57
19
|
idle: 0,
|
|
@@ -74,8 +36,8 @@ export function rowOfTrack(animation: PetAnimation): number {
|
|
|
74
36
|
* atlas coordinates here would drift each frame by the scale factor and
|
|
75
37
|
* render torn/overlapping frames.
|
|
76
38
|
*/
|
|
77
|
-
export function framePosition(row: number, col: number, scale = 1): { x: number; y: number } {
|
|
78
|
-
return { x: -col *
|
|
39
|
+
export function framePosition(cell: PetCell, columns: number, row: number, col: number, scale = 1): { x: number; y: number } {
|
|
40
|
+
return { x: -col * cell.width * scale, y: -row * cell.height * scale }
|
|
79
41
|
}
|
|
80
42
|
|
|
81
43
|
/** Total duration of one track, ms. */
|
|
@@ -84,51 +46,13 @@ export function trackDuration(track: TrackDef): number {
|
|
|
84
46
|
}
|
|
85
47
|
|
|
86
48
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* @param image - the fully decoded spritesheet (natural size 1536×1872).
|
|
92
|
-
* @returns per-row frame counts, length 9.
|
|
93
|
-
*/
|
|
94
|
-
export function detectFrameCounts(image: HTMLImageElement): number[] {
|
|
95
|
-
const canvas = document.createElement('canvas')
|
|
96
|
-
canvas.width = image.naturalWidth
|
|
97
|
-
canvas.height = image.naturalHeight
|
|
98
|
-
const ctx = canvas.getContext('2d')
|
|
99
|
-
if (ctx === null) return Array.from({ length: 9 }, () => FRAME_COLUMNS)
|
|
100
|
-
ctx.drawImage(image, 0, 0)
|
|
101
|
-
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data
|
|
102
|
-
const counts: number[] = []
|
|
103
|
-
const stride = FRAME_COLUMNS * FRAME_WIDTH
|
|
104
|
-
const probeStep = 8
|
|
105
|
-
const margin = 12
|
|
106
|
-
for (let row = 0; row < 9; row++) {
|
|
107
|
-
let count = 0
|
|
108
|
-
for (let col = 0; col < FRAME_COLUMNS; col++) {
|
|
109
|
-
let hasContent = false
|
|
110
|
-
const x0 = col * FRAME_WIDTH
|
|
111
|
-
const y0 = row * FRAME_HEIGHT
|
|
112
|
-
for (let y = y0 + margin; y < y0 + FRAME_HEIGHT - margin && !hasContent; y += probeStep) {
|
|
113
|
-
for (let x = x0 + margin; x < x0 + FRAME_WIDTH - margin && !hasContent; x += probeStep) {
|
|
114
|
-
const idx = (y * stride + x) * 4
|
|
115
|
-
if ((data[idx + 3] ?? 0) > 8) hasContent = true
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (hasContent) count += 1
|
|
119
|
-
}
|
|
120
|
-
counts.push(count)
|
|
121
|
-
}
|
|
122
|
-
return counts
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Trim a track to the actual frame count of its row. A row with 0 detected
|
|
127
|
-
* frames degrades to the first frame (the atlas is still loading or corrupt)
|
|
128
|
-
* so the pet never renders blank.
|
|
49
|
+
* Trim a track to the actual frame count of its row (the manifest's per-row
|
|
50
|
+
* counts are authoritative; this is a last-line guard against a definition
|
|
51
|
+
* whose row count disagrees with its track table). A row with 0 detected
|
|
52
|
+
* frames degrades to the first frame so the pet never renders blank.
|
|
129
53
|
*/
|
|
130
54
|
export function trimTrack(track: TrackDef, frameCount: number): TrackDef {
|
|
131
|
-
const n = Math.max(1, Math.min(frameCount, track.frames.length))
|
|
55
|
+
const n = Math.max(1, Math.min(frameCount, track.frames.length, track.durations.length))
|
|
132
56
|
return {
|
|
133
57
|
frames: track.frames.slice(0, n),
|
|
134
58
|
durations: track.durations.slice(0, n),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { makePetSettingsSchema } from './index.ts'
|
|
3
|
+
|
|
4
|
+
describe('makePetSettingsSchema', () => {
|
|
5
|
+
it('resolves the selected pet and display defaults from an empty section', () => {
|
|
6
|
+
const schema = makePetSettingsSchema('whale-girl')
|
|
7
|
+
expect(schema({})).toMatchObject({
|
|
8
|
+
petId: 'whale-girl',
|
|
9
|
+
visible: true,
|
|
10
|
+
size: 160,
|
|
11
|
+
right: 24,
|
|
12
|
+
bottom: 20,
|
|
13
|
+
enabled: true,
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('accepts any petId string so a removed pet cannot brick the namespace', () => {
|
|
18
|
+
// The service clamps the value against the registry; the schema must
|
|
19
|
+
// never reject a stale stored selection outright.
|
|
20
|
+
const schema = makePetSettingsSchema('whale-girl')
|
|
21
|
+
expect(schema({ petId: 'dragon' }).petId).toBe('dragon')
|
|
22
|
+
})
|
|
23
|
+
})
|