@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
|
@@ -63,6 +63,8 @@ interface FakeClientLifecycle {
|
|
|
63
63
|
emitSettings(): void
|
|
64
64
|
sessionsListenerCount(): number
|
|
65
65
|
setEnabled(enabled: boolean): void
|
|
66
|
+
/** Namespaces the family settings binder was asked for (empty without one). */
|
|
67
|
+
boundNamespaces(): string[]
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
const activeLifecycles: FakeClientLifecycle[] = []
|
|
@@ -72,11 +74,19 @@ afterEach(() => {
|
|
|
72
74
|
document.body.replaceChildren()
|
|
73
75
|
})
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
interface FakeContextOptions {
|
|
78
|
+
/** Provide the dsh-web-settings family binder, as the group plugin does. */
|
|
79
|
+
familyBinder?: boolean
|
|
80
|
+
/** Initial value of the settings section the bound form answers with. */
|
|
81
|
+
enabled?: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fakeContext(options: FakeContextOptions = {}): FakeClientLifecycle {
|
|
76
85
|
const disposers: (() => void)[] = []
|
|
77
86
|
const settingsListeners = new Set<() => void>()
|
|
78
87
|
const sessionListeners = new Set<() => void>()
|
|
79
|
-
|
|
88
|
+
const boundNamespaces: string[] = []
|
|
89
|
+
let settingsValue: { enabled?: boolean } | undefined = options.enabled === undefined ? undefined : { enabled: options.enabled }
|
|
80
90
|
const scope = {
|
|
81
91
|
getSnapshot: () => ({
|
|
82
92
|
status: 'ready',
|
|
@@ -92,6 +102,12 @@ function fakeContext(): FakeClientLifecycle {
|
|
|
92
102
|
return () => { settingsListeners.delete(listener) }
|
|
93
103
|
},
|
|
94
104
|
}
|
|
105
|
+
const familyBinder = {
|
|
106
|
+
bind: (spec: { namespace: string }) => {
|
|
107
|
+
boundNamespaces.push(spec.namespace)
|
|
108
|
+
return scope
|
|
109
|
+
},
|
|
110
|
+
}
|
|
95
111
|
const ctx = {
|
|
96
112
|
effect: (fn: () => unknown, _label?: string) => {
|
|
97
113
|
const dispose = fn()
|
|
@@ -101,8 +117,10 @@ function fakeContext(): FakeClientLifecycle {
|
|
|
101
117
|
return cleanup
|
|
102
118
|
},
|
|
103
119
|
locale: { register: () => () => {} },
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
// The family binder is optional (dsh-web-settings may be absent); both
|
|
121
|
+
// answers are the shared per-entry form of this plugin's own profile entry.
|
|
122
|
+
get: (name: string) => name === 'webUiSettings' && options.familyBinder === true ? familyBinder : undefined,
|
|
123
|
+
configForms: { get: () => scope },
|
|
106
124
|
slots: {
|
|
107
125
|
// Cordis runs the factory when the slot mounts and its returned
|
|
108
126
|
// disposer when the fiber disposes; mirror that so slot content
|
|
@@ -139,6 +157,7 @@ function fakeContext(): FakeClientLifecycle {
|
|
|
139
157
|
},
|
|
140
158
|
sessionsListenerCount: () => sessionListeners.size,
|
|
141
159
|
setEnabled: (enabled: boolean) => { settingsValue = { enabled } },
|
|
160
|
+
boundNamespaces: () => boundNamespaces,
|
|
142
161
|
}
|
|
143
162
|
activeLifecycles.push(lifecycle)
|
|
144
163
|
return lifecycle
|
|
@@ -236,4 +255,18 @@ describe('pet client apply', () => {
|
|
|
236
255
|
expect(lifecycle.sessionsListenerCount()).toBe(1)
|
|
237
256
|
expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(1)
|
|
238
257
|
})
|
|
258
|
+
|
|
259
|
+
it('user gets the pet settings form through the family binder when the group plugin is mounted', () => {
|
|
260
|
+
// Given a client context that provides the dsh-web-settings family binder
|
|
261
|
+
// and answers 'disabled' through the form that binder returns
|
|
262
|
+
const lifecycle = fakeContext({ familyBinder: true, enabled: false })
|
|
263
|
+
|
|
264
|
+
// When the pet client plugin applies
|
|
265
|
+
apply(lifecycle.ctx)
|
|
266
|
+
|
|
267
|
+
// Then the card bound the 'pet' namespace through that binder...
|
|
268
|
+
expect(lifecycle.boundNamespaces()).toEqual(['pet'])
|
|
269
|
+
// ...and the pet surface followed the form it answered (disabled -> hidden)
|
|
270
|
+
expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(0)
|
|
271
|
+
})
|
|
239
272
|
})
|
package/src/client/index.ts
CHANGED
|
@@ -16,15 +16,18 @@
|
|
|
16
16
|
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
17
17
|
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
18
18
|
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
|
19
|
-
import type {
|
|
19
|
+
import type { ConfigForm, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
20
20
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
21
21
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
22
|
-
// Type-only: pulls the
|
|
22
|
+
// Type-only: pulls the shared configuration forms Context merge (ctx.configForms).
|
|
23
23
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
24
24
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
25
25
|
// Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
|
|
26
26
|
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
27
27
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
28
|
+
// Type-only: pulls the workspace plugin's Context merge (ctx.uiWorkspace), the
|
|
29
|
+
// multi-instance navigation face that replaced ISessions.open().
|
|
30
|
+
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
|
28
31
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
29
32
|
import type { PetGameplayVerbResult, PetInteractResult, PetStateView } from '../service.ts'
|
|
30
33
|
import type { PetInteraction } from '../affinity.ts'
|
|
@@ -40,6 +43,7 @@ import { frames2dRenderer } from './renderers/frames2d.ts'
|
|
|
40
43
|
import { registerPetUiTeardown, takeoverPetUiTeardown } from './ui-teardown.ts'
|
|
41
44
|
import { PetSettingsSection, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
|
|
42
45
|
import { NS, en, zh, t } from './locales.ts'
|
|
46
|
+
import { mainViewSessionId } from './main-session.ts'
|
|
43
47
|
import { reportDailyHeartbeat } from './telemetry.ts'
|
|
44
48
|
|
|
45
49
|
/** The host pet API as the browser sees it (same-origin JSON endpoints). */
|
|
@@ -54,7 +58,7 @@ interface PetHttpApi {
|
|
|
54
58
|
setPet(petId: string): Promise<{ ok: true; petId: string } | { ok: false; error: string }>
|
|
55
59
|
setSkin(skin?: string): Promise<{ ok: boolean; error?: string; skin?: string }>
|
|
56
60
|
gameplayTouch(zone?: string): Promise<PetGameplayVerbResult>
|
|
57
|
-
gameplaySetMode(mode:
|
|
61
|
+
gameplaySetMode(mode: string | null): Promise<PetGameplayVerbResult>
|
|
58
62
|
gameplayWorkTick(): Promise<PetGameplayVerbResult>
|
|
59
63
|
gameplayBuy(item: string): Promise<PetGameplayVerbResult>
|
|
60
64
|
}
|
|
@@ -94,11 +98,11 @@ const petApi: PetHttpApi = {
|
|
|
94
98
|
/** Poll interval for the host snapshot. */
|
|
95
99
|
const POLL_MS = 2000
|
|
96
100
|
|
|
97
|
-
/** Settings namespace the pet settings
|
|
101
|
+
/** Settings namespace of the pet's settings page. It is the profile entry id the Host serves a form for. */
|
|
98
102
|
const PET_SETTINGS_NS = 'pet'
|
|
99
103
|
|
|
100
104
|
/** Required services (sessions powers bubble-to-session navigation). */
|
|
101
|
-
export const inject = ['slots', 'locale', 'connection', '
|
|
105
|
+
export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote', 'sessions', 'uiWorkspace']
|
|
102
106
|
|
|
103
107
|
/** Re-exported for consumers that type against the injected face. */
|
|
104
108
|
export type { PetInjected, PetDockEntryProps } from './PetDockEntry.tsx'
|
|
@@ -111,12 +115,49 @@ export type { PetDefinition } from '../registry.ts'
|
|
|
111
115
|
declare module '@deepseek-ai/cordis' {
|
|
112
116
|
interface Context {
|
|
113
117
|
/**
|
|
114
|
-
* Optional
|
|
115
|
-
*
|
|
116
|
-
*
|
|
118
|
+
* Optional family binder provided by dsh-web-settings; absent when that
|
|
119
|
+
* group plugin is not installed, so callers fall back to the shared
|
|
120
|
+
* per-entry form.
|
|
117
121
|
*/
|
|
118
|
-
webUiSettings?: {
|
|
122
|
+
webUiSettings?: {
|
|
123
|
+
bind<S>(spec: { namespace: string; decode?: (section: unknown) => S | undefined }): ConfigForm<S>
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The configuration form the settings card stages and saves through: the
|
|
130
|
+
* family binder when dsh-web-settings is mounted, otherwise the shared form of
|
|
131
|
+
* this plugin's own profile entry.
|
|
132
|
+
*
|
|
133
|
+
* The two are not interchangeable by name. A plugin's settings ARE its own
|
|
134
|
+
* config on 0.1.7, so the form belongs to the profile entry that carries the
|
|
135
|
+
* plugin — and only the family binder knows which entry that is, because the
|
|
136
|
+
* family bundle renames child rows ('pet' becomes 'web-ui-pet' there) while a
|
|
137
|
+
* standalone install keeps the package's own id.
|
|
138
|
+
* @param ctx - client root context.
|
|
139
|
+
* @returns the form for the pet's settings page.
|
|
140
|
+
*/
|
|
141
|
+
const AGGREGATE_ENTRY_ID = 'web-ui-pet'
|
|
142
|
+
const PET_ENTRY_IDS: readonly string[] = [AGGREGATE_ENTRY_ID, 'ui-pet', PET_SETTINGS_NS]
|
|
143
|
+
|
|
144
|
+
function servedEntryId(forms: ConfigForms): string {
|
|
145
|
+
let served: readonly string[] | undefined
|
|
146
|
+
try {
|
|
147
|
+
served = forms.describe().getSnapshot().view?.namespaces.map(view => view.ns)
|
|
148
|
+
} catch {
|
|
149
|
+
served = undefined
|
|
150
|
+
}
|
|
151
|
+
if (!served || served.length === 0) return PET_SETTINGS_NS
|
|
152
|
+
return PET_ENTRY_IDS.find(id => served.includes(id)) ?? PET_SETTINGS_NS
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function petSettingsForm(ctx: ClientContext): ConfigForm<PetSettings> {
|
|
156
|
+
const binder = ctx.get('webUiSettings')
|
|
157
|
+
if (binder !== undefined && typeof binder.bind === 'function') {
|
|
158
|
+
return binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
|
|
119
159
|
}
|
|
160
|
+
return ctx.configForms.get<PetSettings>(servedEntryId(ctx.configForms))
|
|
120
161
|
}
|
|
121
162
|
|
|
122
163
|
/**
|
|
@@ -158,24 +199,23 @@ export function apply(ctx: ClientContext): void {
|
|
|
158
199
|
defaultPetRendererRegistry.register(live2dRenderer)
|
|
159
200
|
defaultPetRendererRegistry.register(frames2dRenderer)
|
|
160
201
|
|
|
161
|
-
const
|
|
162
|
-
const settingsScope = binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
|
|
202
|
+
const settingsForm = petSettingsForm(ctx)
|
|
163
203
|
const enabled = (): boolean => {
|
|
164
|
-
const snapshot =
|
|
204
|
+
const snapshot = settingsForm.getSnapshot()
|
|
165
205
|
return snapshot.status === 'ready'
|
|
166
206
|
? snapshot.value?.enabled ?? true
|
|
167
207
|
: snapshot.status === 'unavailable'
|
|
168
208
|
}
|
|
169
209
|
|
|
170
|
-
// First-level settings section: one staged form over the 'pet'
|
|
171
|
-
//
|
|
172
|
-
// the petId choices from the registry endpoint itself — the
|
|
173
|
-
// the available pets (built-in assets plus user dirs), so the
|
|
174
|
-
// ever shows installed pets. Installing new pets happens in the
|
|
175
|
-
// store.
|
|
176
|
-
const petSettings = new PetSettingsCardController(
|
|
210
|
+
// First-level settings section: one staged form over the 'pet' profile
|
|
211
|
+
// entry's configuration, registered as a top-level settings page. The
|
|
212
|
+
// controller loads the petId choices from the registry endpoint itself — the
|
|
213
|
+
// registry lists the available pets (built-in assets plus user dirs), so the
|
|
214
|
+
// section only ever shows installed pets. Installing new pets happens in the
|
|
215
|
+
// Workshop store.
|
|
216
|
+
const petSettings = new PetSettingsCardController(settingsForm)
|
|
177
217
|
// The section entry owns the controller: unregistering it (fiber disposal,
|
|
178
|
-
// hot reload) releases the
|
|
218
|
+
// hot reload) releases the form subscription through petSettings.dispose.
|
|
179
219
|
ctx.slots.inject('settings.section', () => {
|
|
180
220
|
try {
|
|
181
221
|
const unregister = ctx.slots.register({
|
|
@@ -224,19 +264,17 @@ export function apply(ctx: ClientContext): void {
|
|
|
224
264
|
const setState = petStore.actions.setState
|
|
225
265
|
const setFeedback = petStore.actions.setFeedback
|
|
226
266
|
|
|
227
|
-
// Clicking a session bubble jumps the GUI to that session; the
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// service types, whose Context
|
|
234
|
-
// face; pin the browser runtime's
|
|
267
|
+
// Clicking a session bubble jumps the GUI to that session; the
|
|
268
|
+
// catalog's main-view ownership marker tells which session the main view
|
|
269
|
+
// holds (the multi-instance replacement for the removed list.current), so
|
|
270
|
+
// the host can lead the bubble stack with it. A bubble can outlive its
|
|
271
|
+
// disposed session by one poll tick, and navigation rejects unknown ids,
|
|
272
|
+
// so consult the live list first. The pet's type program also loads the
|
|
273
|
+
// host-side dsh-session package through the service types, whose Context
|
|
274
|
+
// merge declares a different 'sessions' face; pin the browser runtime's
|
|
275
|
+
// outward face here.
|
|
235
276
|
const sessions = ctx.sessions as unknown as ISessions
|
|
236
|
-
const currentSessionId = (): string | undefined =>
|
|
237
|
-
const current = sessions.list.getSnapshot().current
|
|
238
|
-
return current === undefined ? undefined : String(current)
|
|
239
|
-
}
|
|
277
|
+
const currentSessionId = (): string | undefined => mainViewSessionId(sessions.list.getSnapshot().byId)
|
|
240
278
|
|
|
241
279
|
// The registry list is fetched lazily with retries baked into the poll
|
|
242
280
|
// cycle: until it lands, the dock entry renders nothing and every 2s
|
|
@@ -315,7 +353,9 @@ export function apply(ctx: ClientContext): void {
|
|
|
315
353
|
const openSession = (sessionId: string): void => {
|
|
316
354
|
const list = sessions.list.getSnapshot()
|
|
317
355
|
if ((list.byId as any)[sessionId] === undefined) return
|
|
318
|
-
|
|
356
|
+
// Navigation belongs to the workspace UI since the multi-instance Client
|
|
357
|
+
// Session model; the Session Controller no longer opens one.
|
|
358
|
+
ctx.uiWorkspace.openSession(sessionId as never)
|
|
319
359
|
}
|
|
320
360
|
|
|
321
361
|
const injected = (): PetInjected => ({
|
|
@@ -456,7 +496,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
456
496
|
// (issue #785): disposal drops the subscription and tears the UI down
|
|
457
497
|
// (terminal), so a hot-reloaded or re-injected bundle never leaves the
|
|
458
498
|
// previous React root, container, or poll loop behind on document.body.
|
|
459
|
-
const unsubscribeSettings =
|
|
499
|
+
const unsubscribeSettings = settingsForm.subscribe(syncUi)
|
|
460
500
|
ctx.effect(
|
|
461
501
|
() => () => {
|
|
462
502
|
unsubscribeSettings()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/main-session.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Main-view Session derivation for the multi-instance Client Session model.
|
|
4
|
+
*
|
|
5
|
+
* The Client Session Controller carries no global "current" selection: view
|
|
6
|
+
* selection belongs to the workspace UI, which owns the main-area reference and
|
|
7
|
+
* publishes it through the catalog's per-source ownership counts
|
|
8
|
+
* (`SessionSummary.retainedBy.mainView`). A plugin that needs the Session the
|
|
9
|
+
* main view shows derives it from that marker instead of the removed
|
|
10
|
+
* `SessionListState.current` field.
|
|
11
|
+
*
|
|
12
|
+
* The marker describes view ownership, not authority. It is the right input for
|
|
13
|
+
* presentation and for deciding which view a navigation targets; it is not a
|
|
14
|
+
* business fallback, because source counts never imply visibility or success.
|
|
15
|
+
* @module dsh-web/shared/main-session
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The catalog row subset this helper reads (a `SessionSummary` projection). */
|
|
19
|
+
export interface MainViewOwnedRow<Id extends string = string> {
|
|
20
|
+
/** Catalog row identity. */
|
|
21
|
+
readonly id: Id
|
|
22
|
+
/** Per-source local ownership counts; absent rows are never owned. */
|
|
23
|
+
readonly retainedBy?: Readonly<Partial<Record<string, number>>> | undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the Session the main view currently shows.
|
|
28
|
+
*
|
|
29
|
+
* Reads the catalog's rows rather than a per-id retain-info source: ownership
|
|
30
|
+
* counts ride the list snapshot, so this neither allocates observers nor opens
|
|
31
|
+
* history, and a subscription to the list still fires when the selection moves.
|
|
32
|
+
* @param byId - the session catalog's rows (`SessionListState.byId`), when available.
|
|
33
|
+
* @returns the main-view session id, or undefined when the main view shows none.
|
|
34
|
+
*/
|
|
35
|
+
export function mainViewSessionId<Id extends string>(
|
|
36
|
+
byId: Readonly<Record<Id, MainViewOwnedRow<Id> | undefined>> | undefined | null,
|
|
37
|
+
): Id | undefined {
|
|
38
|
+
if (byId === undefined || byId === null) return undefined
|
|
39
|
+
for (const row of Object.values<MainViewOwnedRow<Id> | undefined>(byId)) {
|
|
40
|
+
if (row !== undefined && (row.retainedBy?.mainView ?? 0) > 0) return row.id
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* card-store pattern.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type {
|
|
11
|
+
import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
12
12
|
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
13
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
14
14
|
|
|
@@ -114,7 +114,7 @@ interface PlannedWrite {
|
|
|
114
114
|
judge: (() => boolean) | undefined
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/** One durable write inside the save's atomic
|
|
117
|
+
/** One durable write inside the save's atomic form mutation. */
|
|
118
118
|
export interface BatchedWrite {
|
|
119
119
|
/** Field this entry writes. */
|
|
120
120
|
field: string
|
|
@@ -211,34 +211,34 @@ export class CardForm<T> {
|
|
|
211
211
|
private readonly specs: Map<string, FieldSpec>
|
|
212
212
|
private readonly staged = new Map<string, StagedEdit>()
|
|
213
213
|
private readonly listeners = new Set<() => void>()
|
|
214
|
-
/** The
|
|
215
|
-
private readonly
|
|
214
|
+
/** The form subscription installed in the constructor; released by dispose(). */
|
|
215
|
+
private readonly disposeForm: () => void
|
|
216
216
|
private disposed = false
|
|
217
217
|
private saving = false
|
|
218
218
|
private failed = false
|
|
219
219
|
private failedReason: string | undefined
|
|
220
220
|
|
|
221
|
-
/** @param scope - the bound
|
|
221
|
+
/** @param scope - the bound configuration form for this card's namespace. */
|
|
222
222
|
constructor(
|
|
223
|
-
private readonly scope:
|
|
223
|
+
private readonly scope: ConfigForm<T>,
|
|
224
224
|
specs: FieldSpec[],
|
|
225
225
|
) {
|
|
226
226
|
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
227
|
-
this.
|
|
227
|
+
this.disposeForm = scope.subscribe(() => { this.publish() })
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* Release the
|
|
231
|
+
* Release the form subscription and every bound store listener. The card
|
|
232
232
|
* must call this on teardown; later calls are no-ops.
|
|
233
233
|
*/
|
|
234
234
|
dispose(): void {
|
|
235
235
|
if (this.disposed) return
|
|
236
236
|
this.disposed = true
|
|
237
|
-
this.
|
|
237
|
+
this.disposeForm()
|
|
238
238
|
this.listeners.clear()
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
/** Publish a projection of this form, rebuilt whenever the
|
|
241
|
+
/** Publish a projection of this form, rebuilt whenever the form or a draft changes. */
|
|
242
242
|
bind<S>(project: () => S): SnapshotStore<S> {
|
|
243
243
|
const store = createSnapshotStore(project())
|
|
244
244
|
this.listeners.add(() => { store.set(project()) })
|
|
@@ -295,19 +295,19 @@ export class CardForm<T> {
|
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
/**
|
|
298
|
-
* Write every staged edit in one atomic
|
|
298
|
+
* Write every staged edit in one atomic form mutation, then re-seed from
|
|
299
299
|
* what the Host accepted.
|
|
300
300
|
*
|
|
301
301
|
* The whole batch rides one mutate, so cross-field validate hooks
|
|
302
302
|
* (baseURL+model) judge it as a unit: the Host either applies every write
|
|
303
|
-
* or refuses the batch. The
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* settled snapshot back
|
|
307
|
-
* fails the whole save. A
|
|
308
|
-
*
|
|
309
|
-
* message. A save that did not land keeps its drafts, so
|
|
310
|
-
* correct them instead of retyping.
|
|
303
|
+
* or refuses the batch. The form contract answers a refusal or a skipped
|
|
304
|
+
* write with `false` (it recovers with a fresh Host view instead of
|
|
305
|
+
* throwing), so the outcome is judged twice: the answer itself, and then the
|
|
306
|
+
* settled snapshot read back one planned write at a time. One missed write
|
|
307
|
+
* fails the whole save. A transport that rejects instead (the dsh-web bridge
|
|
308
|
+
* controller on a dead connection) reports through the same failure path
|
|
309
|
+
* with its rejection message. A save that did not land keeps its drafts, so
|
|
310
|
+
* the user can correct them instead of retyping.
|
|
311
311
|
* @returns settlement after the mutation and the read-back.
|
|
312
312
|
*/
|
|
313
313
|
async save(): Promise<void> {
|
|
@@ -323,30 +323,33 @@ export class CardForm<T> {
|
|
|
323
323
|
this.failed = false
|
|
324
324
|
this.failedReason = undefined
|
|
325
325
|
this.publish()
|
|
326
|
-
// One atomic namespace mutation: the
|
|
327
|
-
//
|
|
326
|
+
// One atomic namespace mutation: the form contract takes ordered path
|
|
327
|
+
// operations, so the whole staged batch is validated, persisted, and
|
|
328
328
|
// recovered together — either every write lands or none does.
|
|
329
329
|
const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
|
|
330
330
|
? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
|
|
331
331
|
: { op: 'unset', path: [item.field] })
|
|
332
332
|
let failedReason: string | undefined
|
|
333
|
+
let accepted = false
|
|
333
334
|
try {
|
|
334
|
-
await this.scope.mutate(ops)
|
|
335
|
+
accepted = await this.scope.mutate(ops)
|
|
335
336
|
} catch (error) {
|
|
336
337
|
failedReason = error instanceof Error ? error.message : String(error)
|
|
337
338
|
}
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// judge every planned write against the settled
|
|
341
|
-
// atomic, so one missed write fails the
|
|
342
|
-
|
|
339
|
+
// `false` is the contract's refusal/skip answer, and the form still
|
|
340
|
+
// resolves after a refusal (it recovers with a fresh view), so the answer
|
|
341
|
+
// alone is not enough: judge every planned write against the settled
|
|
342
|
+
// snapshot as well. The mutation is atomic, so one missed write fails the
|
|
343
|
+
// whole save and keeps the drafts.
|
|
344
|
+
const landed = accepted && failedReason === undefined && valid.every(item => item.judge())
|
|
343
345
|
for (const [field, before] of pending) {
|
|
344
346
|
if (landed && this.staged.get(field) === before) this.staged.delete(field)
|
|
345
347
|
}
|
|
346
348
|
this.saving = false
|
|
347
349
|
this.failed = !landed
|
|
348
|
-
// A
|
|
349
|
-
// generic failure copy; a rejecting
|
|
350
|
+
// A refused or unlanded batch carries no server reason: the card surfaces
|
|
351
|
+
// its generic failure copy; a rejecting transport (the bridge) adds its
|
|
352
|
+
// message.
|
|
350
353
|
this.failedReason = failedReason
|
|
351
354
|
this.publish()
|
|
352
355
|
}
|
|
@@ -381,7 +384,7 @@ export class CardForm<T> {
|
|
|
381
384
|
*/
|
|
382
385
|
private landedSet(field: string, value: unknown): boolean {
|
|
383
386
|
// A redacted secret never appears in any wire view layer: the Host strips
|
|
384
|
-
// role('secret') fields and reports them through a sidecar the
|
|
387
|
+
// role('secret') fields and reports them through a sidecar the form
|
|
385
388
|
// snapshot does not expose, so there is nothing to compare the draft
|
|
386
389
|
// against. Settling is the only signal the form has; the rest of the
|
|
387
390
|
// batch, when one exists, still carries the atomic verdict by read-back.
|
|
@@ -412,7 +415,7 @@ export class CardForm<T> {
|
|
|
412
415
|
return spec
|
|
413
416
|
}
|
|
414
417
|
|
|
415
|
-
private snapshotOf():
|
|
418
|
+
private snapshotOf(): ConfigFormSnapshot<T> {
|
|
416
419
|
return this.scope.getSnapshot()
|
|
417
420
|
}
|
|
418
421
|
|
package/src/event-projection.ts
CHANGED
|
@@ -132,9 +132,11 @@ export function projectOfficialEvent(
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
case 'tool/result': {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
135
|
+
// Session format V4 moved the call identity and the outcome onto the
|
|
136
|
+
// tool-role message root; the content blocks no longer carry either.
|
|
137
|
+
const { message } = event.data
|
|
138
|
+
const callId = String(message.toolCallId)
|
|
139
|
+
const failed = event.data.error !== undefined || message.isError === true
|
|
138
140
|
const wasTest = runtime.testCalls.delete(callId)
|
|
139
141
|
runtime.activeTools.delete(callId)
|
|
140
142
|
runtime.stepHadFailure ||= failed
|
package/src/gameplay.test.ts
CHANGED
|
@@ -8,8 +8,12 @@ import { parsePetManifest } from './manifest-v2.ts'
|
|
|
8
8
|
import {
|
|
9
9
|
applyGameplayEffects,
|
|
10
10
|
clampGameplay,
|
|
11
|
+
declaredModes,
|
|
12
|
+
isDeclaredMode,
|
|
11
13
|
drawLotteryTier,
|
|
12
14
|
initialGameplayState,
|
|
15
|
+
modeRestoreOf,
|
|
16
|
+
modeStateOf,
|
|
13
17
|
rollTouchBranch,
|
|
14
18
|
rollWorkOutcome,
|
|
15
19
|
settleGameplay,
|
|
@@ -28,7 +32,7 @@ function mikuManifest(gameplay: Record<string, unknown>): Record<string, unknown
|
|
|
28
32
|
frames2d: {
|
|
29
33
|
dir: 'thumb',
|
|
30
34
|
tracks: {
|
|
31
|
-
idle: {}, drag: {}, standup: {}, sleep: {}, work: {}, success: {}, fail: {},
|
|
35
|
+
idle: {}, drag: {}, standup: {}, sleep: {}, work: {}, success: {}, fail: {}, bath: {},
|
|
32
36
|
happy: {}, shy: {}, flirty: {}, angry: {}, scratch: {}, blink1: {}, blink2: {}, eat: {}, shop: {},
|
|
33
37
|
},
|
|
34
38
|
phases: { idle: 'idle', done: 'success', failed: 'fail' },
|
|
@@ -148,6 +152,94 @@ describe('gameplay manifest', () => {
|
|
|
148
152
|
expect(parsePetManifest(bare, 'mem').ok).toBe(false)
|
|
149
153
|
})
|
|
150
154
|
|
|
155
|
+
it('user declares extra named modes and sees them listed after sleep', () => {
|
|
156
|
+
// Given a manifest that adds two named modes; when it is parsed; then both
|
|
157
|
+
// modes survive and the declared list appends them after sleep.
|
|
158
|
+
const withModes = mikuManifest({
|
|
159
|
+
...FULL_GAMEPLAY,
|
|
160
|
+
modes: {
|
|
161
|
+
bath: { state: 'bath', label: 'Bath', activeLabel: 'Bathing', restore: { stat: 'mood', amount: 3, intervalMs: 1000 } },
|
|
162
|
+
play: { state: 'happy' },
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
const result = parsePetManifest(withModes, 'mem')
|
|
166
|
+
expect(result.ok).toBe(true)
|
|
167
|
+
if (!result.ok) return
|
|
168
|
+
const gameplay = result.manifest.gameplay!
|
|
169
|
+
expect(gameplay.modes?.bath).toEqual({
|
|
170
|
+
state: 'bath', label: 'Bath', activeLabel: 'Bathing',
|
|
171
|
+
restore: { stat: 'mood', amount: 3, intervalMs: 1000 },
|
|
172
|
+
})
|
|
173
|
+
expect(gameplay.modes?.play).toEqual({ state: 'happy' })
|
|
174
|
+
expect(declaredModes(gameplay)).toEqual(['sleep', 'bath', 'play'])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('user declares a roam block and malformed ones are rejected', () => {
|
|
178
|
+
// Given a valid roam block; when it is parsed; then it round-trips while
|
|
179
|
+
// malformed variants fail closed.
|
|
180
|
+
const roam = { state: 'happy', intervalMs: 12_000, probability: 0.5, distanceMin: 80, distanceMax: 220, speed: 90 }
|
|
181
|
+
const parsed = parsePetManifest(mikuManifest({ ...FULL_GAMEPLAY, roam }), 'mem')
|
|
182
|
+
expect(parsed.ok).toBe(true)
|
|
183
|
+
if (!parsed.ok) return
|
|
184
|
+
expect(parsed.manifest.gameplay?.roam).toEqual(roam)
|
|
185
|
+
expect(parsePetManifest(mikuManifest({ roam: { ...roam, state: 'ghost' } }), 'mem').ok).toBe(false)
|
|
186
|
+
expect(parsePetManifest(mikuManifest({ roam: { ...roam, distanceMin: 300, distanceMax: 100 } }), 'mem').ok).toBe(false)
|
|
187
|
+
expect(parsePetManifest(mikuManifest({ roam: { ...roam, probability: 0 } }), 'mem').ok).toBe(false)
|
|
188
|
+
expect(parsePetManifest(mikuManifest({ roam: { ...roam, speed: 0 } }), 'mem').ok).toBe(false)
|
|
189
|
+
expect(parsePetManifest(mikuManifest({ roam: { ...roam, sparkle: true } }), 'mem').ok).toBe(false)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('user restricts the roam directions to a declared subset', () => {
|
|
193
|
+
// Given a roam block; when directions are omitted or restricted; then the
|
|
194
|
+
// default four apply and a bad list fails closed.
|
|
195
|
+
const roam = { state: 'happy', intervalMs: 15_000, probability: 0.2, distanceMin: 80, distanceMax: 220, speed: 90 }
|
|
196
|
+
const plain = parsePetManifest(mikuManifest({ ...FULL_GAMEPLAY, roam }), 'mem')
|
|
197
|
+
expect(plain.ok).toBe(true)
|
|
198
|
+
if (!plain.ok) return
|
|
199
|
+
expect(plain.manifest.gameplay?.roam?.directions).toBeUndefined()
|
|
200
|
+
const restricted = parsePetManifest(mikuManifest({ ...FULL_GAMEPLAY, roam: { ...roam, directions: ['left', 'right'] } }), 'mem')
|
|
201
|
+
expect(restricted.ok).toBe(true)
|
|
202
|
+
if (!restricted.ok) return
|
|
203
|
+
expect(restricted.manifest.gameplay?.roam?.directions).toEqual(['left', 'right'])
|
|
204
|
+
expect(parsePetManifest(mikuManifest({ roam: { ...roam, directions: [] } }), 'mem').ok).toBe(false)
|
|
205
|
+
expect(parsePetManifest(mikuManifest({ roam: { ...roam, directions: ['left', 'left'] } }), 'mem').ok).toBe(false)
|
|
206
|
+
expect(parsePetManifest(mikuManifest({ roam: { ...roam, directions: ['sideways'] } }), 'mem').ok).toBe(false)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('user drops a persisted mode the manifest no longer declares', () => {
|
|
210
|
+
// Given a manifest with one extra mode; when the persisted ids are checked;
|
|
211
|
+
// then only declared ids (never prototype keys) count.
|
|
212
|
+
const parsed = parsePetManifest(mikuManifest({
|
|
213
|
+
...FULL_GAMEPLAY,
|
|
214
|
+
modes: { bath: { state: 'happy' } },
|
|
215
|
+
}), 'mem')
|
|
216
|
+
expect(parsed.ok).toBe(true)
|
|
217
|
+
if (!parsed.ok) return
|
|
218
|
+
const def = parsed.manifest.gameplay!
|
|
219
|
+
expect(isDeclaredMode(def, 'work')).toBe(true)
|
|
220
|
+
expect(isDeclaredMode(def, 'sleep')).toBe(true)
|
|
221
|
+
expect(isDeclaredMode(def, 'bath')).toBe(true)
|
|
222
|
+
// A mode that was persisted before the manifest dropped it is not declared.
|
|
223
|
+
expect(isDeclaredMode(def, 'wash')).toBe(false)
|
|
224
|
+
expect(isDeclaredMode(def, 'ghost')).toBe(false)
|
|
225
|
+
// Object.prototype members must never count as declared modes.
|
|
226
|
+
expect(isDeclaredMode(def, 'constructor')).toBe(false)
|
|
227
|
+
expect(isDeclaredMode(def, 'toString')).toBe(false)
|
|
228
|
+
expect(modeStateOf(def, 'constructor')).toBeUndefined()
|
|
229
|
+
expect(modeRestoreOf(def, 'constructor')).toBeUndefined()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('user cannot declare malformed or reserved extra modes', () => {
|
|
233
|
+
// Given reserved and malformed mode blocks; when each is parsed; then every
|
|
234
|
+
// one of them fails closed.
|
|
235
|
+
expect(parsePetManifest(mikuManifest({ modes: { sleep: { state: 'bath' } } }), 'mem').ok).toBe(false)
|
|
236
|
+
expect(parsePetManifest(mikuManifest({ modes: { work: { state: 'bath' } } }), 'mem').ok).toBe(false)
|
|
237
|
+
expect(parsePetManifest(mikuManifest({ modes: { bath: { state: 'ghost' } } }), 'mem').ok).toBe(false)
|
|
238
|
+
expect(parsePetManifest(mikuManifest({ modes: { bath: { state: 'bath', sparkle: true } } }), 'mem').ok).toBe(false)
|
|
239
|
+
expect(parsePetManifest(mikuManifest({ modes: { bath: { state: 'bath', restore: { stat: 'ghost', amount: 3, intervalMs: 1000 } } } }), 'mem').ok).toBe(false)
|
|
240
|
+
expect(parsePetManifest(mikuManifest({ modes: { bath: { state: 'bath', restore: { stat: 'mood', amount: 3, intervalMs: 10 } } } }), 'mem').ok).toBe(false)
|
|
241
|
+
})
|
|
242
|
+
|
|
151
243
|
it('rejects bad ranges (tickMs, decay, zone slices, stateMs)', () => {
|
|
152
244
|
const badTick = mikuManifest({ work: { state: 'work', successState: 'success', failState: 'fail', tickMs: 50, successProbability: 0.5 } })
|
|
153
245
|
expect(parsePetManifest(badTick, 'mem').ok).toBe(false)
|
|
@@ -185,6 +277,51 @@ describe('gameplay engine', () => {
|
|
|
185
277
|
expect(state.settledAt).toBe(600_000)
|
|
186
278
|
})
|
|
187
279
|
|
|
280
|
+
it('user activates a declared extra mode and its restore ticks', () => {
|
|
281
|
+
// Given a manifest with a bath mode; when the pet sits in it; then the mode
|
|
282
|
+
// restore pays mood on its own interval.
|
|
283
|
+
const parsed = parsePetManifest(mikuManifest({
|
|
284
|
+
...FULL_GAMEPLAY,
|
|
285
|
+
modes: { bath: { state: 'bath', label: 'Bath', restore: { stat: 'mood', amount: 3, intervalMs: 1000 } } },
|
|
286
|
+
}), 'mem')
|
|
287
|
+
if (!parsed.ok) throw new Error('fixture must parse')
|
|
288
|
+
const bathDef = parsed.manifest.gameplay!
|
|
289
|
+
expect(modeStateOf(bathDef, 'bath')).toBe('bath')
|
|
290
|
+
expect(modeRestoreOf(bathDef, 'bath')).toEqual({ stat: 'mood', amount: 3, intervalMs: 1000 })
|
|
291
|
+
expect(modeRestoreOf(bathDef, 'work')).toBeUndefined()
|
|
292
|
+
expect(modeRestoreOf(bathDef, null)).toBeUndefined()
|
|
293
|
+
|
|
294
|
+
const state = initialGameplayState(bathDef, 0)
|
|
295
|
+
state.stats.mood = 10
|
|
296
|
+
state.mode = 'bath'
|
|
297
|
+
// 5 s of bath: 5 x 3 mood, minus 5 s of the 0.5/min baseline decay
|
|
298
|
+
settleGameplay(state, bathDef, 5000, { sessionActive: true })
|
|
299
|
+
expect(state.stats.mood).toBeCloseTo(10 - (5 / 60) * 0.5 + 15, 4)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('user keeps a 1 s mode cadence across a fast polling interval', () => {
|
|
303
|
+
// Given a fast 200 ms poll loop; when ten seconds elapse; then the mode
|
|
304
|
+
// still pays exactly ten ticks with no drift.
|
|
305
|
+
const parsed = parsePetManifest(mikuManifest({
|
|
306
|
+
...FULL_GAMEPLAY,
|
|
307
|
+
modes: { bath: { state: 'bath', restore: { stat: 'mood', amount: 3, intervalMs: 1000 } } },
|
|
308
|
+
}), 'mem')
|
|
309
|
+
if (!parsed.ok) throw new Error('fixture must parse')
|
|
310
|
+
const bathDef = parsed.manifest.gameplay!
|
|
311
|
+
// Start above zero: a stat parked at 0 is exempt from decay, which would
|
|
312
|
+
// otherwise hide part of the decay side of the balance.
|
|
313
|
+
const state = initialGameplayState(bathDef, 0)
|
|
314
|
+
state.stats.mood = 50
|
|
315
|
+
state.mode = 'bath'
|
|
316
|
+
let now = 0
|
|
317
|
+
for (let i = 0; i < 50; i++) {
|
|
318
|
+
now += 200
|
|
319
|
+
settleGameplay(state, bathDef, now, { sessionActive: true })
|
|
320
|
+
}
|
|
321
|
+
// 10 s of 200 ms polls still pays exactly 10 x 3 mood (carry, no drift)
|
|
322
|
+
expect(state.stats.mood).toBeCloseTo(50 + 30 - (10 / 60) * 0.5, 4)
|
|
323
|
+
})
|
|
324
|
+
|
|
188
325
|
it('preserves remainder carry across frequent polling intervals for passive income and sleep restore (#1478)', () => {
|
|
189
326
|
const state = initialGameplayState(def, 0)
|
|
190
327
|
state.stats.energy = 20
|